diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 369a122a5..b3dd9d5ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: os: windows-2022 runtime: win-arm64 - name: macOS (Intel) - os: macos-13 + os: macos-15-intel runtime: osx-x64 - name: macOS (Apple Silicon) os: macos-latest @@ -39,11 +39,13 @@ jobs: 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' run: | @@ -73,7 +75,7 @@ jobs: 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 index 5e75fb2a3..0640d19e9 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -13,12 +13,14 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 + with: + submodules: true - name: Set up .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Run formatting check - run: dotnet format --verify-no-changes + 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 c5970870b..76d7be77f 100644 --- a/.github/workflows/localization-check.yml +++ b/.github/workflows/localization-check.yml @@ -13,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 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 37d8afbab..0845774fb 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -15,25 +15,25 @@ jobs: 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: bash + shell: pwsh env: VERSION: ${{ inputs.version }} RUNTIME: ${{ matrix.runtime }} - run: ./build/scripts/package.windows.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: @@ -44,9 +44,9 @@ jobs: 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 @@ -59,12 +59,12 @@ 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: @@ -76,7 +76,7 @@ jobs: runtime: [linux-x64, linux-arm64] steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download package dependencies run: | export DEBIAN_FRONTEND=noninteractive @@ -84,7 +84,7 @@ jobs: 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 @@ -98,7 +98,7 @@ jobs: 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: | @@ -106,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 e61e608b0..816870a02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: contents: write steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Create release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -40,7 +40,7 @@ jobs: VERSION: ${{ needs.version.outputs.version }} 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 e686a5342..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/ @@ -39,3 +44,4 @@ 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 442ce085e..4d00388b9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2025 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 diff --git a/README.md b/README.md index ac3104a17..38a7e99fa 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,26 @@ [![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/Italiano/Português/Русский/Українська/简体中文/繁體中文/日本語/தமிழ் (Tamil) +* Deutsch/English/Español/Bahasa Indonesia/Français/Italiano/Português/Русский/Українська/简体中文/繁體中文/日本語/தமிழ் (Tamil)/한국어 * Built-in light/dark themes * Customize theme * Visual commit graph @@ -43,42 +57,34 @@ * Issue Link * Workspace * Custom Action -* Using AI to generate commit message (C# port of [anjerodev/commitollama](https://github.com/anjerodev/commitollama)) +* 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 - -You can find the current translation status in [TRANSLATION.md](https://github.com/sourcegit-scm/sourcegit/blob/develop/TRANSLATION.md) - ## How to Use **To use this tool, you need to install Git(>=2.25.1) first.** You can download the latest stable from [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) or download workflow artifacts from [GitHub Actions](https://github.com/sourcegit-scm/sourcegit/actions) to try this app based on latest commits. -This software creates a folder `$"{System.Environment.SpecialFolder.ApplicationData}/SourceGit"`, which is platform-dependent, to store user settings, downloaded avatars and crash logs. +This software creates a folder, which is platform-dependent, to store user settings, downloaded avatars and crash logs. -| OS | PATH | -|---------|-----------------------------------------------------| -| Windows | `%APPDATA%\SourceGit` | -| Linux | `${HOME}/.config/SourceGit` or `${HOME}/.sourcegit` | -| macOS | `${HOME}/Library/Application Support/SourceGit` | +| OS | PATH | +|---------|-------------------------------------------| +| Windows | `%APPDATA%\SourceGit` | +| Linux | `~/.sourcegit` | +| macOS | `~/Library/Application Support/SourceGit` | > [!TIP] > * You can open this data storage directory from the main menu `Open Data Storage Directory`. -> * You can create a `data` folder next to the `SourceGit` executable to force this app to store data (user settings, downloaded avatars and crash logs) into it (Portable-Mode). Only works on Windows. +> * 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 `scoop` with follow commands: ```shell scoop bucket add extras @@ -86,17 +92,25 @@ For **Windows** users: ``` * 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: ```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. @@ -106,6 +120,7 @@ For **Linux** users: `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 @@ -125,11 +140,52 @@ For **Linux** users: ``` 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) is installed on your Linux. +* Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) or [git-credential-libsecret](https://pkgs.org/search/?q=git-credential-libsecret) is installed on your Linux. * Maybe you need to set environment variable `AVALONIA_SCREEN_SCALE_FACTORS`. See https://github.com/AvaloniaUI/Avalonia/wiki/Configuring-X11-per-monitor-DPI. * If you can NOT type accented characters, such as `ê`, `ó`, try to set the environment variable `AVALONIA_IM_MODULE` to `none`. +## Commandline arguments + +Users can also launcher `SourceGit` from commandline. Usage: + +``` + // Open repository in existing `SourceGit` instance or a new one + --history // Launch `SourceGit` to see the history of a file or dir + --blame // Launch `SourceGit` to blame a file (HEAD version only) +``` + ## OpenAI This software supports using OpenAI or other AI service that has an OpenAI compatible HTTP API to generate commit message. You need configurate the service in `Preference` window. @@ -153,43 +209,59 @@ This app supports open repository in external tools listed in the table below. | Visual Studio Code - Insiders | YES | YES | YES | | VSCodium | YES | YES | YES | | Cursor | YES | YES | YES | -| Fleet | 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 storage directory 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 - -* Dark Theme - - ![Theme Dark](./screenshots/theme_dark.png) - -* Light Theme - - ![Theme Light](./screenshots/theme_light.png) +## Conventional Commit Helper -* Custom +You can define your own conventional commit types (per-repository) by following steps: - You can find custom themes from [sourcegit-theme](https://github.com/sourcegit-scm/sourcegit-theme.git). And welcome to share your own themes. +1. Create a json file with your own conventional commit type definitions. For example: +```json +[ + { + "Name": "New Feature", + "Type": "Feature", + "Description": "Adding a new feature", + "PrefillShortDesc": "this is a test" + }, + { + "Name": "Bug Fixes", + "Type": "Fix", + "Description": "Fixing a bug" + } +] +``` +2. Configure the `Conventional Commit Types` in repository configuration window. ## Contributing Everyone is welcome to submit a PR. Please make sure your PR is based on the latest `develop` branch and the target branch of PR is `develop`. +This project has a submodule in `depends/AvaloniaEdit` which is a custom fork of [Official AvaloniaEdit](https://github.com/AvaloniaUI/AvaloniaEdit). +Please make sure it is initialized - enable `--recurse-submodules` option while cloning or run `git submodule update --init` after cloned. + In short, here are the commands to get started once [.NET tools are installed](https://dotnet.microsoft.com/en-us/download): ```sh @@ -203,6 +275,27 @@ 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 dad5a4757..000000000 --- a/SourceGit.sln +++ /dev/null @@ -1,123 +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 - .github\workflows\format-check.yml = .github\workflows\format-check.yml - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{49A7C2D6-558C-4FAA-8F5D-EEE81497AED7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "files", "files", "{3AB707DB-A02C-4AFC-BF12-D7DF2B333BAC}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .gitattributes = .gitattributes - .gitignore = .gitignore - global.json = global.json - LICENSE = LICENSE - README.md = README.md - VERSION = VERSION - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{ABC98884-F023-4EF4-A9C9-5DE9452BE955}" - ProjectSection(SolutionItems) = preProject - build\resources\app\App.icns = build\resources\app\App.icns - build\resources\app\App.plist = build\resources\app\App.plist - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_common", "_common", "{04FD74B1-FBDB-496E-A48F-3D59D71FF952}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "usr", "usr", "{76639799-54BC-45E8-BD90-F45F63ACD11D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "share", "share", "{A3ABAA7C-EE14-4448-B466-6E69C1347E7D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "applications", "applications", "{2AF28D3B-14A8-46A8-B828-157FAAB1B06F}" - ProjectSection(SolutionItems) = preProject - build\resources\_common\usr\share\applications\sourcegit.desktop = build\resources\_common\usr\share\applications\sourcegit.desktop - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "icons", "icons", "{7166EC6C-17F5-4B5E-B38E-1E53C81EACF6}" - ProjectSection(SolutionItems) = preProject - build\resources\_common\usr\share\icons\sourcegit.png = build\resources\_common\usr\share\icons\sourcegit.png - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deb", "deb", "{9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DEBIAN", "DEBIAN", "{F101849D-BDB7-40D4-A516-751150C3CCFC}" - ProjectSection(SolutionItems) = preProject - build\resources\deb\DEBIAN\control = build\resources\deb\DEBIAN\control - build\resources\deb\DEBIAN\preinst = build\resources\deb\DEBIAN\preinst - build\resources\deb\DEBIAN\prerm = build\resources\deb\DEBIAN\prerm - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rpm", "rpm", "{9BA0B044-0CC9-46F8-B551-204F149BF45D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SPECS", "SPECS", "{7802CD7A-591B-4EDD-96F8-9BF3F61692E4}" - ProjectSection(SolutionItems) = preProject - build\resources\rpm\SPECS\build.spec = build\resources\rpm\SPECS\build.spec - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "appimage", "appimage", "{5D125DD9-B48A-491F-B2FB-D7830D74C4DC}" - ProjectSection(SolutionItems) = preProject - build\resources\appimage\sourcegit.appdata.xml = build\resources\appimage\sourcegit.appdata.xml - build\resources\appimage\sourcegit.png = build\resources\appimage\sourcegit.png - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C54D4001-9940-477C-A0B6-E795ED0A3209}" - ProjectSection(SolutionItems) = preProject - build\scripts\localization-check.js = build\scripts\localization-check.js - build\scripts\package.linux.sh = build\scripts\package.linux.sh - build\scripts\package.osx-app.sh = build\scripts\package.osx-app.sh - build\scripts\package.windows.sh = build\scripts\package.windows.sh - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {2091C34D-4A17-4375-BEF3-4D60BE8113E4} = {49A7C2D6-558C-4FAA-8F5D-EEE81497AED7} - {FD384607-ED99-47B7-AF31-FB245841BC92} = {773082AC-D9C8-4186-8521-4B6A7BEE6158} - {67B6D05F-A000-40BA-ADB4-C9065F880D7B} = {F45A9D95-AF25-42D8-BBAC-8259C9EEE820} - {ABC98884-F023-4EF4-A9C9-5DE9452BE955} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {04FD74B1-FBDB-496E-A48F-3D59D71FF952} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {76639799-54BC-45E8-BD90-F45F63ACD11D} = {04FD74B1-FBDB-496E-A48F-3D59D71FF952} - {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} = {76639799-54BC-45E8-BD90-F45F63ACD11D} - {2AF28D3B-14A8-46A8-B828-157FAAB1B06F} = {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} - {7166EC6C-17F5-4B5E-B38E-1E53C81EACF6} = {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} - {9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {F101849D-BDB7-40D4-A516-751150C3CCFC} = {9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC} - {9BA0B044-0CC9-46F8-B551-204F149BF45D} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {7802CD7A-591B-4EDD-96F8-9BF3F61692E4} = {9BA0B044-0CC9-46F8-B551-204F149BF45D} - {5D125DD9-B48A-491F-B2FB-D7830D74C4DC} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {C54D4001-9940-477C-A0B6-E795ED0A3209} = {773082AC-D9C8-4186-8521-4B6A7BEE6158} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {7FF1B9C6-B5BF-4A50-949F-4B407A0E31C9} - EndGlobalSection -EndGlobal diff --git a/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 index efc5676f9..e2600fb4f 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -7,42 +7,43 @@ The project uses the following third-party libraries or assets ### AvaloniaUI - **Source**: https://github.com/AvaloniaUI/Avalonia -- **Version**: 11.2.8 +- **Version**: 12.0.5 - **License**: MIT License - **License Link**: https://github.com/AvaloniaUI/Avalonia/blob/master/licence.md -### AvaloniaEdit +### Avalonia.Controls.DataGrid -- **Source**: https://github.com/AvaloniaUI/AvaloniaEdit -- **Version**: 11.2.0 +- **Source**: https://github.com/AvaloniaUI/Avalonia.Controls.DataGrid +- **Version**: 12.0.1 - **License**: MIT License -- **License Link**: https://github.com/AvaloniaUI/AvaloniaEdit/blob/master/LICENSE +- **License Link**: https://github.com/AvaloniaUI/Avalonia.Controls.DataGrid/blob/master/licence.md -### LiveChartsCore.SkiaSharpView.Avalonia +### AvaloniaEdit -- **Source**: https://github.com/beto-rodriguez/LiveCharts2 -- **Version**: 2.0.0-rc5.4 +- **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/beto-rodriguez/LiveCharts2/blob/master/LICENSE +- **License Link**: https://github.com/AvaloniaUI/AvaloniaEdit/blob/master/LICENSE ### TextMateSharp - **Source**: https://github.com/danipen/TextMateSharp -- **Version**: 1.0.66 +- **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.2.0-beta.4 +- **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.2.0-beta.4 +- **Version**: 2.9.0-beta.1 - **License**: MIT License - **License Link**: https://github.com/Azure/azure-sdk-for-net/blob/main/LICENSE.txt @@ -56,10 +57,16 @@ The project uses the following third-party libraries or assets ### Pfim - **Source**: https://github.com/nickbabcock/Pfim -- **Version**: 0.11.3 +- **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 @@ -98,3 +105,10 @@ The project uses the following third-party libraries or assets - **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 26ab879ff..47960c547 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -6,543 +6,526 @@ This document shows the translation status of each locale file in the repository ### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen) -### ![de__DE](https://img.shields.io/badge/de__DE-97.37%25-yellow) +### ![de__DE](https://img.shields.io/badge/de__DE-89.52%25-yellow)
Missing keys in de_DE.axaml -- Text.ChangeSubmoduleUrl -- Text.ChangeSubmoduleUrl.Submodule -- Text.ChangeSubmoduleUrl.URL -- Text.CommitCM.InteractiveRebase -- Text.CommitCM.Rebase -- Text.CommitCM.Reset -- Text.CommitCM.SquashCommitsSinceThis -- Text.MoveSubmodule -- Text.MoveSubmodule.MoveTo -- Text.MoveSubmodule.Submodule -- Text.SetSubmoduleBranch -- Text.SetSubmoduleBranch.Submodule -- Text.SetSubmoduleBranch.Current -- Text.SetSubmoduleBranch.New -- Text.SetSubmoduleBranch.New.Tip -- Text.Submodule.Branch -- Text.Submodule.Histories -- Text.Submodule.Move -- Text.Submodule.SetBranch -- Text.Submodule.SetURL -- Text.Submodule.Update -- Text.UpdateSubmodules.UpdateToRemoteTrackingBranch +- 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
-### ![es__ES](https://img.shields.io/badge/es__ES-94.87%25-yellow) +### ![el__GR](https://img.shields.io/badge/el__GR-99.51%25-yellow) + +
+Missing keys in el_GR.axaml + +- Text.Launcher.NewVersion +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.WorkingCopy.FilterChanges + +
+ +### ![es__ES](https://img.shields.io/badge/es__ES-99.90%25-yellow)
Missing keys in es_ES.axaml -- Text.Askpass.Passphrase -- Text.ChangeSubmoduleUrl -- Text.ChangeSubmoduleUrl.Submodule -- Text.ChangeSubmoduleUrl.URL -- Text.CommitCM.CopyCommitMessage -- Text.CommitCM.InteractiveRebase -- Text.CommitCM.Rebase -- Text.CommitCM.Reset -- Text.CommitCM.SquashCommitsSinceThis -- Text.CommitDetail.Info.Key -- Text.CommitDetail.Info.Signer -- Text.Configure.CommitMessageTemplate.BuiltinVars -- Text.ConfigureCustomActionControls.Options -- Text.ConfigureCustomActionControls.Options.Tip -- Text.ConfirmRestart.Title -- Text.ConfirmRestart.Message -- Text.Diff.Image.Blend -- Text.Diff.Image.SideBySide -- Text.Diff.Image.Swipe -- Text.Diff.New -- Text.Diff.Old -- Text.DirHistories -- Text.InteractiveRebase.ReorderTip -- Text.MoveSubmodule -- Text.MoveSubmodule.MoveTo -- Text.MoveSubmodule.Submodule -- Text.Push.New -- Text.Repository.OnlyHighlightCurrentBranchInGraph -- Text.Repository.ShowFirstParentOnly -- Text.Repository.ShowLostCommits -- Text.Repository.UseRelativeTimeInGraph -- Text.SetSubmoduleBranch -- Text.SetSubmoduleBranch.Submodule -- Text.SetSubmoduleBranch.Current -- Text.SetSubmoduleBranch.New -- Text.SetSubmoduleBranch.New.Tip -- Text.Submodule.Branch -- Text.Submodule.Histories -- Text.Submodule.Move -- Text.Submodule.SetBranch -- Text.Submodule.SetURL -- Text.Submodule.Update -- Text.UpdateSubmodules.UpdateToRemoteTrackingBranch +- Text.WorkingCopy.FilterChanges
-### ![fr__FR](https://img.shields.io/badge/fr__FR-83.53%25-yellow) +### ![fr__FR](https://img.shields.io/badge/fr__FR-95.49%25-yellow)
Missing keys in fr_FR.axaml -- Text.AddToIgnore -- Text.AddToIgnore.Pattern -- Text.AddToIgnore.Storage -- Text.Askpass.Passphrase -- Text.Avatar.Load -- Text.Bisect -- Text.Bisect.Abort -- Text.Bisect.Bad -- Text.Bisect.Detecting -- Text.Bisect.Good -- Text.Bisect.Skip -- Text.Bisect.WaitingForRange -- Text.BranchCM.ResetToSelectedCommit -- Text.ChangeSubmoduleUrl -- Text.ChangeSubmoduleUrl.Submodule -- Text.ChangeSubmoduleUrl.URL -- Text.Checkout.RecurseSubmodules -- Text.Checkout.WarnLostCommits -- Text.Checkout.WithFastForward -- Text.Checkout.WithFastForward.Upstream -- Text.CommitCM.CopyAuthor -- Text.CommitCM.CopyCommitMessage -- Text.CommitCM.CopyCommitter -- Text.CommitCM.CopySubject -- Text.CommitCM.InteractiveRebase -- Text.CommitCM.PushRevision -- Text.CommitCM.Rebase -- Text.CommitCM.Reset -- Text.CommitCM.SquashCommitsSinceThis -- Text.CommitDetail.Changes.Count -- Text.CommitDetail.Info.Key -- Text.CommitDetail.Info.Signer -- Text.CommitMessageTextBox.SubjectCount -- Text.Configure.CommitMessageTemplate.BuiltinVars -- Text.Configure.CustomAction.Arguments.Tip -- Text.Configure.CustomAction.InputControls -- Text.Configure.CustomAction.InputControls.Edit -- Text.Configure.CustomAction.InputControls.Tip -- Text.Configure.CustomAction.Scope.Tag -- Text.Configure.Git.PreferredMergeMode -- 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.Type -- Text.ConfirmEmptyCommit.Continue -- Text.ConfirmEmptyCommit.NoLocalChanges -- Text.ConfirmEmptyCommit.StageAllThenCommit -- Text.ConfirmEmptyCommit.WithLocalChanges -- Text.ConfirmRestart.Title -- Text.ConfirmRestart.Message -- Text.CreateBranch.OverwriteExisting -- Text.DeinitSubmodule -- Text.DeinitSubmodule.Force -- Text.DeinitSubmodule.Path -- Text.Diff.Image.Blend -- Text.Diff.Image.SideBySide -- Text.Diff.Image.Swipe -- Text.Diff.New -- Text.Diff.Old -- Text.Diff.Submodule.Deleted -- Text.DirHistories -- Text.ExecuteCustomAction.Target -- Text.ExecuteCustomAction.Repository -- Text.GitFlow.FinishWithPush -- Text.GitFlow.FinishWithSquash -- Text.Hotkeys.Global.SwitchWorkspace -- Text.Hotkeys.Global.SwitchTab -- Text.Hotkeys.TextEditor.OpenExternalMergeTool -- Text.InteractiveRebase.ReorderTip -- Text.Launcher.Workspaces -- Text.Launcher.Pages -- Text.Merge.Edit -- Text.MoveSubmodule -- Text.MoveSubmodule.MoveTo -- Text.MoveSubmodule.Submodule -- Text.Preferences.Git.IgnoreCRAtEOLInDiff -- Text.Pull.RecurseSubmodules -- Text.Push.New -- Text.Push.Revision -- Text.Push.Revision.Title -- Text.Repository.BranchSort -- Text.Repository.BranchSort.ByCommitterDate -- Text.Repository.BranchSort.ByName -- Text.Repository.ClearStashes -- Text.Repository.OnlyHighlightCurrentBranchInGraph -- Text.Repository.Search.ByContent -- Text.Repository.Search.ByPath -- Text.Repository.ShowFirstParentOnly -- Text.Repository.ShowLostCommits -- Text.Repository.ShowSubmodulesAsTree -- Text.Repository.UseRelativeTimeInGraph -- Text.Repository.ViewLogs -- Text.Repository.Visit -- Text.ResetWithoutCheckout -- Text.ResetWithoutCheckout.MoveTo -- Text.ResetWithoutCheckout.Target -- Text.SetSubmoduleBranch -- Text.SetSubmoduleBranch.Submodule -- Text.SetSubmoduleBranch.Current -- Text.SetSubmoduleBranch.New -- Text.SetSubmoduleBranch.New.Tip -- Text.Stash.Mode -- Text.StashCM.CopyMessage -- Text.Submodule.Branch -- Text.Submodule.Deinit -- Text.Submodule.Histories -- Text.Submodule.Move -- Text.Submodule.RelativePath -- Text.Submodule.RelativePath.Placeholder -- 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.TagCM.CustomAction -- Text.UpdateSubmodules.UpdateToRemoteTrackingBranch -- Text.ViewLogs -- Text.ViewLogs.Clear -- Text.ViewLogs.CopyLog -- Text.ViewLogs.Delete -- Text.WorkingCopy.AddToGitIgnore.InFolder -- Text.WorkingCopy.ConfirmCommitWithDetachedHead -- Text.WorkingCopy.ConfirmCommitWithFilter -- Text.WorkingCopy.Conflicts.OpenExternalMergeTool -- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts -- Text.WorkingCopy.Conflicts.UseMine -- Text.WorkingCopy.Conflicts.UseTheirs -- Text.WorkingCopy.ResetAuthor +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitterTime +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.CopyAsPatch +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.Launcher.NewVersion +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.Preferences.General.UseCompactBranchNames +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges + +
+ +### ![he__IL](https://img.shields.io/badge/he__IL-95.49%25-yellow) + +
+Missing keys in he_IL.axaml + +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitterTime +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.CopyAsPatch +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.Launcher.NewVersion +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.Preferences.General.UseCompactBranchNames +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges + +
+ +### ![id__ID](https://img.shields.io/badge/id__ID-99.90%25-yellow) + +
+Missing keys in id_ID.axaml + +- Text.WorkingCopy.FilterChanges
-### ![it__IT](https://img.shields.io/badge/it__IT-88.90%25-yellow) +### ![it__IT](https://img.shields.io/badge/it__IT-89.03%25-yellow)
Missing keys in it_IT.axaml -- Text.AddToIgnore -- Text.AddToIgnore.Pattern -- Text.AddToIgnore.Storage -- Text.Askpass.Passphrase -- Text.Avatar.Load -- Text.BranchCM.ResetToSelectedCommit -- Text.ChangeSubmoduleUrl -- Text.ChangeSubmoduleUrl.Submodule -- Text.ChangeSubmoduleUrl.URL -- Text.Checkout.WarnLostCommits -- Text.Checkout.WithFastForward -- Text.Checkout.WithFastForward.Upstream -- Text.CommitCM.CopyCommitMessage -- Text.CommitCM.InteractiveRebase -- Text.CommitCM.PushRevision -- Text.CommitCM.Rebase -- Text.CommitCM.Reset -- Text.CommitCM.SquashCommitsSinceThis -- Text.CommitDetail.Changes.Count -- Text.CommitDetail.Info.Key -- Text.CommitDetail.Info.Signer -- Text.Configure.CommitMessageTemplate.BuiltinVars -- Text.Configure.CustomAction.Arguments.Tip -- Text.Configure.CustomAction.InputControls -- Text.Configure.CustomAction.InputControls.Edit -- Text.Configure.CustomAction.InputControls.Tip -- Text.Configure.CustomAction.Scope.Tag -- 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.Type -- Text.ConfirmRestart.Title -- Text.ConfirmRestart.Message -- Text.CreateBranch.OverwriteExisting -- Text.DeinitSubmodule -- Text.DeinitSubmodule.Force -- Text.DeinitSubmodule.Path -- Text.Diff.Image.Blend -- Text.Diff.Image.SideBySide -- Text.Diff.Image.Swipe -- Text.Diff.New -- Text.Diff.Old -- Text.Diff.Submodule.Deleted -- Text.DirHistories -- Text.ExecuteCustomAction.Target -- Text.ExecuteCustomAction.Repository -- Text.Hotkeys.Global.SwitchWorkspace -- Text.Hotkeys.Global.SwitchTab -- Text.InteractiveRebase.ReorderTip -- Text.Launcher.Workspaces -- Text.Launcher.Pages -- Text.Merge.Edit -- Text.MoveSubmodule -- Text.MoveSubmodule.MoveTo -- Text.MoveSubmodule.Submodule -- Text.Pull.RecurseSubmodules -- Text.Push.New -- Text.Push.Revision -- Text.Push.Revision.Title -- Text.Repository.ClearStashes -- Text.Repository.OnlyHighlightCurrentBranchInGraph -- Text.Repository.Search.ByPath -- Text.Repository.ShowFirstParentOnly -- Text.Repository.ShowLostCommits -- Text.Repository.UseRelativeTimeInGraph -- Text.ResetWithoutCheckout -- Text.ResetWithoutCheckout.MoveTo -- Text.ResetWithoutCheckout.Target -- Text.SetSubmoduleBranch -- Text.SetSubmoduleBranch.Submodule -- Text.SetSubmoduleBranch.Current -- Text.SetSubmoduleBranch.New -- Text.SetSubmoduleBranch.New.Tip -- Text.Stash.Mode -- Text.StashCM.CopyMessage -- Text.Submodule.Branch -- Text.Submodule.Deinit -- Text.Submodule.Histories -- Text.Submodule.Move -- Text.Submodule.SetBranch -- Text.Submodule.SetURL -- Text.Submodule.Update -- Text.TagCM.CustomAction -- Text.UpdateSubmodules.UpdateToRemoteTrackingBranch -- Text.WorkingCopy.AddToGitIgnore.InFolder -- Text.WorkingCopy.ConfirmCommitWithDetachedHead -- Text.WorkingCopy.ResetAuthor +- Text.AIAssistant.Use +- Text.App.HideOthers +- Text.Apply.3Way +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.BranchCM.CompareWithSpecial +- Text.ChangeCM.ResetFileTo +- Text.CheckoutBranchFromStash +- Text.CheckoutBranchFromStash.Branch +- Text.CheckoutBranchFromStash.Stash +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.Clone.Bookmark +- Text.Clone.Group +- Text.CommandPalette.Branches +- Text.CommandPalette.BranchesAndTags +- Text.CommandPalette.RepositoryActions +- Text.CommandPalette.RevisionFiles +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitterTime +- Text.CommitDetail.CollapseToBottom +- Text.CommitMessageTextBox.Column +- Text.Compare.Changes +- Text.Compare.Commits +- Text.Compare.Commits.LeftOnly +- Text.Compare.Commits.RightOnly +- Text.Compare.Commits.Tips +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.ConfigureCustomActionControls.StringFormatter +- Text.ConfigureCustomActionControls.StringFormatter.Tip +- Text.ConfigureCustomActionControls.UseFriendlyName +- Text.ConfirmEmptyCommit.StageSelectedThenCommit +- Text.CopyAsPatch +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.Diff.Submodule.UncommittedChanges +- Text.Discard.IncludeModified +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.GotoRevisionSelector +- Text.Histories.HighlightsInGraph +- Text.Histories.HighlightsInGraph.All +- Text.Histories.HighlightsInGraph.CurrentBranchOnly +- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits +- Text.Histories.HighlightsInGraph.SelectedCommitsOnly +- Text.Histories.ShowColumns +- Text.HistoriesDetailsStandalone +- Text.HistoriesDetailsStandalone.CommitDetail +- Text.HistoriesDetailsStandalone.RevisionCompare +- Text.Hotkeys.Global.OpenLocalRepository +- Text.Hotkeys.Repo.CreateBranch +- Text.Hotkeys.Repo.GoToChild +- Text.Hotkeys.Repo.GoToParent +- Text.Hotkeys.Repo.ToggleHistoriesDetailPanel +- Text.Init.CommandTip +- Text.Init.ErrorMessageTip +- Text.InteractiveRebase.NoVerify +- Text.Launcher.NewVersion +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.OpenLocalRepository +- Text.OpenLocalRepository.Bookmark +- Text.OpenLocalRepository.Group +- Text.OpenLocalRepository.Path +- Text.Preferences.AI.AdditionalPrompt +- Text.Preferences.AI.Model +- Text.Preferences.AI.Model.AutoFetchAvailableModels +- Text.Preferences.General.ShowRelativeTimeInGraph +- Text.Preferences.General.Use24Hours +- Text.Preferences.General.UseCompactBranchNames +- Text.Preferences.Git.UseStashAndReapplyByDefault +- Text.Rebase.NoVerify +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.RemoteCM.EnableAutoFetch +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.SelfUpdate.CurrentVersion +- Text.SelfUpdate.ReleaseDate +- Text.StashCM.ApplyFileChanges +- Text.StashCM.Branch +- Text.SubmoduleRevisionCompare +- Text.SubmoduleRevisionCompare.OpenDetails +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges +- Text.Worktree.Branch +- Text.Worktree.Head +- Text.Worktree.Path
-### ![ja__JP](https://img.shields.io/badge/ja__JP-83.53%25-yellow) +### ![ja__JP](https://img.shields.io/badge/ja__JP-99.51%25-yellow)
Missing keys in ja_JP.axaml -- Text.AddToIgnore -- Text.AddToIgnore.Pattern -- Text.AddToIgnore.Storage -- Text.Askpass.Passphrase -- Text.Avatar.Load -- Text.Bisect -- Text.Bisect.Abort -- Text.Bisect.Bad -- Text.Bisect.Detecting -- Text.Bisect.Good -- Text.Bisect.Skip -- Text.Bisect.WaitingForRange -- Text.BranchCM.CompareWithCurrent -- Text.BranchCM.ResetToSelectedCommit -- Text.ChangeSubmoduleUrl -- Text.ChangeSubmoduleUrl.Submodule -- Text.ChangeSubmoduleUrl.URL -- Text.Checkout.RecurseSubmodules -- Text.Checkout.WarnLostCommits -- Text.Checkout.WithFastForward -- Text.Checkout.WithFastForward.Upstream -- Text.CommitCM.CopyAuthor -- Text.CommitCM.CopyCommitMessage -- Text.CommitCM.CopyCommitter -- Text.CommitCM.CopySubject -- Text.CommitCM.InteractiveRebase -- Text.CommitCM.PushRevision -- Text.CommitCM.Rebase -- Text.CommitCM.Reset -- Text.CommitCM.SquashCommitsSinceThis -- Text.CommitDetail.Changes.Count -- Text.CommitDetail.Info.Key -- Text.CommitDetail.Info.Signer -- Text.CommitMessageTextBox.SubjectCount -- Text.Configure.CommitMessageTemplate.BuiltinVars -- Text.Configure.CustomAction.Arguments.Tip -- Text.Configure.CustomAction.InputControls -- Text.Configure.CustomAction.InputControls.Edit -- Text.Configure.CustomAction.InputControls.Tip -- Text.Configure.CustomAction.Scope.Tag -- Text.Configure.Git.PreferredMergeMode -- 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.Type -- Text.ConfirmEmptyCommit.Continue -- Text.ConfirmEmptyCommit.NoLocalChanges -- Text.ConfirmEmptyCommit.StageAllThenCommit -- Text.ConfirmEmptyCommit.WithLocalChanges -- Text.ConfirmRestart.Title -- Text.ConfirmRestart.Message -- Text.CreateBranch.OverwriteExisting -- Text.DeinitSubmodule -- Text.DeinitSubmodule.Force -- Text.DeinitSubmodule.Path -- Text.Diff.Image.Blend -- Text.Diff.Image.SideBySide -- Text.Diff.Image.Swipe -- Text.Diff.New -- Text.Diff.Old -- Text.Diff.Submodule.Deleted -- Text.DirHistories -- Text.ExecuteCustomAction.Target -- Text.ExecuteCustomAction.Repository -- Text.GitFlow.FinishWithPush -- Text.GitFlow.FinishWithSquash -- Text.Hotkeys.Global.SwitchWorkspace -- Text.Hotkeys.Global.SwitchTab -- Text.Hotkeys.TextEditor.OpenExternalMergeTool -- Text.InteractiveRebase.ReorderTip -- Text.Launcher.Workspaces -- Text.Launcher.Pages -- Text.Merge.Edit -- Text.MoveSubmodule -- Text.MoveSubmodule.MoveTo -- Text.MoveSubmodule.Submodule -- Text.Preferences.Git.IgnoreCRAtEOLInDiff -- Text.Pull.RecurseSubmodules -- Text.Push.New -- Text.Push.Revision -- Text.Push.Revision.Title -- Text.Repository.BranchSort -- Text.Repository.BranchSort.ByCommitterDate -- Text.Repository.BranchSort.ByName -- Text.Repository.ClearStashes -- Text.Repository.FilterCommits -- Text.Repository.OnlyHighlightCurrentBranchInGraph -- Text.Repository.Search.ByContent -- Text.Repository.Search.ByPath -- Text.Repository.ShowFirstParentOnly -- Text.Repository.ShowLostCommits -- Text.Repository.ShowSubmodulesAsTree -- Text.Repository.UseRelativeTimeInGraph -- Text.Repository.ViewLogs -- Text.Repository.Visit -- Text.ResetWithoutCheckout -- Text.ResetWithoutCheckout.MoveTo -- Text.ResetWithoutCheckout.Target -- Text.SetSubmoduleBranch -- Text.SetSubmoduleBranch.Submodule -- Text.SetSubmoduleBranch.Current -- Text.SetSubmoduleBranch.New -- Text.SetSubmoduleBranch.New.Tip -- Text.Stash.Mode -- Text.StashCM.CopyMessage -- Text.Submodule.Branch -- 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.TagCM.CustomAction -- Text.UpdateSubmodules.UpdateToRemoteTrackingBranch -- Text.ViewLogs -- Text.ViewLogs.Clear -- Text.ViewLogs.CopyLog -- Text.ViewLogs.Delete -- Text.WorkingCopy.AddToGitIgnore.InFolder -- Text.WorkingCopy.ConfirmCommitWithDetachedHead -- Text.WorkingCopy.ConfirmCommitWithFilter -- Text.WorkingCopy.Conflicts.OpenExternalMergeTool -- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts -- Text.WorkingCopy.Conflicts.UseMine -- Text.WorkingCopy.Conflicts.UseTheirs -- Text.WorkingCopy.ResetAuthor +- Text.Launcher.NewVersion +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.WorkingCopy.FilterChanges + +
+ +### ![ko__KR](https://img.shields.io/badge/ko__KR-96.96%25-yellow) + +
+Missing keys in ko_KR.axaml + +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.Launcher.NewVersion +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges
-### ![pt__BR](https://img.shields.io/badge/pt__BR-76.37%25-yellow) +### ![pt__BR](https://img.shields.io/badge/pt__BR-62.78%25-red)
Missing keys in pt_BR.axaml -- Text.AddToIgnore -- Text.AddToIgnore.Pattern -- Text.AddToIgnore.Storage -- Text.AIAssistant.Regen - Text.AIAssistant.Use -- Text.ApplyStash -- Text.ApplyStash.DropAfterApply -- Text.ApplyStash.RestoreIndex -- Text.ApplyStash.Stash -- Text.Askpass.Passphrase -- Text.Avatar.Load -- Text.Bisect -- Text.Bisect.Abort -- Text.Bisect.Bad -- Text.Bisect.Detecting -- Text.Bisect.Good -- Text.Bisect.Skip -- Text.Bisect.WaitingForRange -- Text.BranchCM.CustomAction -- Text.BranchCM.MergeMultiBranches -- Text.BranchCM.ResetToSelectedCommit -- Text.BranchUpstreamInvalid +- Text.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.RecurseSubmodules - 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.Merge +- 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.CommitCM.SquashCommitsSinceThis - Text.CommitDetail.Changes.Count +- Text.CommitDetail.CollapseToBottom - Text.CommitDetail.Files.Search -- Text.CommitDetail.Info.Children +- 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.InputControls.Tip - 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 @@ -552,51 +535,127 @@ This document shows the translation status of each locale file in the repository - 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.Name.WarnSpace - 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.UseBlockNavigation +- 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.GitFlow.FinishWithPush +- 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.SwitchWorkspace +- Text.Hotkeys.Global.OpenLocalRepository +- Text.Hotkeys.Global.ShowWorkspaceDropdownMenu - Text.Hotkeys.Global.SwitchTab -- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- 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.Workspaces +- 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 @@ -604,43 +663,89 @@ This document shows the translation status of each locale file in the repository - Text.MoveSubmodule - Text.MoveSubmodule.MoveTo - Text.MoveSubmodule.Submodule -- Text.Preferences.AI.Streaming +- 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.ShowChildren +- 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.Pull.RecurseSubmodules +- 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.OnlyHighlightCurrentBranchInGraph +- 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.UseRelativeTimeInGraph - 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 @@ -652,9 +757,12 @@ This document shows the translation status of each locale file in the repository - 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 @@ -667,83 +775,169 @@ This document shows the translation status of each locale file in the repository - 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.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.Merge +- Text.WorkingCopy.Conflicts.MergeExternal - Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts - Text.WorkingCopy.Conflicts.UseMine - Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.FilterChanges +- Text.WorkingCopy.NoVerify - Text.WorkingCopy.ResetAuthor - Text.WorkingCopy.SignOff +- Text.Worktree.Branch +- Text.Worktree.Head +- Text.Worktree.Open +- Text.Worktree.Path +- Text.Yes
-### ![ru__RU](https://img.shields.io/badge/ru__RU-99.52%25-yellow) +### ![ru__RU](https://img.shields.io/badge/ru__RU-%E2%88%9A-brightgreen) -
-Missing keys in ru_RU.axaml - -- Text.CommitCM.InteractiveRebase -- Text.CommitCM.Rebase -- Text.CommitCM.Reset -- Text.CommitCM.SquashCommitsSinceThis - -
- -### ![ta__IN](https://img.shields.io/badge/ta__IN-83.65%25-yellow) +### ![ta__IN](https://img.shields.io/badge/ta__IN-64.45%25-red)
Missing keys in ta_IN.axaml +- Text.About.ReleaseDate +- Text.About.ReleaseNotes - Text.AddToIgnore - Text.AddToIgnore.Pattern - Text.AddToIgnore.Storage +- Text.AIAssistant.Use +- Text.App.Hide +- Text.App.HideOthers +- Text.App.ShowAll +- Text.Apply.3Way +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard - Text.Askpass.Passphrase - Text.Avatar.Load - Text.Bisect - Text.Bisect.Abort - Text.Bisect.Bad -- Text.Bisect.Detecting - Text.Bisect.Good - Text.Bisect.Skip -- Text.Bisect.WaitingForRange -- Text.BranchCM.CompareWithCurrent +- 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.RecurseSubmodules - 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.CommitCM.SquashCommitsSinceThis - 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.InputControls.Tip +- 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 @@ -753,67 +947,197 @@ This document shows the translation status of each locale file in the repository - 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.GitFlow.FinishWithPush +- 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.Hotkeys.Global.SwitchWorkspace +- 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.TextEditor.OpenExternalMergeTool +- 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.Workspaces +- 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.Pull.RecurseSubmodules +- 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.OnlyHighlightCurrentBranchInGraph +- 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.UseRelativeTimeInGraph - 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 @@ -826,67 +1150,164 @@ This document shows the translation status of each locale file in the repository - 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.OpenExternalMergeTool +- 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-84.84%25-yellow) +### ![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.Detecting - Text.Bisect.Good - Text.Bisect.Skip -- Text.Bisect.WaitingForRange +- 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.RecurseSubmodules - 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.CommitCM.SquashCommitsSinceThis - 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.InputControls.Tip +- 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 @@ -896,64 +1317,194 @@ This document shows the translation status of each locale file in the repository - 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.GitFlow.FinishWithPush +- 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.Hotkeys.Global.SwitchWorkspace +- 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.TextEditor.OpenExternalMergeTool +- 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.Workspaces +- 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.Pull.RecurseSubmodules +- 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.OnlyHighlightCurrentBranchInGraph +- 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.UseRelativeTimeInGraph - 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 @@ -966,15 +1517,42 @@ This document shows the translation status of each locale file in the repository - 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
diff --git a/VERSION b/VERSION index 214f4dc33..054d00cdb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2025.26 \ 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 17305edf6..0698a8fbc 100644 --- a/build/README.md +++ b/build/README.md @@ -5,7 +5,7 @@ ## How to build this project manually -1. Make sure [.NET SDK 9](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 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 71786b435..3f944e3ab 100755 --- a/build/resources/deb/DEBIAN/control +++ b/build/resources/deb/DEBIAN/control @@ -1,7 +1,7 @@ Package: sourcegit Version: 2025.10 Priority: optional -Depends: libx11-6, libice6, libsm6, libicu | libicu76 | libicu74 | libicu72 | libicu71 | libicu70 | libicu69 | libicu68 | libicu67 | libicu66 | libicu65 | libicu63 | libicu60 | libicu57 | libicu55 | libicu52, xdg-utils +Depends: libx11-6, libice6, libsm6, @ICU_DEPS@, xdg-utils Architecture: amd64 Installed-Size: 60440 Maintainer: longshuang@msn.cn 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/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 2a684837a..669fdf846 100644 --- a/build/resources/rpm/SPECS/build.spec +++ b/build/resources/rpm/SPECS/build.spec @@ -20,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/package.linux.sh b/build/scripts/package.linux.sh index 1b4adbdcb..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,13 +61,24 @@ cp -f SourceGit/* resources/deb/opt/sourcegit ln -rsf resources/deb/opt/sourcegit/sourcegit resources/deb/usr/bin cp -r resources/_common/applications resources/deb/usr/share cp -r resources/_common/icons resources/deb/usr/share + # Calculate installed size in KB installed_size=$(du -sk resources/deb | cut -f1) -# Update the control file + +# 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" 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.sh b/build/scripts/package.windows.sh deleted file mode 100755 index c22a9d354..000000000 --- a/build/scripts/package.windows.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -o -set -u -set pipefail - -cd build - -rm -rf SourceGit/*.pdb - -if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then - powershell -Command "Compress-Archive -Path SourceGit -DestinationPath \"sourcegit_$VERSION.$RUNTIME.zip\" -Force" -else - zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit -fi diff --git a/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 6e8af07b3..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 10988efdb..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 f26919c73..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 { @@ -37,21 +37,64 @@ public static bool IsCheckForUpdateCommandVisible } } - public static readonly Command OpenPreferencesCommand = new Command(async _ => await ShowDialog(new Views.Preferences())); - public static readonly Command OpenHotkeysCommand = new Command(async _ => await ShowDialog(new Views.Hotkeys())); - public static readonly Command OpenAppDataDirCommand = new Command(_ => Native.OS.OpenInFileManager(Native.OS.DataDir)); - public static readonly Command OpenAboutCommand = new Command(async _ => await ShowDialog(new Views.About())); - public static readonly Command CheckForUpdateCommand = new Command(_ => (Current as App)?.Check4Update(true)); - public static readonly Command QuitCommand = new Command(_ => Quit(0)); - public static readonly Command CopyTextBlockCommand = new Command(async p => - { - if (p is not TextBlock textBlock) - return; - - if (textBlock.Inlines is { Count: > 0 } inlines) - await CopyTextAsync(inlines.Text); - else if (!string.IsNullOrEmpty(textBlock.Text)) - await CopyTextAsync(textBlock.Text); + 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 index d18882b86..08c1b5478 100644 --- a/src/App.Extensions.cs +++ b/src/App.Extensions.cs @@ -1,4 +1,9 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using Avalonia.Media; namespace SourceGit { @@ -13,6 +18,59 @@ 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 @@ -22,5 +80,45 @@ public static T Use(this T cmd, Models.ICommandLog log) where T : Commands.Co 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 d60b76515..8eef67ae7 100644 --- a/src/App.JsonCodeGen.cs +++ b/src/App.JsonCodeGen.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -8,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) @@ -35,37 +51,27 @@ public override void Write(Utf8JsonWriter writer, GridLength value, JsonSerializ } } - public class DataGridLengthConverter : JsonConverter - { - public override DataGridLength Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var size = reader.GetDouble(); - return new DataGridLength(size, DataGridLengthUnitType.Pixel, 0, size); - } - - public override void Write(Utf8JsonWriter writer, DataGridLength value, JsonSerializerOptions options) - { - writer.WriteNumberValue(value.DisplayValue); - } - } - [JsonSourceGenerationOptions( WriteIndented = true, IgnoreReadOnlyFields = true, IgnoreReadOnlyProperties = true, Converters = [ + typeof(DateTimeConverter), typeof(ColorConverter), typeof(GridLengthConverter), - typeof(DataGridLengthConverter), ] )] - [JsonSerializable(typeof(Models.ExternalToolPaths))] + [JsonSerializable(typeof(Models.ExternalToolCustomization))] [JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))] [JsonSerializable(typeof(Models.JetBrainsState))] [JsonSerializable(typeof(Models.ThemeOverrides))] [JsonSerializable(typeof(Models.Version))] [JsonSerializable(typeof(Models.RepositorySettings))] + [JsonSerializable(typeof(Models.RepositoryUIStates))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ViewModels.Preferences))] + [JsonSerializable(typeof(ViewModels.RepositoryNodeMinimalInfo))] internal partial class JsonCodeGen : JsonSerializerContext { } } diff --git a/src/App.axaml b/src/App.axaml index f4dc3d893..bfc05cfb4 100644 --- a/src/App.axaml +++ b/src/App.axaml @@ -12,8 +12,11 @@ + + + @@ -23,6 +26,7 @@ + @@ -42,6 +46,10 @@ + + + + diff --git a/src/App.axaml.cs b/src/App.axaml.cs index f5c0559a1..a22f764d6 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -1,13 +1,7 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Net.Http; -using System.Reflection; -using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading; using System.Threading.Tasks; using Avalonia; @@ -17,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; @@ -33,7 +27,7 @@ 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) => @@ -52,7 +46,7 @@ public static void Main(string[] args) } catch (Exception ex) { - LogException(ex); + Native.OS.LogException(ex); } } @@ -77,117 +71,42 @@ public static AppBuilder BuildAvaloniaApp() Native.OS.SetupApp(builder); return builder; } - - public static void LogException(Exception ex) - { - if (ex == null) - return; - - var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); - var file = Path.Combine(Native.OS.DataDir, $"crash_{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($"User: {Environment.UserName}"); - 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(); - } #endregion #region Utility Functions - public static object CreateViewForViewModel(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); - - return null; - } - - public static Task ShowDialog(object data, Window owner = null) - { - if (owner == null) - { - if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } mainWindow }) - owner = mainWindow; - else - return null; - } - - if (data is Views.ChromelessWindow window) - return window.ShowDialog(owner); - - window = CreateViewForViewModel(data) as Views.ChromelessWindow; - if (window != null) - { - window.DataContext = data; - return window.ShowDialog(owner); - } - - return null; - } - - public static void ShowWindow(object data) - { - if (data is Views.ChromelessWindow window) - { - window.Show(); - return; - } - - window = CreateViewForViewModel(data) as Views.ChromelessWindow; - if (window != null) - { - window.DataContext = data; - window.Show(); - } - } - - public static async Task AskConfirmAsync(string message, Action onSure) + public static async Task AskConfirmAsync(string message, Models.ConfirmButtonType buttonType = Models.ConfirmButtonType.OkCancel) { if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) { var confirm = new Views.Confirm(); - confirm.Message.Text = message; - confirm.OnSure = onSure; + confirm.SetData(message, buttonType); return await confirm.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 { _launcher: not null } app) - 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 { _launcher: not null } app) - app._launcher.DispatchNotification(context, message, false); + return Models.ConfirmEmptyCommitResult.Cancel; } public static void SetLocale(string localeKey) { + var locale = Models.Locale.Supported.Find(x => x.Key.Equals(localeKey, StringComparison.OrdinalIgnoreCase)); + var finalLocaleKey = locale?.Key ?? "en_US"; + if (Current is not App app || - app.Resources[localeKey] is not ResourceDictionary targetLocale || + app.Resources[finalLocaleKey] is not ResourceDictionary targetLocale || targetLocale == app._activeLocale) return; @@ -236,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; } @@ -252,7 +169,7 @@ 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) { if (Current is not App app) return; @@ -263,8 +180,8 @@ public static void SetFonts(string defaultFont, string monospaceFont, bool onlyU app._fontsOverrides = null; } - defaultFont = app.FixFontFamilyName(defaultFont); - monospaceFont = app.FixFontFamilyName(monospaceFont); + defaultFont = StringExtensions.FormatFontNames(defaultFont); + monospaceFont = StringExtensions.FormatFontNames(monospaceFont); var resDic = new ResourceDictionary(); if (!string.IsNullOrEmpty(defaultFont)) @@ -274,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 @@ -283,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) @@ -306,19 +210,6 @@ public static void SetFonts(string defaultFont, string monospaceFont, bool onlyU } } - public static async Task CopyTextAsync(string data) - { - if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow.Clipboard: { } clipboard }) - await clipboard.SetTextAsync(data ?? ""); - } - - public static async Task GetClipboardTextAsync() - { - if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow.Clipboard: { } clipboard }) - return await clipboard.GetTextAsync(); - return null; - } - public static string Text(string key, params object[] args) { var fmt = Current?.FindResource($"Text.{key}") as string; @@ -331,27 +222,6 @@ 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; - - if (Current?.FindResource(key) is StreamGeometry geo) - icon.Data = geo; - - return icon; - } - - public static IStorageProvider GetStorageProvider() - { - if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - return desktop.MainWindow?.StorageProvider; - - return null; - } - public static ViewModels.Launcher GetLauncher() { return Current is App app ? app._launcher : null; @@ -360,14 +230,9 @@ public static ViewModels.Launcher GetLauncher() public static void Quit(int exitCode) { if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.MainWindow?.Close(); desktop.Shutdown(exitCode); - } else - { Environment.Exit(exitCode); - } } #endregion @@ -377,11 +242,9 @@ public override void Initialize() AvaloniaXamlLoader.Load(this); var pref = ViewModels.Preferences.Instance; - pref.PropertyChanged += (_, _) => pref.Save(); - SetLocale(pref.Locale); SetTheme(pref.Theme, pref.ThemeOverrides); - SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily, pref.OnlyUseMonoFontInEditor); + SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily); } public override void OnFrameworkInitializationCompleted() @@ -398,38 +261,24 @@ public override void OnFrameworkInitializationCompleted() e.Cancel = true; }); - if (TryLaunchAsCoreEditor(desktop)) + if (TryLaunchAsFileHistoryViewer(desktop)) return; - if (TryLaunchAsAskpass(desktop)) + if (TryLaunchAsBlameViewer(desktop)) return; - _ipcChannel = new Models.IpcChannel(); - if (!_ipcChannel.IsFirstInstance) - { - var arg = desktop.Args is { Length: > 0 } ? desktop.Args[0].Trim() : string.Empty; - if (!string.IsNullOrEmpty(arg)) - { - if (arg.StartsWith('"') && arg.EndsWith('"')) - arg = arg.Substring(1, arg.Length - 2).Trim(); + if (TryLaunchAsCoreEditor(desktop)) + return; - if (arg.Length > 0 && !Path.IsPathFullyQualified(arg)) - arg = Path.GetFullPath(arg); - } + if (TryLaunchAsAskpass(desktop)) + return; - _ipcChannel.SendToFirstInstance(arg); - Environment.Exit(0); - } - else - { - _ipcChannel.MessageReceived += TryOpenRepository; - desktop.Exit += (_, _) => _ipcChannel.Dispose(); - TryLaunchAsNormal(desktop); - } + TryLaunchAsNormal(desktop); } } #endregion + #region Launch Ways private static bool TryLaunchAsRebaseTodoEditor(string[] args, out int exitCode) { exitCode = -1; @@ -437,7 +286,7 @@ private static bool TryLaunchAsRebaseTodoEditor(string[] args, out int exitCode) 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; @@ -446,29 +295,13 @@ private static bool TryLaunchAsRebaseTodoEditor(string[] args, out int exitCode) 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; using var stream = File.OpenRead(jobsFile); var collection = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.InteractiveRebaseJobCollection); - using var writer = new StreamWriter(file); - foreach (var job in collection.Jobs) - { - var code = job.Action switch - { - Models.InteractiveRebaseAction.Pick => 'p', - Models.InteractiveRebaseAction.Edit => 'e', - Models.InteractiveRebaseAction.Reword => 'r', - Models.InteractiveRebaseAction.Squash => 's', - Models.InteractiveRebaseAction.Fixup => 'f', - _ => 'd' - }; - writer.WriteLine($"{code} {job.SHA}"); - } - - writer.Flush(); - + collection.WriteTodoList(file); exitCode = 0; return true; } @@ -482,7 +315,7 @@ private static bool TryLaunchAsRebaseMessageEditor(string[] args, out int exitCo 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; @@ -491,7 +324,7 @@ private static bool TryLaunchAsRebaseMessageEditor(string[] args, out int exitCo var origHeadFile = Path.Combine(gitDir, "rebase-merge", "orig-head"); var ontoFile = Path.Combine(gitDir, "rebase-merge", "onto"); var doneFile = Path.Combine(gitDir, "rebase-merge", "done"); - var jobsFile = Path.Combine(gitDir, "sourcegit_rebase_jobs.json"); + var jobsFile = Path.Combine(gitDir, "sourcegit.interactive_rebase"); if (!File.Exists(ontoFile) || !File.Exists(origHeadFile) || !File.Exists(doneFile) || !File.Exists(jobsFile)) return true; @@ -499,28 +332,89 @@ private static bool TryLaunchAsRebaseMessageEditor(string[] args, out int exitCo var onto = File.ReadAllText(ontoFile).Trim(); using var stream = File.OpenRead(jobsFile); var collection = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.InteractiveRebaseJobCollection); - if (!collection.Onto.Equals(onto) || !collection.OrigHead.Equals(origHead)) - return true; + if (collection.Onto.StartsWith(onto, StringComparison.OrdinalIgnoreCase) && collection.OrigHead.StartsWith(origHead, StringComparison.OrdinalIgnoreCase)) + collection.WriteCommitMessage(doneFile, file); - var done = File.ReadAllText(doneFile).Trim().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - if (done.Length == 0) - return true; + return true; + } - var current = done[^1].Trim(); - var match = REG_REBASE_TODO().Match(current); - if (!match.Success) + private bool TryLaunchAsFileHistoryViewer(IClassicDesktopStyleApplicationLifetime desktop) + { + var args = desktop.Args; + if (args is not { Length: > 1 } || !args[0].Equals("--history", StringComparison.Ordinal)) + return false; + + var fullPath = Path.GetFullPath(args[1].Replace('\\', '/').Trim('\"').Trim()); + var dir = Path.GetDirectoryName(fullPath); + + var test = new Commands.QueryRepositoryRootPath(dir).GetResult(); + if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) + { + Console.Out.WriteLine($"'{args[1]}' is not in a valid git repository"); + desktop.Shutdown(-1); return true; + } + + Native.OS.SetupExternalTools(); + Models.AvatarManager.Instance.Start(); - var sha = match.Groups[1].Value; - foreach (var job in collection.Jobs) + var repo = test.StdOut.Trim(); + var relativePath = Path.GetRelativePath(repo, fullPath).Replace('\\', '/'); + if (File.Exists(fullPath)) { - if (job.SHA.StartsWith(sha)) + desktop.MainWindow = new Views.FileHistories() { - File.WriteAllText(file, job.Message); - break; - } + 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 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; } @@ -530,7 +424,7 @@ private bool TryLaunchAsCoreEditor(IClassicDesktopStyleApplicationLifetime deskt 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); @@ -563,86 +457,116 @@ private bool TryLaunchAsAskpass(IClassicDesktopStyleApplicationLifetime desktop) private void TryLaunchAsNormal(IClassicDesktopStyleApplicationLifetime desktop) { + _ipcChannel = new Models.IpcChannel(); + if (!_ipcChannel.IsFirstInstance) + { + var arg = desktop.Args is { Length: > 0 } ? desktop.Args[0] : string.Empty; + if (!string.IsNullOrEmpty(arg)) + { + arg = arg.Replace('\\', '/').TrimEnd('/').Trim('\"').Trim(); + if (arg.Length > 0 && !Path.IsPathFullyQualified(arg)) + arg = Path.GetFullPath(arg); + } + + _ipcChannel.SendToFirstInstance(arg); + Environment.Exit(0); + return; + } + Native.OS.SetupExternalTools(); Models.AvatarManager.Instance.Start(); + if (this.TryGetFeature() is { } activatable) + { + activatable.Activated += (_, e) => + { + if (e is FileActivatedEventArgs { Files: { Count: > 0 } } fileArgs) + _launcher?.TryOpenRepositoryFromPath(fileArgs.Files[0].Path.LocalPath); + }; + } + string startupRepo = null; - if (desktop.Args is { Length: 1 } && 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.OnMainWindowClose; - -#if !DISABLE_UPDATE_DETECTION - if (pref.ShouldCheck4UpdateOnStartup()) - Check4Update(); -#endif - } + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; - private void TryOpenRepository(string repo) - { - if (!string.IsNullOrEmpty(repo) && Directory.Exists(repo)) + // Fix macOS crash when quiting from Dock + if (OperatingSystem.IsMacOS()) { - var test = new Commands.QueryRepositoryRootPath(repo).GetResultAsync().Result; - if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) + desktop.ShutdownRequested += (_, e) => { - Dispatcher.UIThread.Invoke(() => - { - var node = ViewModels.Preferences.Instance.FindOrAddNodeByRepositoryPath(test.StdOut.Trim(), null, false); - ViewModels.Welcome.Instance.Refresh(); - _launcher?.OpenRepositoryInTab(node, null); - - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: Views.Launcher wnd }) - wnd.BringToTop(); - }); - - return; - } + e.Cancel = true; + Dispatcher.UIThread.Post(() => Quit(0)); + }; } - Dispatcher.UIThread.Invoke(() => + desktop.Exit += (_, _) => { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: Views.Launcher launcher }) - launcher.BringToTop(); - }); + _ipcChannel?.Dispose(); + _ipcChannel = null; + }; + + _ipcChannel.MessageReceived += repo => + { + Dispatcher.UIThread.Invoke(() => + { + _launcher.TryOpenRepositoryFromPath(repo); + if (desktop.MainWindow is Views.Launcher main) + main.BringToTop(); + }); + }; + +#if !DISABLE_UPDATE_DETECTION + if (pref.ShouldCheck4UpdateOnStartup()) + Check4Update(); +#endif } + #endregion + #region Check for Updates private void Check4Update(bool manually = false) { + if (_launcher != null) + _launcher.NewVersion = null; + Task.Run(async () => { try { // Fetch latest release information. - using var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(5) }; - var data = await client.GetStringAsync("https://sourcegit-scm.github.io/data/version.json"); + using var client = new HttpClient(); + client.Timeout = TimeSpan.FromSeconds(5); - // Parse JSON into Models.Version. + 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; - // Check if already up-to-date. - if (!ver.IsNewVersion) + if (manually) { - if (manually) + if (ver.IsNewVersion) + ShowSelfUpdateResult(ver); + else ShowSelfUpdateResult(new Models.AlreadyUpToDate()); - return; } - - // Should not check ignored tag if this is called manually. - if (!manually) + else if (_launcher != null) { - var pref = ViewModels.Preferences.Instance; - if (ver.TagName == pref.IgnoreUpdateTag) + if (!ver.IsNewVersion || ver.TagName == ViewModels.Preferences.Instance.IgnoreUpdateTag) return; - } - ShowSelfUpdateResult(ver); + _launcher.NewVersion = ver; + } } catch (Exception e) { @@ -654,54 +578,24 @@ private void Check4Update(bool manually = false) private void ShowSelfUpdateResult(object data) { - Dispatcher.UIThread.Post(async () => - { - await ShowDialog(new ViewModels.SelfUpdate { Data = data }); - }); - } - - private string FixFontFamilyName(string input) - { - if (string.IsNullOrEmpty(input)) - return string.Empty; - - var parts = input.Split(','); - var trimmed = new List(); - - foreach (var part in parts) + try { - var t = part.Trim(); - if (string.IsNullOrEmpty(t)) - continue; - - // Collapse multiple spaces into single space - var prevChar = '\0'; - var sb = new StringBuilder(); - - foreach (var c in t) + Dispatcher.UIThread.Invoke(async () => { - if (c == ' ' && prevChar == ' ') - continue; - sb.Append(c); - prevChar = c; - } - - var name = sb.ToString(); - if (name.Contains('#')) - { - if (!name.Equals("fonts:Inter#Inter", StringComparison.Ordinal) && - !name.Equals("fonts:SourceGit#JetBrains Mono", StringComparison.Ordinal)) - continue; - } - - trimmed.Add(name); + 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. } - - return trimmed.Count > 0 ? string.Join(',', trimmed) : string.Empty; } - - [GeneratedRegex(@"^[a-z]+\s+([a-fA-F0-9]{4,40})(\s+.*)?$")] - private static partial Regex REG_REBASE_TODO(); + #endregion private Models.IpcChannel _ipcChannel = null; private ViewModels.Launcher _launcher = null; diff --git a/src/Commands/Add.cs b/src/Commands/Add.cs index 9bd576359..916063d82 100644 --- a/src/Commands/Add.cs +++ b/src/Commands/Add.cs @@ -2,25 +2,11 @@ { public class Add : Command { - public Add(string repo, bool includeUntracked) - { - WorkingDirectory = repo; - Context = repo; - Args = includeUntracked ? "add ." : "add -u ."; - } - - public Add(string repo, Models.Change change) - { - WorkingDirectory = repo; - Context = repo; - Args = $"add -- {change.Path.Quoted()}"; - } - public Add(string repo, string pathspecFromFile) { WorkingDirectory = repo; Context = repo; - Args = $"add --pathspec-from-file={pathspecFromFile.Quoted()}"; + Args = $"add --force --verbose --pathspec-from-file={pathspecFromFile.Quoted()}"; } } } diff --git a/src/Commands/Apply.cs b/src/Commands/Apply.cs index 189d43359..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.Quoted()}"; + builder.Append(extra).Append(' '); + + Args = builder.Append(file.Quoted()).ToString(); } } } diff --git a/src/Commands/Blame.cs b/src/Commands/Blame.cs index 92753ac18..0f247d736 100644 --- a/src/Commands/Blame.cs +++ b/src/Commands/Blame.cs @@ -7,17 +7,22 @@ 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.Quoted()}"; 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 async Task ReadAsync() @@ -61,19 +66,20 @@ private void ParseLine(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(_dateFormat); + 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); @@ -88,7 +94,6 @@ private void ParseLine(string line) private readonly Models.BlameData _result = new Models.BlameData(); private readonly StringBuilder _content = new StringBuilder(); - private readonly string _dateFormat = Models.DateTimeFormat.Active.DateOnly; private string _lastSHA = string.Empty; private bool _needUnifyCommitSHA = false; private int _minSHALen = 64; diff --git a/src/Commands/Branch.cs b/src/Commands/Branch.cs index 1928154e9..6ed017841 100644 --- a/src/Commands/Branch.cs +++ b/src/Commands/Branch.cs @@ -32,25 +32,25 @@ public async Task RenameAsync(string to) return await ExecAsync().ConfigureAwait(false); } - public async Task SetUpstreamAsync(string upstream) + public async Task SetUpstreamAsync(Models.Branch tracking) { - if (string.IsNullOrEmpty(upstream)) + if (tracking == null) Args = $"branch {_name} --unset-upstream"; else - Args = $"branch {_name} -u {upstream}"; + Args = $"branch {_name} -u {tracking.FriendlyName}"; return await ExecAsync().ConfigureAwait(false); } - public async Task DeleteLocalAsync() + public async Task DeleteLocalAsync(bool force) { - Args = $"branch -D {_name}"; + Args = $"branch {(force ? "-D" : "-d")} {_name}"; return await ExecAsync().ConfigureAwait(false); } - public async Task DeleteRemoteAsync(string remote) + public async Task DeleteRemoteAsync(string remote, bool force) { - Args = $"branch -D -r {remote}/{_name}"; + Args = $"branch {(force ? "-D" : "-d")} -r {remote}/{_name}"; return await ExecAsync().ConfigureAwait(false); } diff --git a/src/Commands/Checkout.cs b/src/Commands/Checkout.cs index 23cbd413b..024636bf9 100644 --- a/src/Commands/Checkout.cs +++ b/src/Commands/Checkout.cs @@ -72,5 +72,20 @@ public async Task FileWithRevisionAsync(string file, string revision) Args = $"checkout --no-overlay {revision} -- {file.Quoted()}"; return await ExecAsync().ConfigureAwait(false); } + + public async Task MultipleFilesWithRevisionAsync(List files, string revision) + { + var builder = new StringBuilder(); + builder + .Append("checkout --no-overlay ") + .Append(revision) + .Append(" --"); + + foreach (var f in files) + builder.Append(' ').Append(f.Quoted()); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } } } diff --git a/src/Commands/CherryPick.cs b/src/Commands/CherryPick.cs index 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 6ed749995..119b73aa6 100644 --- a/src/Commands/Clean.cs +++ b/src/Commands/Clean.cs @@ -2,11 +2,17 @@ { public class Clean : Command { - public Clean(string repo) + public Clean(string repo, Models.CleanMode mode) { WorkingDirectory = repo; Context = repo; - Args = "clean -qfdx"; + + Args = mode switch + { + Models.CleanMode.OnlyUntrackedFiles => "clean -qfd", + Models.CleanMode.OnlyIgnoredFiles => "clean -qfdX", + _ => "clean -qfdx", + }; } } } diff --git a/src/Commands/Clone.cs b/src/Commands/Clone.cs index efec264b8..ffd11bda9 100644 --- a/src/Commands/Clone.cs +++ b/src/Commands/Clone.cs @@ -1,4 +1,6 @@ -namespace SourceGit.Commands +using System.Text; + +namespace SourceGit.Commands { public class Clone : Command { @@ -7,15 +9,16 @@ public Clone(string ctx, string path, string url, string localName, string sshKe Context = ctx; WorkingDirectory = path; SSHKey = sshKey; - Args = "clone --progress --verbose "; + 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()); + + Args = builder.ToString(); } } } diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index 2cc85b8e8..977c81012 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -37,19 +37,6 @@ public enum EditorType public bool RaiseError { get; set; } = true; public Models.ICommandLog Log { get; set; } = null; - public void Exec() - { - try - { - var start = CreateGitStartInfo(false); - Process.Start(start); - } - catch (Exception ex) - { - App.RaiseException(Context, ex.Message); - } - } - public async Task ExecAsync() { Log?.AppendLine($"$ git {Args}\n"); @@ -61,8 +48,8 @@ public async Task ExecAsync() proc.OutputDataReceived += (_, e) => HandleOutput(e.Data, errs); proc.ErrorDataReceived += (_, e) => HandleOutput(e.Data, errs); - Process dummy = null; - var dummyProcLock = new object(); + var captured = new CapturedProcess() { Process = proc }; + var capturedLock = new object(); try { proc.Start(); @@ -70,13 +57,12 @@ public async Task ExecAsync() // Not safe, please only use `CancellationToken` in readonly commands. if (CancellationToken.CanBeCanceled) { - dummy = proc; CancellationToken.Register(() => { - lock (dummyProcLock) + lock (capturedLock) { - if (dummy is { HasExited: false }) - dummy.Kill(); + if (captured is { Process: { HasExited: false } }) + captured.Process.Kill(true); } }); } @@ -84,7 +70,7 @@ public async Task ExecAsync() catch (Exception e) { if (RaiseError) - App.RaiseException(Context, e.Message); + RaiseException(e.Message); Log?.AppendLine(string.Empty); return false; @@ -102,12 +88,9 @@ public async Task ExecAsync() HandleOutput(e.Message, errs); } - if (dummy != null) + lock (capturedLock) { - lock (dummyProcLock) - { - dummy = null; - } + captured.Process = null; } Log?.AppendLine(string.Empty); @@ -118,7 +101,7 @@ public async Task ExecAsync() { var errMsg = string.Join("\n", errs).Trim(); if (!string.IsNullOrEmpty(errMsg)) - App.RaiseException(Context, errMsg); + RaiseException(errMsg); } return false; @@ -127,9 +110,33 @@ public async Task ExecAsync() return true; } + protected Result ReadToEnd() + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + + try + { + proc.Start(); + } + catch (Exception e) + { + return Result.Failed(e.Message); + } + + var rs = new Result() { IsSuccess = true }; + rs.StdOut = proc.StandardOutput.ReadToEnd(); + rs.StdErr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + rs.IsSuccess = proc.ExitCode == 0; + return rs; + } + protected async Task ReadToEndAsync() { - using var proc = new Process() { StartInfo = CreateGitStartInfo(true) }; + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); try { @@ -149,11 +156,10 @@ protected async Task ReadToEndAsync() return rs; } - private ProcessStartInfo CreateGitStartInfo(bool redirect) + 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; @@ -166,16 +172,16 @@ private ProcessStartInfo CreateGitStartInfo(bool redirect) } // 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 if (OperatingSystem.IsLinux()) @@ -184,16 +190,27 @@ private ProcessStartInfo CreateGitStartInfo(bool redirect) start.Environment.Add("LC_ALL", "C"); } - // Force using this app as git editor. - start.Arguments += Editor switch + var builder = new StringBuilder(2048); + builder + .Append("--no-pager -c core.quotepath=off -c credential.helper=") + .Append(Native.OS.CredentialHelper) + .Append(' '); + + switch (Editor) { - EditorType.CoreEditor => $"""-c core.editor="{selfExecFile.Quoted()} --core-editor" """, - EditorType.RebaseEditor => $"""-c core.editor="{selfExecFile.Quoted()} --rebase-message-editor" -c sequence.editor="{selfExecFile.Quoted()} --rebase-todo-editor" -c rebase.abbreviateCommands=true """, - _ => "-c core.editor=true ", - }; + case EditorType.CoreEditor: + builder.Append($"""-c core.editor="\"{selfExecFile}\" --core-editor" """); + break; + case EditorType.RebaseEditor: + builder.Append($"""-c core.editor="\"{selfExecFile}\" --rebase-message-editor" -c sequence.editor="\"{selfExecFile}\" --rebase-todo-editor" -c rebase.abbreviateCommands=true """); + break; + default: + builder.Append("-c core.editor=true "); + break; + } - // Append command args - start.Arguments += Args; + builder.Append(Args); + start.Arguments = builder.ToString(); // Working directory if (!string.IsNullOrEmpty(WorkingDirectory)) @@ -202,6 +219,11 @@ private ProcessStartInfo CreateGitStartInfo(bool redirect) return start; } + protected void RaiseException(string error) + { + Models.Notification.Send(Context, error, true); + } + private void HandleOutput(string line, List errs) { if (line == null) @@ -226,6 +248,11 @@ private void HandleOutput(string line, List errs) 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 41d650f76..b756c4ab4 100644 --- a/src/Commands/Commit.cs +++ b/src/Commands/Commit.cs @@ -1,22 +1,39 @@ using System.IO; +using System.Text; using System.Threading.Tasks; namespace SourceGit.Commands { public class Commit : Command { - public Commit(string repo, string message, bool signOff, bool amend, bool resetAuthor) + public Commit(string repo, string message, bool signOff, bool noVerify, bool amend, bool resetAuthor) { _tmpFile = Path.GetTempFileName(); _message = message; WorkingDirectory = repo; Context = repo; - Args = $"commit --allow-empty --file={_tmpFile.Quoted()}"; + + 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) - Args += resetAuthor ? " --amend --reset-author --no-edit" : " --amend --no-edit"; + { + builder.Append("--amend "); + if (resetAuthor) + builder.Append("--reset-author "); + builder.Append("--no-edit"); + } + + Args = builder.ToString(); } public async Task RunAsync() @@ -34,7 +51,7 @@ public async Task RunAsync() } } - private readonly string _tmpFile = string.Empty; - private readonly string _message = string.Empty; + private readonly string _tmpFile; + private readonly string _message; } } diff --git a/src/Commands/CompareRevisions.cs b/src/Commands/CompareRevisions.cs index 7951e6adf..c7ee58915 100644 --- a/src/Commands/CompareRevisions.cs +++ b/src/Commands/CompareRevisions.cs @@ -1,5 +1,5 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; +using System.Diagnostics; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -7,9 +7,9 @@ namespace SourceGit.Commands { public partial class CompareRevisions : Command { - [GeneratedRegex(@"^([MADC])\s+(.+)$")] + [GeneratedRegex(@"^([MADT])\s+(.+)$")] private static partial Regex REG_FORMAT(); - [GeneratedRegex(@"^R[0-9]{0,4}\s+(.+)$")] + [GeneratedRegex(@"^([CR])[0-9]{0,4}\s+(.+)$")] private static partial Regex REG_RENAME_FORMAT(); public CompareRevisions(string repo, string start, string end) @@ -33,56 +33,63 @@ public CompareRevisions(string repo, string start, string end, string path) public async Task> ReadAsync() { var changes = new List(); - var rs = await ReadToEndAsync().ConfigureAwait(false); - if (!rs.IsSuccess) - return changes; + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); - var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) - ParseLine(changes, line); + 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); + } - changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); - return changes; - } + continue; + } - private void ParseLine(List outs, string line) - { - var match = REG_FORMAT().Match(line); - if (!match.Success) - { - match = REG_RENAME_FORMAT().Match(line); - if (match.Success) - { - var renamed = new Models.Change() { Path = match.Groups[1].Value }; - renamed.Set(Models.ChangeState.Renamed); - outs.Add(renamed); - } + var change = new Models.Change() { Path = match.Groups[2].Value }; + var status = match.Groups[1].Value; - return; - } + 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; + } + } - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; + await proc.WaitForExitAsync().ConfigureAwait(false); - switch (status[0]) + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + } + catch { - case 'M': - change.Set(Models.ChangeState.Modified); - outs.Add(change); - break; - case 'A': - change.Set(Models.ChangeState.Added); - outs.Add(change); - break; - case 'D': - change.Set(Models.ChangeState.Deleted); - outs.Add(change); - break; - case 'C': - change.Set(Models.ChangeState.Copied); - outs.Add(change); - break; + //ignore changes; } + + return changes; } } } diff --git a/src/Commands/Config.cs b/src/Commands/Config.cs index 0348c5109..75f4d738a 100644 --- a/src/Commands/Config.cs +++ b/src/Commands/Config.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Threading.Tasks; namespace SourceGit.Commands @@ -20,6 +21,26 @@ public Config(string repository) } } + public Dictionary ReadAll() + { + Args = "config -l"; + + var output = ReadToEnd(); + var rs = new Dictionary(); + if (output.IsSuccess) + { + var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var parts = line.Split('=', 2); + if (parts.Length == 2) + rs[parts[0]] = parts[1]; + } + } + + return rs; + } + public async Task> ReadAllAsync() { Args = "config -l"; @@ -33,13 +54,23 @@ public async Task> ReadAllAsync() { var parts = line.Split('=', 2); if (parts.Length == 2) - rs[parts[0]] = parts[1]; + { + var key = parts[0].ToLower(CultureInfo.CurrentCulture); // Always use lower case for key + var value = parts[1]; + rs[key] = value; + } } } return rs; } + public string Get(string key) + { + Args = $"config {key}"; + return ReadToEnd().StdOut.Trim(); + } + public async Task GetAsync(string key) { Args = $"config {key}"; diff --git a/src/Commands/CountLocalChangesWithoutUntracked.cs b/src/Commands/CountLocalChanges.cs similarity index 66% rename from src/Commands/CountLocalChangesWithoutUntracked.cs rename to src/Commands/CountLocalChanges.cs index 769d732e9..17916926d 100644 --- a/src/Commands/CountLocalChangesWithoutUntracked.cs +++ b/src/Commands/CountLocalChanges.cs @@ -3,13 +3,14 @@ namespace SourceGit.Commands { - public class CountLocalChangesWithoutUntracked : Command + public class CountLocalChanges : Command { - public CountLocalChangesWithoutUntracked(string repo) + public CountLocalChanges(string repo, bool includeUntracked) { + var option = includeUntracked ? "-uall" : "-uno"; WorkingDirectory = repo; Context = repo; - Args = "--no-optional-locks status -uno --ignore-submodules=all --porcelain"; + Args = $"--no-optional-locks status {option} --ignore-submodules=all --porcelain"; } public async Task GetResultAsync() diff --git a/src/Commands/Diff.cs b/src/Commands/Diff.cs index 3eae1b54b..89248ddc1 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -11,212 +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-all-space --unified={unified} {opt}"; - else if (Models.DiffOption.IgnoreCRAtEOL) - Args = $"diff --no-ext-diff --patch --ignore-cr-at-eol --unified={unified} {opt}"; - else - Args = $"diff --no-ext-diff --patch --unified={unified} {opt}"; + 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 async Task ReadAsync() { - var rs = await ReadToEndAsync().ConfigureAwait(false); - var sr = new StringReader(rs.StdOut); - while (sr.ReadLine() is { } line) - ParseLine(line); + 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 || _result.TextDiff.Lines.Count == 0) + 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; + } + } + _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; } - private void ParseLine(string line) + private void ParseLine(ArraySegment lineBytes) { - if (_result.IsBinary) + // Decode line bytes to UTF-8 string + var line = Encoding.UTF8.GetString(lineBytes.Array, lineBytes.Offset, lineBytes.Count); + if (line.Length == 0) return; - if (line.StartsWith("old mode ", StringComparison.Ordinal)) + // 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) { - _result.OldMode = line.Substring(9); - return; + 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 (line.StartsWith("deleted file mode ", StringComparison.Ordinal)) + // Fallback to diff headers to support type-changed diff (multiple headers). + ParseDiffHeaderLine(line); + } + + private void ParseDiffHeaderLine(string line) + { + if (line.StartsWith(SPECIAL_DIFF_START, StringComparison.Ordinal)) + return; + + if (ParseFileModeChange(line)) + return; + + var match = REG_HASH_CHANGE().Match(line); + if (match.Success) { - _result.OldMode = line.Substring(18); + if (string.IsNullOrEmpty(_result.OldHash)) + _result.OldHash = match.Groups[1].Value; + _result.NewHash = match.Groups[2].Value; return; } - if (line.StartsWith("new file mode ", StringComparison.Ordinal)) + if (line.StartsWith(SPECIAL_BINARY, StringComparison.Ordinal)) + _result.IsBinary = true; + } + + private bool ParseChunkStartLine(string line) + { + var match = REG_INDICATOR().Match(line); + if (match.Success) { - _result.NewMode = line.Substring(14); - 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 (_result.IsLFS) + return false; + } + + private bool ParseChunkBodyLine(string line, ArraySegment lineBytes) + { + var prefix = line[0]; + var content = line.Substring(1); + var rawContent = lineBytes[1..].ToArray(); + if (ParseLFSChange(prefix, content)) + return true; + + if (prefix == PREFIX_DELETED) { - var ch = line[0]; - if (ch == '-') - { - if (line.StartsWith("-oid sha256:", StringComparison.Ordinal)) - { - _result.LFSDiff.Old.Oid = line.Substring(12); - } - else if (line.StartsWith("-size ", StringComparison.Ordinal)) - { - _result.LFSDiff.Old.Size = long.Parse(line.AsSpan(6)); - } - } - else if (ch == '+') - { - if (line.StartsWith("+oid sha256:", StringComparison.Ordinal)) - { - _result.LFSDiff.New.Oid = line.Substring(12); - } - else if (line.StartsWith("+size ", StringComparison.Ordinal)) - { - _result.LFSDiff.New.Size = long.Parse(line.AsSpan(6)); - } - } - else if (line.StartsWith(" size ", StringComparison.Ordinal)) - { - _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(line.AsSpan(6)); - } - return; + _result.TextDiff.DeletedLines++; + _last = new Models.TextDiffLine(Models.TextDiffLineType.Deleted, content, rawContent, _oldLine, 0); + _deleted.Add(_last); + _oldLine++; + return true; } - if (_result.TextDiff.Lines.Count == 0) + if (prefix == PREFIX_ADDED) { - if (line.StartsWith("Binary", StringComparison.Ordinal)) - { - _result.IsBinary = true; - return; - } + _result.TextDiff.AddedLines++; + _last = new Models.TextDiffLine(Models.TextDiffLineType.Added, content, rawContent, 0, _newLine); + _added.Add(_last); + _newLine++; + return true; + } - if (string.IsNullOrEmpty(_result.OldHash)) - { - var match = REG_HASH_CHANGE().Match(line); - if (!match.Success) - return; + if (prefix == PREFIX_CONTEXT) + { + ProcessInlineHighlights(); - _result.OldHash = match.Groups[1].Value; - _result.NewHash = match.Groups[2].Value; - } - else - { - var match = REG_INDICATOR().Match(line); - if (!match.Success) - return; - - _oldLine = int.Parse(match.Groups[1].Value); - _newLine = int.Parse(match.Groups[2].Value); - _last = new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0); - _result.TextDiff.Lines.Add(_last); - } + _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, content, rawContent, _oldLine, _newLine); + _result.TextDiff.Lines.Add(_last); + _oldLine++; + _newLine++; + return true; } - else + + if (prefix == PREFIX_COMMAND) { - if (line.Length == 0) - { - ProcessInlineHighlights(); - _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", _oldLine, _newLine); - _result.TextDiff.Lines.Add(_last); - _oldLine++; - _newLine++; - return; - } + if (content.Equals(SPECIAL_NO_NEWLINE, StringComparison.Ordinal)) + _last.NoNewLineEndOfFile = true; + 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; - } + return false; + } - _last = new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), _oldLine, 0); - _deleted.Add(_last); - _oldLine++; + 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; - } - - _last = new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), 0, _newLine); - _added.Add(_last); - _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); - _last = new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0); - _result.TextDiff.Lines.Add(_last); - } - else - { - if (_oldLine == 1 && _newLine == 1 && line.StartsWith(PREFIX_LFS_MODIFY, StringComparison.Ordinal)) - { - _result.IsLFS = true; - _result.LFSDiff = new Models.LFSDiff(); - return; - } - - _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, line.Substring(1), _oldLine, _newLine); - _result.TextDiff.Lines.Add(_last); - _oldLine++; - _newLine++; - } + if (content.StartsWith(LFS_SIZE_PREFIX, StringComparison.Ordinal)) + _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(content.AsSpan(5)); } - else if (line.Equals("\\ No newline at end of file", StringComparison.Ordinal)) + return true; + } + + if ((_oldLine == 1 && _newLine == 1 && prefix == PREFIX_CONTEXT) || + (_oldLine == 1 && _newLine == 0 && prefix == PREFIX_DELETED) || + (_oldLine == 0 && _newLine == 1 && prefix == PREFIX_ADDED)) + { + if (content.StartsWith(LFS_SPECIFIER, StringComparison.Ordinal)) { - _last.NoNewLineEndOfFile = true; + _isLFS = true; + _result.LFSDiff = new Models.LFSDiff(); + return true; } } + + return false; } private void ProcessInlineHighlights() @@ -240,10 +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)); } } } @@ -265,5 +365,7 @@ private void ProcessInlineHighlights() 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 index b80f55959..a102bd474 100644 --- a/src/Commands/DiffTool.cs +++ b/src/Commands/DiffTool.cs @@ -1,47 +1,77 @@ -using System.IO; +using System; +using System.Diagnostics; namespace SourceGit.Commands { public class DiffTool : Command { - public DiffTool(string repo, int type, string exec, Models.DiffOption option) + public DiffTool(string repo, Models.DiffOption option) { WorkingDirectory = repo; Context = repo; - - _merger = Models.ExternalMerger.Supported.Find(x => x.Type == type); - _exec = exec; _option = option; } public void Open() { - if (_merger == null) + var tool = Native.OS.GetDiffMergeTool(true); + if (tool == null) { - App.RaiseException(Context, "Invalid merge tool in preference setting!"); + RaiseException("Invalid diff/merge tool in preference setting!"); return; } - if (_merger.Type == 0) + if (string.IsNullOrEmpty(tool.Cmd)) { + if (!CheckGitConfiguration()) + return; + Args = $"difftool -g --no-prompt {_option}"; } - else if (File.Exists(_exec)) + else { - var cmd = $"{_exec.Quoted()} {_merger.DiffCmd}"; + var cmd = $"{tool.Exec.Quoted()} {tool.Cmd}"; Args = $"-c difftool.sourcegit.cmd={cmd.Quoted()} difftool --tool=sourcegit --no-prompt {_option}"; } - else + + try { - App.RaiseException(Context, $"Can NOT find external diff tool in '{_exec}'!"); - return; + 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; } - Exec(); + return true; } - private Models.ExternalMerger _merger; - private string _exec; private Models.DiffOption _option; } } diff --git a/src/Commands/Discard.cs b/src/Commands/Discard.cs index eaf5f844d..8d08b90a7 100644 --- a/src/Commands/Discard.cs +++ b/src/Commands/Discard.cs @@ -10,38 +10,44 @@ public static class Discard /// /// Discard all local changes (unstaged & staged) /// - /// - /// - /// - public static async Task AllAsync(string repo, bool includeIgnored, Models.ICommandLog log) + public static async Task AllAsync(string repo, bool includeModified, bool includeUntracked, bool includeIgnored, Models.ICommandLog log) { - var changes = await new QueryLocalChanges(repo).GetResultAsync().ConfigureAwait(false); - try + if (includeUntracked) { - foreach (var c in changes) + // Untracked paths that contains `.git` file (detached submodule) must be removed manually. + var changes = await new QueryLocalChanges(repo).GetResultAsync().ConfigureAwait(false); + try { - if (c.WorkTree == Models.ChangeState.Untracked || - c.WorkTree == Models.ChangeState.Added || - c.Index == Models.ChangeState.Added || - c.Index == Models.ChangeState.Renamed) + foreach (var c in changes) { - var fullPath = Path.Combine(repo, c.Path); - if (Directory.Exists(fullPath)) - Directory.Delete(fullPath, true); - else - File.Delete(fullPath); + 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); } - catch (Exception e) + else if (includeIgnored) { - App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + await new Clean(repo, Models.CleanMode.OnlyIgnoredFiles).Use(log).ExecAsync().ConfigureAwait(false); } - await new Reset(repo, "HEAD", "--hard").Use(log).ExecAsync().ConfigureAwait(false); - - if (includeIgnored) - await new Clean(repo).Use(log).ExecAsync().ConfigureAwait(false); + if (includeModified) + await new Reset(repo, "", "--hard").Use(log).ExecAsync().ConfigureAwait(false); } /// @@ -74,14 +80,14 @@ public static async Task ChangesAsync(string repo, List changes, } catch (Exception e) { - App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + Models.Notification.Send(repo, $"Failed to discard changes. Reason: {e.Message}", true); } if (restores.Count > 0) { var pathSpecFile = Path.GetTempFileName(); await File.WriteAllLinesAsync(pathSpecFile, restores).ConfigureAwait(false); - await new Restore(repo, pathSpecFile, false).Use(log).ExecAsync().ConfigureAwait(false); + await new Restore(repo, pathSpecFile).Use(log).ExecAsync().ConfigureAwait(false); File.Delete(pathSpecFile); } } diff --git a/src/Commands/Fetch.cs b/src/Commands/Fetch.cs index d25cc80c8..914361257 100644 --- a/src/Commands/Fetch.cs +++ b/src/Commands/Fetch.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -6,27 +7,35 @@ public class Fetch : Command { public Fetch(string repo, string remote, bool noTags, bool force) { - _remoteKey = $"remote.{remote}.sshkey"; + _remote = remote; WorkingDirectory = repo; Context = repo; - 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) - Args += "--force "; + builder.Append("--force "); + builder.Append(remote); + + Args = builder.ToString(); + } - Args += remote; + public Fetch(string repo, string remote) + { + _remote = remote; + + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + Args = $"fetch --progress --verbose {remote}"; } public Fetch(string repo, Models.Branch local, Models.Branch remote) { - _remoteKey = $"remote.{remote.Remote}.sshkey"; + _remote = remote.Remote; WorkingDirectory = repo; Context = repo; @@ -35,10 +44,10 @@ public Fetch(string repo, Models.Branch local, Models.Branch remote) public async Task RunAsync() { - SSHKey = await new Config(WorkingDirectory).GetAsync(_remoteKey).ConfigureAwait(false); + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{_remote}.sshkey").ConfigureAwait(false); return await ExecAsync().ConfigureAwait(false); } - private readonly string _remoteKey; + private readonly string _remote; } } diff --git a/src/Commands/GenerateCommitMessage.cs b/src/Commands/GenerateCommitMessage.cs deleted file mode 100644 index 4889881bf..000000000 --- a/src/Commands/GenerateCommitMessage.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -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 async Task ReadAsync() - { - return await ReadToEndAsync().ConfigureAwait(false); - } - } - - public GenerateCommitMessage(Models.OpenAIService service, string repo, List changes, CancellationToken cancelToken, Action onResponse) - { - _service = service; - _repo = repo; - _changes = changes; - _cancelToken = cancelToken; - _onResponse = onResponse; - } - - public async Task ExecAsync() - { - try - { - _onResponse?.Invoke("Waiting for pre-file analyzing to completed...\n\n"); - - var responseBuilder = new StringBuilder(); - var summaryBuilder = new StringBuilder(); - foreach (var change in _changes) - { - if (_cancelToken.IsCancellationRequested) - return; - - responseBuilder.Append("- "); - summaryBuilder.Append("- "); - - var rs = await new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadAsync(); - if (rs.IsSuccess) - { - await _service.ChatAsync( - _service.AnalyzeDiffPrompt, - $"Here is the `git diff` output: {rs.StdOut}", - _cancelToken, - update => - { - responseBuilder.Append(update); - summaryBuilder.Append(update); - - _onResponse?.Invoke($"Waiting for pre-file analyzing to completed...\n\n{responseBuilder}"); - }); - } - - responseBuilder.AppendLine(); - summaryBuilder.Append("(file: ").Append(change.Path).AppendLine(")"); - } - - if (_cancelToken.IsCancellationRequested) - return; - - var responseBody = responseBuilder.ToString(); - var subjectBuilder = new StringBuilder(); - await _service.ChatAsync( - _service.GenerateSubjectPrompt, - $"Here are the summaries changes:\n{summaryBuilder}", - _cancelToken, - update => - { - subjectBuilder.Append(update); - _onResponse?.Invoke($"{subjectBuilder}\n\n{responseBody}"); - }); - } - catch (Exception e) - { - App.RaiseException(_repo, $"Failed to generate commit message: {e}"); - } - } - - private Models.OpenAIService _service; - private string _repo; - private List _changes; - private CancellationToken _cancelToken; - private Action _onResponse; - } -} diff --git a/src/Commands/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 56002e223..0438d76de 100644 --- a/src/Commands/GitFlow.cs +++ b/src/Commands/GitFlow.cs @@ -3,53 +3,80 @@ namespace SourceGit.Commands { - public static class GitFlow + public class GitFlow : Command { - public static async Task InitAsync(string repo, string master, string develop, string feature, string release, string hotfix, string version, Models.ICommandLog log) + public GitFlow(string repo) { - var config = new Config(repo); - await config.SetAsync("gitflow.branch.master", master).ConfigureAwait(false); + WorkingDirectory = repo; + Context = repo; + } + + public async Task InitAsync(string production, string develop, string feature, string release, string hotfix, string tag) + { + if (Native.OS.GitFlowVersion == Models.GitFlowVersion.Next) + { + var builder = new StringBuilder(); + builder + .Append("flow init --preset=classic ") + .Append("--main=").Append(production.Quoted()).Append(' ') + .Append("--develop=").Append(develop.Quoted()).Append(' ') + .Append("--feature=").Append(feature).Append(' ') + .Append("--bugfix=bugfix/ ") + .Append("--release=").Append(release).Append(' ') + .Append("--hotfix=").Append(hotfix).Append(' ') + .Append("--support=support/"); + + if (!string.IsNullOrEmpty(tag)) + builder.Append(" --tag=").Append(tag); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } + + 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", version, true).ConfigureAwait(false); + await config.SetAsync("gitflow.prefix.versiontag", tag, true).ConfigureAwait(false); - var init = new Command(); - init.WorkingDirectory = repo; - init.Context = repo; - init.Args = "flow init -d"; - return await init.Use(log).ExecAsync().ConfigureAwait(false); + Args = "flow init -d"; + return await ExecAsync().ConfigureAwait(false); } - public static async Task StartAsync(string repo, Models.GitFlowBranchType type, string name, Models.ICommandLog log) + public async Task StartAsync(Models.GitFlowBranchType type, string name, Models.Branch based) { - var start = new Command(); - start.WorkingDirectory = repo; - start.Context = repo; + var builder = new StringBuilder(); + builder.Append("flow "); switch (type) { case Models.GitFlowBranchType.Feature: - start.Args = $"flow feature start {name}"; + builder.Append("feature"); break; case Models.GitFlowBranchType.Release: - start.Args = $"flow release start {name}"; + builder.Append("release"); break; case Models.GitFlowBranchType.Hotfix: - start.Args = $"flow hotfix start {name}"; + builder.Append("hotfix"); break; default: - App.RaiseException(repo, "Bad git-flow branch type!!!"); + RaiseException("Bad git-flow branch type!!!"); return false; } - return await start.Use(log).ExecAsync().ConfigureAwait(false); + builder.Append(" start ").Append(name); + if (based != null) + builder.Append(' ').Append(based.Name); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public static async Task FinishAsync(string repo, Models.GitFlowBranchType type, string name, bool squash, bool push, bool keepBranch, Models.ICommandLog log) + public async Task FinishAsync(Models.GitFlowBranchType type, string name, bool rebase, bool squash, bool keepBranch) { var builder = new StringBuilder(); builder.Append("flow "); @@ -66,24 +93,21 @@ public static async Task FinishAsync(string repo, Models.GitFlowBranchType builder.Append("hotfix"); break; default: - App.RaiseException(repo, "Bad git-flow branch type!!!"); + RaiseException("Bad git-flow branch type!!!"); return false; } builder.Append(" finish "); + if (rebase) + builder.Append("--rebase "); if (squash) builder.Append("--squash "); - if (push) - builder.Append("--push "); if (keepBranch) - builder.Append("-k "); + builder.Append("--keep "); builder.Append(name); - var finish = new Command(); - finish.WorkingDirectory = repo; - finish.Context = repo; - finish.Args = builder.ToString(); - return await finish.Use(log).ExecAsync().ConfigureAwait(false); + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } } } diff --git a/src/Commands/InteractiveRebase.cs b/src/Commands/InteractiveRebase.cs index ebcadec13..6b61b9d2a 100644 --- a/src/Commands/InteractiveRebase.cs +++ b/src/Commands/InteractiveRebase.cs @@ -1,16 +1,23 @@ -namespace SourceGit.Commands +using System.Text; + +namespace SourceGit.Commands { public class InteractiveRebase : Command { - public InteractiveRebase(string repo, string basedOn, bool autoStash) + public InteractiveRebase(string repo, string basedOn, bool autoStash, bool noVerify) { WorkingDirectory = repo; Context = repo; Editor = EditorType.RebaseEditor; - Args = "rebase -i --autosquash "; + + var builder = new StringBuilder(512); + builder.Append("-c core.commentChar=\"^\" -c core.commentString=\"±\" rebase -i --autosquash "); if (autoStash) - Args += "--autostash "; - Args += basedOn; + builder.Append("--autostash "); + if (noVerify) + builder.Append("--no-verify "); + + Args = builder.Append(basedOn).ToString(); } } } diff --git a/src/Commands/IsConflictResolved.cs b/src/Commands/IsAncestor.cs similarity index 58% rename from src/Commands/IsConflictResolved.cs rename to src/Commands/IsAncestor.cs index 2d53766a2..e40793e2d 100644 --- a/src/Commands/IsConflictResolved.cs +++ b/src/Commands/IsAncestor.cs @@ -2,15 +2,13 @@ namespace SourceGit.Commands { - public class IsConflictResolved : Command + public class IsAncestor : Command { - public IsConflictResolved(string repo, Models.Change change) + public IsAncestor(string repo, string checkPoint, string endPoint) { - var opt = new Models.DiffOption(change, true); - WorkingDirectory = repo; Context = repo; - Args = $"diff -a --ignore-cr-at-eol --check {opt}"; + Args = $"merge-base --is-ancestor {checkPoint} {endPoint}"; } public async Task GetResultAsync() diff --git a/src/Commands/IsBareRepository.cs b/src/Commands/IsBareRepository.cs index 98b127ce6..03520131c 100644 --- a/src/Commands/IsBareRepository.cs +++ b/src/Commands/IsBareRepository.cs @@ -11,6 +11,17 @@ public IsBareRepository(string 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")) || diff --git a/src/Commands/IsBinary.cs b/src/Commands/IsBinary.cs index 0e60f38c6..9dbe05459 100644 --- a/src/Commands/IsBinary.cs +++ b/src/Commands/IsBinary.cs @@ -8,11 +8,11 @@ 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 {Models.Commit.EmptyTreeSHA1} {commit} --numstat -- {path.Quoted()}"; + Args = $"diff --no-color --no-ext-diff --numstat {Models.EmptyTreeHash.Guess(revision)} {revision} -- {path.Quoted()}"; RaiseError = false; } diff --git a/src/Commands/IsLFSFiltered.cs b/src/Commands/IsLFSFiltered.cs index e8e5513ca..5c45f8bc6 100644 --- a/src/Commands/IsLFSFiltered.cs +++ b/src/Commands/IsLFSFiltered.cs @@ -20,9 +20,19 @@ public IsLFSFiltered(string repo, string sha, string path) RaiseError = false; } + public bool GetResult() + { + return Parse(ReadToEnd()); + } + public async Task GetResultAsync() { var rs = await ReadToEndAsync().ConfigureAwait(false); + return Parse(rs); + } + + private bool Parse(Result rs) + { return rs.IsSuccess && rs.StdOut.Contains("filter\0lfs"); } } diff --git a/src/Commands/IssueTracker.cs b/src/Commands/IssueTracker.cs new file mode 100644 index 000000000..e4c156417 --- /dev/null +++ b/src/Commands/IssueTracker.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class IssueTracker : Command + { + public IssueTracker(string repo, bool isShared) + { + WorkingDirectory = repo; + Context = repo; + + if (isShared) + { + var storage = $"{repo}/.issuetracker"; + _isStorageFileExists = File.Exists(storage); + _baseArg = $"config -f {storage.Quoted()}"; + } + else + { + _isStorageFileExists = true; + _baseArg = "config --local"; + } + } + + public async Task ReadAllAsync(List outs, bool isShared) + { + if (!_isStorageFileExists) + return; + + Args = $"{_baseArg} -l"; + + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var parts = line.Split('=', 2); + if (parts.Length < 2) + continue; + + var key = parts[0]; + var value = parts[1]; + + if (!key.StartsWith("issuetracker.", StringComparison.Ordinal)) + continue; + + if (key.EndsWith(".regex", StringComparison.Ordinal)) + { + var prefixLen = "issuetracker.".Length; + var suffixLen = ".regex".Length; + var ruleName = key.Substring(prefixLen, key.Length - prefixLen - suffixLen); + FindOrAdd(outs, ruleName, isShared).RegexString = value; + } + else if (key.EndsWith(".url", StringComparison.Ordinal)) + { + var prefixLen = "issuetracker.".Length; + var suffixLen = ".url".Length; + var ruleName = key.Substring(prefixLen, key.Length - prefixLen - suffixLen); + FindOrAdd(outs, ruleName, isShared).URLTemplate = value; + } + } + } + } + + public async Task AddAsync(Models.IssueTracker rule) + { + Args = $"{_baseArg} issuetracker.{rule.Name.Quoted()}.regex {rule.RegexString.Quoted()}"; + + var succ = await ExecAsync().ConfigureAwait(false); + if (succ) + { + Args = $"{_baseArg} issuetracker.{rule.Name.Quoted()}.url {rule.URLTemplate.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + return false; + } + + public async Task UpdateRegexAsync(Models.IssueTracker rule) + { + Args = $"{_baseArg} issuetracker.{rule.Name.Quoted()}.regex {rule.RegexString.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task UpdateURLTemplateAsync(Models.IssueTracker rule) + { + Args = $"{_baseArg} issuetracker.{rule.Name.Quoted()}.url {rule.URLTemplate.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task RemoveAsync(string name) + { + if (!_isStorageFileExists) + return true; + + Args = $"{_baseArg} --remove-section issuetracker.{name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + private Models.IssueTracker FindOrAdd(List rules, string ruleName, bool isShared) + { + var rule = rules.Find(x => x.Name.Equals(ruleName, StringComparison.Ordinal)); + if (rule != null) + return rule; + + rule = new Models.IssueTracker() { IsShared = isShared, Name = ruleName }; + rules.Add(rule); + return rule; + } + + private readonly bool _isStorageFileExists; + private readonly string _baseArg; + } +} diff --git a/src/Commands/LFS.cs b/src/Commands/LFS.cs index 001002d1d..6e4283359 100644 --- a/src/Commands/LFS.cs +++ b/src/Commands/LFS.cs @@ -1,16 +1,12 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Text; -using System.Text.RegularExpressions; +using System.Text.Json; using System.Threading.Tasks; namespace SourceGit.Commands { - public partial class LFS : Command + public class LFS : Command { - [GeneratedRegex(@"^(.+)\s+([\w.]+)\s+\w+:(\d+)$")] - private static partial Regex REG_LOCK(); - public LFS(string repo) { WorkingDirectory = repo; @@ -60,30 +56,23 @@ public async Task PruneAsync() public async Task> GetLocksAsync(string remote) { - Args = $"lfs locks --remote={remote}"; + Args = $"lfs locks --json --remote={remote}"; var rs = await ReadToEndAsync().ConfigureAwait(false); - var locks = new List(); - if (rs.IsSuccess) { - var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) + try + { + var locks = JsonSerializer.Deserialize(rs.StdOut, JsonCodeGen.Default.ListLFSLock); + return locks; + } + catch { - 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), - }); - } + // Ignore exceptions. } } - return locks; + return []; } public async Task LockAsync(string remote, string file) @@ -104,5 +93,20 @@ public async Task UnlockAsync(string remote, string file, bool force) Args = builder.ToString(); return await ExecAsync().ConfigureAwait(false); } + + public async Task UnlockMultipleAsync(string remote, List files, bool force) + { + var builder = new StringBuilder(); + builder + .Append("lfs unlock --remote=") + .Append(remote) + .Append(force ? " -f" : " "); + + foreach (string file in files) + builder.Append(' ').Append(file.Quoted()); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } } } diff --git a/src/Commands/Merge.cs b/src/Commands/Merge.cs index bd11b5779..bcb8811f9 100644 --- a/src/Commands/Merge.cs +++ b/src/Commands/Merge.cs @@ -9,7 +9,6 @@ public Merge(string repo, string source, string mode, bool edit) { WorkingDirectory = repo; Context = repo; - Editor = EditorType.CoreEditor; var builder = new StringBuilder(); builder.Append("merge --progress "); 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 c2262c69f..45e17cc6e 100644 --- a/src/Commands/MergeTool.cs +++ b/src/Commands/MergeTool.cs @@ -1,48 +1,65 @@ -using System.IO; +using System; using System.Threading.Tasks; namespace SourceGit.Commands { public class MergeTool : Command { - public MergeTool(string repo, int type, string exec, string file) + public MergeTool(string repo, string file) { WorkingDirectory = repo; - Context = exec; - - _merger = Models.ExternalMerger.Supported.Find(x => x.Type == type); - _exec = exec; + Context = repo; _file = string.IsNullOrEmpty(file) ? string.Empty : file.Quoted(); } public async Task OpenAsync() { - if (_merger == null) + var tool = Native.OS.GetDiffMergeTool(false); + if (tool == null) { - App.RaiseException(Context, "Invalid merge tool in preference setting!"); + RaiseException("Invalid diff/merge tool in preference setting!"); return false; } - if (_merger.Type == 0) + if (string.IsNullOrEmpty(tool.Cmd)) { - Args = $"mergetool {_file}"; + var ok = await CheckGitConfigurationAsync(); + if (!ok) + return false; + + Args = $"mergetool -g --no-prompt {_file}"; } - else if (File.Exists(_exec)) + else { - var cmd = $"{_exec.Quoted()} {_merger.Cmd}"; + 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}"; } - else + + return await ExecAsync().ConfigureAwait(false); + } + + private async Task CheckGitConfigurationAsync() + { + var tool = await new Config(WorkingDirectory).GetAsync("merge.guitool"); + if (string.IsNullOrEmpty(tool)) + tool = await new Config(WorkingDirectory).GetAsync("merge.tool"); + + if (string.IsNullOrEmpty(tool)) { - App.RaiseException(Context, $"Can NOT find external merge tool in '{_exec}'!"); + RaiseException("Missing git configuration: merge.guitool"); return false; } - return await ExecAsync().ConfigureAwait(false); + if (tool.StartsWith("vimdiff", StringComparison.Ordinal) || + tool.StartsWith("nvimdiff", StringComparison.Ordinal)) + { + RaiseException($"CLI based merge tool \"{tool}\" is not supported by this app!"); + return false; + } + + return true; } - private Models.ExternalMerger _merger; - private string _exec; 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/Pull.cs b/src/Commands/Pull.cs index 93896c754..75b433e73 100644 --- a/src/Commands/Pull.cs +++ b/src/Commands/Pull.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -10,12 +11,17 @@ public Pull(string repo, string remote, string branch, bool useRebase) WorkingDirectory = repo; Context = repo; - Args = "pull --verbose --progress "; - if (useRebase) - Args += "--rebase=true "; + 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(); } public async Task RunAsync() diff --git a/src/Commands/Push.cs b/src/Commands/Push.cs index b822af46d..44394dc41 100644 --- a/src/Commands/Push.cs +++ b/src/Commands/Push.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -10,18 +11,20 @@ public Push(string repo, string local, string remote, string remoteBranch, bool WorkingDirectory = repo; Context = repo; - 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 refname, bool isDelete) @@ -30,12 +33,14 @@ public Push(string repo, string remote, string refname, bool isDelete) WorkingDirectory = repo; Context = repo; - 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} {refname}"; + Args = builder.ToString(); } public async Task RunAsync() 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 0eaf2837f..b741fb5c0 100644 --- a/src/Commands/QueryBranches.cs +++ b/src/Commands/QueryBranches.cs @@ -15,7 +15,7 @@ public QueryBranches(string repo) { WorkingDirectory = repo; Context = repo; - Args = "branch -l --all -v --format=\"%(refname)%00%(committerdate:unix)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(upstream:trackshort)\""; + Args = "branch -l --all -v --format=\"%(refname)%00%(committerdate:unix)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(upstream:trackshort)%00%(worktreepath)\""; } public async Task> GetResultAsync() @@ -26,15 +26,16 @@ public QueryBranches(string repo) return branches; var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - var remoteHeads = new Dictionary(); + 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) - remoteHeads.Add(b.FullName, b.Head); + remotes.Add(b.FullName, b); } } @@ -42,15 +43,16 @@ public QueryBranches(string repo) { if (b.IsLocal && !string.IsNullOrEmpty(b.Upstream)) { - if (remoteHeads.TryGetValue(b.Upstream, out var upstreamHead)) + if (remotes.TryGetValue(b.Upstream, out var upstream)) { b.IsUpstreamGone = false; - b.TrackStatus ??= await new QueryTrackStatus(WorkingDirectory, b.Head, upstreamHead).GetResultAsync().ConfigureAwait(false); + + if (mismatched.Contains(b.FullName)) + await new QueryTrackStatus(WorkingDirectory).GetResultAsync(b, upstream).ConfigureAwait(false); } else { b.IsUpstreamGone = true; - b.TrackStatus ??= new Models.BranchTrackStatus(); } } } @@ -58,10 +60,10 @@ public QueryBranches(string repo) return branches; } - private Models.Branch ParseLine(string line) + private Models.Branch ParseLine(string line, HashSet mismatched) { var parts = line.Split('\0'); - if (parts.Length != 6) + if (parts.Length != 7) return null; var branch = new Models.Branch(); @@ -94,19 +96,22 @@ private Models.Branch ParseLine(string line) branch.IsLocal = true; } + ulong.TryParse(parts[1], out var committerDate); + branch.FullName = refName; - branch.CommitterDate = ulong.Parse(parts[1]); + branch.CommitterDate = committerDate; branch.Head = parts[2]; branch.IsCurrent = parts[3] == "*"; branch.Upstream = parts[4]; branch.IsUpstreamGone = false; - if (!branch.IsLocal || - string.IsNullOrEmpty(branch.Upstream) || - string.IsNullOrEmpty(parts[5]) || - parts[5].Equals("=", StringComparison.Ordinal)) - branch.TrackStatus = new Models.BranchTrackStatus(); + 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/QueryCommitChildren.cs b/src/Commands/QueryCommitChildren.cs deleted file mode 100644 index 6af0abb73..000000000 --- a/src/Commands/QueryCommitChildren.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace SourceGit.Commands -{ - public class QueryCommitChildren : Command - { - public QueryCommitChildren(string repo, string commit, int max) - { - WorkingDirectory = repo; - Context = repo; - _commit = commit; - Args = $"rev-list -{max} --parents --branches --remotes --ancestry-path ^{commit}"; - } - - public async Task> GetResultAsync() - { - var rs = await ReadToEndAsync().ConfigureAwait(false); - var outs = new List(); - if (rs.IsSuccess) - { - var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) - { - if (line.Contains(_commit)) - outs.Add(line.Substring(0, 40)); - } - } - - return outs; - } - - private string _commit; - } -} diff --git a/src/Commands/QueryCommitFullMessage.cs b/src/Commands/QueryCommitFullMessage.cs index 07b77f2e3..2e9db9298 100644 --- a/src/Commands/QueryCommitFullMessage.cs +++ b/src/Commands/QueryCommitFullMessage.cs @@ -11,6 +11,12 @@ public QueryCommitFullMessage(string repo, string sha) Args = $"show --no-show-signature --format=%B -s {sha}"; } + public string GetResult() + { + var rs = ReadToEnd(); + return rs.IsSuccess ? rs.StdOut.TrimEnd() : string.Empty; + } + public async Task GetResultAsync() { var rs = await ReadToEndAsync().ConfigureAwait(false); diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs index 311406bdc..1a5073988 100644 --- a/src/Commands/QueryCommits.cs +++ b/src/Commands/QueryCommits.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Text; using System.Threading.Tasks; @@ -7,144 +8,104 @@ 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 --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {limits}"; - _findFirstMerged = needFindHead; + 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 (!onlyCurrentBranch) + builder.Append("--branches --remotes "); if (method == Models.CommitSearchMethod.ByAuthor) { - search += $"-i --author={filter.Quoted()}"; - } - else if (method == Models.CommitSearchMethod.ByCommitter) - { - search += $"-i --committer={filter.Quoted()}"; + builder.Append("-i --author=").Append(filter.Quoted()); } else if (method == Models.CommitSearchMethod.ByMessage) { - var argsBuilder = new StringBuilder(); - argsBuilder.Append(search); - var words = filter.Split([' ', '\t', '\r'], StringSplitOptions.RemoveEmptyEntries); foreach (var word in words) - argsBuilder.Append("--grep=").Append(word.Trim().Quoted()).Append(' '); - argsBuilder.Append("--all-match -i"); - - search = argsBuilder.ToString(); + builder.Append("--grep=").Append(word.Trim().Quoted()).Append(' '); + builder.Append("--all-match -i"); } else if (method == Models.CommitSearchMethod.ByPath) { - search += $"-- {filter.Quoted()}"; + builder.Append("-- ").Append(filter.Quoted()); } else { - search = $"-G{filter.Quoted()}"; + builder.Append("-G").Append(filter.Quoted()); } WorkingDirectory = repo; Context = repo; - Args = $"log -1000 --date-order --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {search}"; - _findFirstMerged = false; + Args = builder.ToString(); + _markMerged = false; } public async Task> GetResultAsync() { - var rs = await ReadToEndAsync().ConfigureAwait(false); - 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 && !_isHeadFound) - _isHeadFound = 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 && !_isHeadFound && _commits.Count > 0) - await MarkFirstMergedAsync().ConfigureAwait(false); - - return _commits; - } + await proc.WaitForExitAsync().ConfigureAwait(false); - private void ParseParent(string data) - { - if (data.Length < 8) - return; - - _current.Parents.AddRange(data.Split(' ', StringSplitOptions.RemoveEmptyEntries)); - } - - private async Task MarkFirstMergedAsync() - { - Args = $"log --since={_commits[^1].CommitterTimeStr.Quoted()} --format=\"%H\""; - - var rs = await ReadToEndAsync().ConfigureAwait(false); - var shas = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - if (shas.Length == 0) - return; - - var set = new HashSet(shas); - - 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 _isHeadFound = false; + private bool _markMerged = false; } } diff --git a/src/Commands/QueryCommitsForInteractiveRebase.cs b/src/Commands/QueryCommitsForInteractiveRebase.cs index 81e28d4fc..7f0d1ba1a 100644 --- a/src/Commands/QueryCommitsForInteractiveRebase.cs +++ b/src/Commands/QueryCommitsForInteractiveRebase.cs @@ -12,14 +12,20 @@ public QueryCommitsForInteractiveRebase(string repo, string on) WorkingDirectory = repo; Context = repo; - Args = $"log --date-order --no-show-signature --decorate=full --format=\"%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%B%n{_boundary}\" {on}..HEAD"; + 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) - return _commits; + { + RaiseException($"Failed to query commits for interactive-rebase. Reason: {rs.StdErr}"); + return commits; + } + + Models.InteractiveCommit current = null; var nextPartIdx = 0; var start = 0; @@ -30,38 +36,41 @@ public QueryCommitsForInteractiveRebase(string repo, string on) switch (nextPartIdx) { case 0: - _current = new Models.InteractiveCommit(); - _current.Commit.SHA = line; - _commits.Add(_current); + current = new Models.InteractiveCommit(); + current.Commit.SHA = line; + commits.Add(current); break; case 1: - ParseParent(line); + current.Commit.ParseParents(line); break; case 2: - _current.Commit.ParseDecorators(line); + current.Commit.ParseDecorators(line); break; case 3: - _current.Commit.Author = Models.User.FindOrAdd(line); + current.Commit.Author = Models.User.FindOrAdd(line); break; case 4: - _current.Commit.AuthorTime = ulong.Parse(line); + current.Commit.AuthorTime = ulong.Parse(line); break; case 5: - _current.Commit.Committer = Models.User.FindOrAdd(line); + current.Commit.Committer = Models.User.FindOrAdd(line); break; case 6: - _current.Commit.CommitterTime = ulong.Parse(line); + 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); + current.Message = rs.StdOut.Substring(start, boundary - start - 1); end = boundary + _boundary.Length; } else { - _current.Message = rs.StdOut.Substring(start); + current.Message = rs.StdOut.Substring(start); end = rs.StdOut.Length - 2; } @@ -78,19 +87,9 @@ public QueryCommitsForInteractiveRebase(string repo, string on) end = rs.StdOut.IndexOf('\n', start); } - return _commits; - } - - private void ParseParent(string data) - { - if (data.Length < 8) - return; - - _current.Commit.Parents.AddRange(data.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + return commits; } - private List _commits = []; - private Models.InteractiveCommit _current = null; private readonly string _boundary; } } diff --git a/src/Commands/QueryConflictFileState.cs b/src/Commands/QueryConflictFileState.cs new file mode 100644 index 000000000..f9a52ee12 --- /dev/null +++ b/src/Commands/QueryConflictFileState.cs @@ -0,0 +1,55 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryConflictFileState : Command + { + public QueryConflictFileState(string repo, Models.Change change) + { + var opt = new Models.DiffOption(change, true); + + WorkingDirectory = repo; + Context = repo; + Args = $"diff --no-color --no-ext-diff --no-textconv --full-index --patch {opt}"; + } + + public async Task GetResultAsync() + { + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + var isBinary = false; + var tokenCount = 0; + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + if (isBinary) + continue; + + if (line.StartsWith("Binary files ", StringComparison.Ordinal)) + isBinary = true; + else if (line.StartsWith("++<<<<<<<", StringComparison.Ordinal) || + line.StartsWith("++=======", StringComparison.Ordinal) || + line.StartsWith("++>>>>>>>", StringComparison.Ordinal)) + tokenCount++; + } + + await proc.WaitForExitAsync().ConfigureAwait(false); + + if (isBinary) + return Models.ConflictFileState.UnmergedBinary; + + return tokenCount == 0 ? Models.ConflictFileState.Resolved : Models.ConflictFileState.UnmergedText; + } + catch + { + return Models.ConflictFileState.Unknown; + } + } + } +} diff --git a/src/Commands/QueryCurrentBranch.cs b/src/Commands/QueryCurrentBranch.cs index c721d13b0..8b698d1e5 100644 --- a/src/Commands/QueryCurrentBranch.cs +++ b/src/Commands/QueryCurrentBranch.cs @@ -11,6 +11,11 @@ public QueryCurrentBranch(string repo) Args = "branch --show-current"; } + public string GetResult() + { + return ReadToEnd().StdOut.Trim(); + } + public async Task GetResultAsync() { var rs = await ReadToEndAsync().ConfigureAwait(false); diff --git a/src/Commands/QueryCurrentBranchCommitHashes.cs b/src/Commands/QueryCurrentBranchCommitHashes.cs new file mode 100644 index 000000000..393160602 --- /dev/null +++ b/src/Commands/QueryCurrentBranchCommitHashes.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryCurrentBranchCommitHashes : Command + { + public QueryCurrentBranchCommitHashes(string repo, ulong sinceTimestamp) + { + WorkingDirectory = repo; + Context = repo; + Args = $"log --since=@{sinceTimestamp} --format=%H"; + } + + public async Task> GetResultAsync() + { + var outs = new HashSet(); + + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { Length: > 8 } line) + outs.Add(line); + + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch + { + // Ignore exceptions; + } + + return outs; + } + } +} diff --git a/src/Commands/QueryFileContent.cs b/src/Commands/QueryFileContent.cs index 41cf63836..d111b5f21 100644 --- a/src/Commands/QueryFileContent.cs +++ b/src/Commands/QueryFileContent.cs @@ -21,13 +21,13 @@ public static async Task RunAsync(string repo, string revision, string f var stream = new MemoryStream(); try { - using var proc = Process.Start(starter); + using var proc = Process.Start(starter)!; 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; @@ -49,7 +49,7 @@ public static async Task FromLFSAsync(string repo, string oid, long size var stream = new MemoryStream(); try { - using var proc = Process.Start(starter); + 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); @@ -58,7 +58,7 @@ public static async Task FromLFSAsync(string repo, string oid, long size } 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; 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/QueryGitCommonDir.cs b/src/Commands/QueryGitCommonDir.cs index c5b9339d5..e71cf2b07 100644 --- a/src/Commands/QueryGitCommonDir.cs +++ b/src/Commands/QueryGitCommonDir.cs @@ -9,19 +9,19 @@ 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) - return null; + if (!rs.IsSuccess || string.IsNullOrEmpty(rs.StdOut)) + return string.Empty; - var stdout = rs.StdOut.Trim(); - if (string.IsNullOrEmpty(stdout)) - return null; - - return Path.IsPathRooted(stdout) ? stdout : Path.GetFullPath(Path.Combine(WorkingDirectory, stdout)); + 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 ce8bfee60..5a91b2173 100644 --- a/src/Commands/QueryGitDir.cs +++ b/src/Commands/QueryGitDir.cs @@ -1,5 +1,4 @@ using System.IO; -using System.Threading.Tasks; namespace SourceGit.Commands { @@ -11,9 +10,13 @@ public QueryGitDir(string workDir) Args = "rev-parse --git-dir"; } - public async Task GetResultAsync() + public string GetResult() + { + return Parse(ReadToEnd()); + } + + private string Parse(Result rs) { - var rs = await ReadToEndAsync().ConfigureAwait(false); if (!rs.IsSuccess) return null; diff --git a/src/Commands/QueryLocalChanges.cs b/src/Commands/QueryLocalChanges.cs index 9605014da..d1bd30a2c 100644 --- a/src/Commands/QueryLocalChanges.cs +++ b/src/Commands/QueryLocalChanges.cs @@ -1,5 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -9,153 +10,165 @@ 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 = $"--no-optional-locks status -u{UNTRACKED[includeUntracked ? 1 : 0]} --ignore-submodules=dirty --porcelain"; + + 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(); } public async Task> GetResultAsync() { var outs = new List(); - var rs = await ReadToEndAsync().ConfigureAwait(false); - if (!rs.IsSuccess) - { - App.RaiseException(Context, rs.StdErr); - return outs; - } - var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) + try { - var match = REG_FORMAT().Match(line); - if (!match.Success) - continue; - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); - switch (status) + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) { - 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; - } + var match = REG_FORMAT().Match(line); + if (!match.Success) + continue; - if (change.Index != Models.ChangeState.None || change.WorkTree != Models.ChangeState.None) - outs.Add(change); + 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 + { + // Ignore exceptions. } return outs; 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/QueryRemotes.cs b/src/Commands/QueryRemotes.cs index bd42aabf1..f8a23b7aa 100644 --- a/src/Commands/QueryRemotes.cs +++ b/src/Commands/QueryRemotes.cs @@ -24,6 +24,19 @@ public QueryRemotes(string repo) if (!rs.IsSuccess) return outs; + 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 lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { @@ -35,11 +48,22 @@ public QueryRemotes(string repo) { Name = match.Groups[1].Value, URL = match.Groups[2].Value, + DisableAutoFetch = disableAutoFetchRemotes.Contains(match.Groups[1].Value) }; if (outs.Find(x => x.Name == remote.Name) != null) continue; + 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); } diff --git a/src/Commands/QueryRepositoryRootPath.cs b/src/Commands/QueryRepositoryRootPath.cs index f7e1eb637..89d259296 100644 --- a/src/Commands/QueryRepositoryRootPath.cs +++ b/src/Commands/QueryRepositoryRootPath.cs @@ -10,6 +10,11 @@ public QueryRepositoryRootPath(string 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 index 64a03e9df..78104523a 100644 --- a/src/Commands/QueryRevisionByRefName.cs +++ b/src/Commands/QueryRevisionByRefName.cs @@ -11,9 +11,19 @@ public QueryRevisionByRefName(string repo, string refname) 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(); diff --git a/src/Commands/QueryRevisionFileNames.cs b/src/Commands/QueryRevisionFileNames.cs index 747534124..e4dcc25c5 100644 --- a/src/Commands/QueryRevisionFileNames.cs +++ b/src/Commands/QueryRevisionFileNames.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; namespace SourceGit.Commands @@ -9,17 +10,30 @@ public QueryRevisionFileNames(string repo, string revision) { WorkingDirectory = repo; Context = repo; - Args = $"ls-tree -r -z --name-only {revision}"; + Args = $"ls-tree -r --name-only {revision}"; } public async Task> GetResultAsync() { - var rs = await ReadToEndAsync().ConfigureAwait(false); - if (!rs.IsSuccess) - return []; + var outs = new List(); - var lines = rs.StdOut.Split('\0', System.StringSplitOptions.RemoveEmptyEntries); - return [.. lines]; + 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 9657c7c7e..a7eaaa9e7 100644 --- a/src/Commands/QueryRevisionObjects.cs +++ b/src/Commands/QueryRevisionObjects.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; -using System.IO; +using System.Diagnostics; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -14,47 +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.Quoted()}"; + builder.Append(" -- ").Append(parentFolder.Quoted()); + + Args = builder.ToString(); } public async Task> GetResultAsync() { var outs = new List(); - var rs = await ReadToEndAsync().ConfigureAwait(false); - if (rs.IsSuccess) + + try { - var sr = new StringReader(rs.StdOut); - while (sr.ReadLine() is { } line) - Parse(outs, line); - } + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); - return outs; - } + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + continue; - private void Parse(List outs, string line) - { - var match = REG_FORMAT().Match(line); - if (!match.Success) - return; + var obj = new Models.Object(); + obj.SHA = match.Groups[2].Value; + obj.Type = Models.ObjectType.Blob; + obj.Path = match.Groups[3].Value; - var obj = new Models.Object(); - obj.SHA = match.Groups[2].Value; - obj.Type = Models.ObjectType.Blob; - 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, + }; - obj.Type = match.Groups[1].Value switch + outs.Add(obj); + } + + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch { - "blob" => Models.ObjectType.Blob, - "tree" => Models.ObjectType.Tree, - "tag" => Models.ObjectType.Tag, - "commit" => Models.ObjectType.Commit, - _ => obj.Type, - }; - - outs.Add(obj); + // Ignore exceptions. + } + + return outs; } } } diff --git a/src/Commands/QuerySingleCommit.cs b/src/Commands/QuerySingleCommit.cs index 897459f0d..822f5c4a5 100644 --- a/src/Commands/QuerySingleCommit.cs +++ b/src/Commands/QuerySingleCommit.cs @@ -12,31 +12,40 @@ public QuerySingleCommit(string repo, string 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 GetResult() + { + var rs = ReadToEnd(); + return Parse(rs); + } + public async Task GetResultAsync() { var rs = await ReadToEndAsync().ConfigureAwait(false); - 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); + } + + 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 bec033ff7..0cd420592 100644 --- a/src/Commands/QueryStagedChangesWithAmend.cs +++ b/src/Commands/QueryStagedChangesWithAmend.cs @@ -1,28 +1,36 @@ using System; using System.Collections.Generic; using System.Text.RegularExpressions; -using System.Threading.Tasks; namespace SourceGit.Commands { public partial class QueryStagedChangesWithAmend : Command { - [GeneratedRegex(@"^:[\d]{6} ([\d]{6}) ([0-9a-f]{40}) [0-9a-f]{40} ([ACDMT])\d{0,6}\t(.*)$")] + [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, string parent) + public QueryStagedChangesWithAmend(string repo) { WorkingDirectory = repo; Context = repo; - Args = $"diff-index --cached -M {parent}"; - _parent = parent; } - public async Task> GetResultAsync() + public List GetResult() { - var rs = await ReadToEndAsync().ConfigureAwait(false); + Args = "show --no-show-signature --format=\"%H %P\" -s HEAD"; + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return []; + + var shas = rs.StdOut.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (shas.Length == 0) + return []; + + var parent = shas.Length > 1 ? shas[1] : Models.EmptyTreeHash.Guess(shas[0]); + Args = $"diff-index --cached -M {parent}"; + rs = ReadToEnd(); if (!rs.IsSuccess) return []; @@ -35,15 +43,16 @@ public QueryStagedChangesWithAmend(string repo, string parent) { var change = new Models.Change() { - Path = match.Groups[3].Value, + Path = match.Groups[4].Value, DataForAmend = new Models.ChangeDataForAmend() { FileMode = match.Groups[1].Value, ObjectHash = match.Groups[2].Value, - ParentSHA = _parent, + ParentSHA = parent, }, }; - change.Set(Models.ChangeState.Renamed); + var type = match.Groups[3].Value; + change.Set(type == "R" ? Models.ChangeState.Renamed : Models.ChangeState.Copied); changes.Add(change); continue; } @@ -58,7 +67,7 @@ public QueryStagedChangesWithAmend(string repo, string parent) { FileMode = match.Groups[1].Value, ObjectHash = match.Groups[2].Value, - ParentSHA = _parent, + ParentSHA = parent, }, }; @@ -68,9 +77,6 @@ public QueryStagedChangesWithAmend(string repo, string parent) case "A": change.Set(Models.ChangeState.Added); break; - case "C": - change.Set(Models.ChangeState.Copied); - break; case "D": change.Set(Models.ChangeState.Deleted); break; @@ -87,7 +93,5 @@ public QueryStagedChangesWithAmend(string repo, string parent) return changes; } - - private readonly string _parent; } } diff --git a/src/Commands/QueryStagedFileBlobGuid.cs b/src/Commands/QueryStagedFileBlobGuid.cs deleted file mode 100644 index 54ecd2ac7..000000000 --- a/src/Commands/QueryStagedFileBlobGuid.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Text.RegularExpressions; -using System.Threading.Tasks; - -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.Quoted()}"; - } - - public async Task GetResultAsync() - { - var rs = await ReadToEndAsync().ConfigureAwait(false); - var match = REG_FORMAT().Match(rs.StdOut.Trim()); - return match.Success ? match.Groups[1].Value : string.Empty; - } - } -} 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/QueryTags.cs b/src/Commands/QueryTags.cs index ba83cb182..9033a8d04 100644 --- a/src/Commands/QueryTags.cs +++ b/src/Commands/QueryTags.cs @@ -12,7 +12,7 @@ public QueryTags(string repo) Context = repo; WorkingDirectory = repo; - Args = $"tag -l --format=\"{_boundary}%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%0a%0a%(contents:body)\""; + 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 async Task> GetResultAsync() @@ -26,20 +26,23 @@ public QueryTags(string repo) foreach (var record in records) { var subs = record.Split('\0'); - if (subs.Length != 6) + if (subs.Length != 7) continue; var name = subs[0].Substring(10); - var message = subs[5].Trim(); + var message = subs[6].Trim(); if (!string.IsNullOrEmpty(message) && message.Equals(name, StringComparison.Ordinal)) message = null; + ulong.TryParse(subs[5], out var creatorDate); + tags.Add(new Models.Tag() { Name = name, IsAnnotated = subs[1].Equals("tag", StringComparison.Ordinal), SHA = string.IsNullOrEmpty(subs[3]) ? subs[2] : subs[3], - CreatorDate = ulong.Parse(subs[4]), + Creator = Models.User.FindOrAdd(subs[4]), + CreatorDate = creatorDate, Message = message, }); } @@ -47,6 +50,6 @@ public QueryTags(string repo) return tags; } - private string _boundary = string.Empty; + private readonly string _boundary; } } diff --git a/src/Commands/QueryTrackStatus.cs b/src/Commands/QueryTrackStatus.cs index d687d2745..f00074f82 100644 --- a/src/Commands/QueryTrackStatus.cs +++ b/src/Commands/QueryTrackStatus.cs @@ -5,31 +5,28 @@ 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 async Task GetResultAsync() + 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 = await ReadToEndAsync().ConfigureAwait(false); if (!rs.IsSuccess) - return status; + return; 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 index 05fcc0538..55f429905 100644 --- a/src/Commands/QueryUpdatableSubmodules.cs +++ b/src/Commands/QueryUpdatableSubmodules.cs @@ -7,14 +7,16 @@ namespace SourceGit.Commands { public partial class QueryUpdatableSubmodules : Command { - [GeneratedRegex(@"^([U\-\+ ])([0-9a-f]+)\s(.*?)(\s\(.*\))?$")] + [GeneratedRegex(@"^([\-\+])([0-9a-f]+)\s(.*?)(\s\(.*\))?$")] private static partial Regex REG_FORMAT_STATUS(); - public QueryUpdatableSubmodules(string repo) + public QueryUpdatableSubmodules(string repo, bool includeUninited) { WorkingDirectory = repo; Context = repo; Args = "submodule status"; + + _includeUninited = includeUninited; } public async Task> GetResultAsync() @@ -30,12 +32,16 @@ public async Task> GetResultAsync() { var stat = match.Groups[1].Value; var path = match.Groups[3].Value; - if (!stat.StartsWith(' ')) - submodules.Add(path); + 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 d08d55ad8..fdc585c01 100644 --- a/src/Commands/Rebase.cs +++ b/src/Commands/Rebase.cs @@ -1,15 +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 "); + + Args = builder.Append(basedOn).ToString(); } } } 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 6a54533b1..f640d0de4 100644 --- a/src/Commands/Reset.cs +++ b/src/Commands/Reset.cs @@ -8,5 +8,12 @@ public Reset(string repo, string revision, string mode) Context = repo; Args = $"reset {mode} {revision}"; } + + public Reset(string repo, string pathspec) + { + WorkingDirectory = repo; + Context = repo; + Args = $"reset --pathspec-from-file={pathspec.Quoted()}"; + } } } diff --git a/src/Commands/Restore.cs b/src/Commands/Restore.cs index e2f9aa09a..bf3bd0a55 100644 --- a/src/Commands/Restore.cs +++ b/src/Commands/Restore.cs @@ -1,45 +1,12 @@ -using System.Text; - -namespace SourceGit.Commands +namespace SourceGit.Commands { public class Restore : Command { - /// - /// Only used for single staged change. - /// - /// - /// - public Restore(string repo, Models.Change stagedChange) + public Restore(string repo, string pathspecFile) { WorkingDirectory = repo; Context = repo; - - var builder = new StringBuilder(); - builder.Append("restore --staged -- ").Append(stagedChange.Path.Quoted()); - - if (stagedChange.Index == Models.ChangeState.Renamed) - builder.Append(' ').Append(stagedChange.OriginalPath.Quoted()); - - Args = builder.ToString(); - } - - /// - /// Restore changes given in a path-spec file. - /// - /// - /// - /// - public Restore(string repo, string pathspecFile, bool isStaged) - { - WorkingDirectory = repo; - Context = repo; - - var builder = new StringBuilder(); - builder.Append("restore "); - builder.Append(isStaged ? "--staged " : "--worktree --recurse-submodules "); - builder.Append("--pathspec-from-file=").Append(pathspecFile.Quoted()); - - 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 c86fc0e06..4ec83a6a6 100644 --- a/src/Commands/SaveChangesAsPatch.cs +++ b/src/Commands/SaveChangesAsPatch.cs @@ -54,7 +54,7 @@ private static async Task ProcessSingleChangeAsync(string repo, Models.Dif 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; @@ -62,14 +62,14 @@ private static async Task ProcessSingleChangeAsync(string repo, Models.Dif try { - using var proc = Process.Start(starter); + 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) { - 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 a3ca373f8..410d15b07 100644 --- a/src/Commands/SaveRevisionFile.cs +++ b/src/Commands/SaveRevisionFile.cs @@ -9,7 +9,7 @@ public static class SaveRevisionFile { public static async Task RunAsync(string repo, string revision, string file, string saveTo) { - var dir = Path.GetDirectoryName(saveTo); + var dir = Path.GetDirectoryName(saveTo) ?? string.Empty; if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); @@ -42,7 +42,7 @@ private static async Task ExecCmdAsync(string repo, string args, string outputFi { try { - using var proc = Process.Start(starter); + using var proc = Process.Start(starter)!; if (input != null) { @@ -55,7 +55,7 @@ private static async Task ExecCmdAsync(string repo, string args, string outputFi } catch (Exception e) { - App.RaiseException(repo, "Save file failed: " + e.Message); + Models.Notification.Send(repo, "Save file failed: " + e.Message, true); } } } diff --git a/src/Commands/Stash.cs b/src/Commands/Stash.cs index ef8bbe087..31b5b71cc 100644 --- a/src/Commands/Stash.cs +++ b/src/Commands/Stash.cs @@ -20,7 +20,8 @@ public async Task PushAsync(string message, bool includeUntracked = true, builder.Append("--include-untracked "); if (keepIndex) builder.Append("--keep-index "); - builder.Append("-m ").Append(message.Quoted()); + if (!string.IsNullOrEmpty(message)) + builder.Append("-m ").Append(message.Quoted()); Args = builder.ToString(); return await ExecAsync().ConfigureAwait(false); @@ -32,8 +33,10 @@ public async Task PushAsync(string message, List changes, b builder.Append("stash push --include-untracked "); if (keepIndex) builder.Append("--keep-index "); - builder.Append("-m ").Append(message).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(' '); @@ -47,7 +50,8 @@ public async Task PushAsync(string message, string pathspecFromFile, bool builder.Append("stash push --include-untracked --pathspec-from-file=").Append(pathspecFromFile.Quoted()).Append(" "); if (keepIndex) builder.Append("--keep-index "); - builder.Append("-m ").Append(message.Quoted()); + if (!string.IsNullOrEmpty(message)) + builder.Append("-m ").Append(message.Quoted()); Args = builder.ToString(); return await ExecAsync().ConfigureAwait(false); @@ -59,7 +63,8 @@ public async Task PushOnlyStagedAsync(string message, bool keepIndex) builder.Append("stash push --staged "); if (keepIndex) builder.Append("--keep-index "); - builder.Append("-m ").Append(message.Quoted()); + if (!string.IsNullOrEmpty(message)) + builder.Append("-m ").Append(message.Quoted()); Args = builder.ToString(); return await ExecAsync().ConfigureAwait(false); } @@ -71,6 +76,12 @@ public async Task ApplyAsync(string name, bool restoreIndex) return await ExecAsync().ConfigureAwait(false); } + public async Task CheckoutBranchAsync(string name, string branch) + { + Args = $"stash branch {branch.Quoted()} {name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + public async Task PopAsync(string name) { Args = $"stash pop -q --index {name.Quoted()}"; diff --git a/src/Commands/Statistics.cs b/src/Commands/Statistics.cs index 2d43c7629..3c6ed017b 100644 --- a/src/Commands/Statistics.cs +++ b/src/Commands/Statistics.cs @@ -1,15 +1,28 @@ 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} --format=%ct$%aN±%aE"; + + 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 async Task ReadAsync() diff --git a/src/Commands/Submodule.cs b/src/Commands/Submodule.cs index bb8d884ac..d8b1dbcb7 100644 --- a/src/Commands/Submodule.cs +++ b/src/Commands/Submodule.cs @@ -43,7 +43,7 @@ public async Task SetBranchAsync(string path, string branch) return await ExecAsync().ConfigureAwait(false); } - public async Task UpdateAsync(List modules, bool init, bool recursive, bool useRemote = false) + public async Task UpdateAsync(List modules, bool init, bool recursive, bool useRemote) { var builder = new StringBuilder(); builder.Append("submodule update"); diff --git a/src/Commands/UnstageChangesForAmend.cs b/src/Commands/UpdateIndexInfo.cs similarity index 80% rename from src/Commands/UnstageChangesForAmend.cs rename to src/Commands/UpdateIndexInfo.cs index 5a826d9c7..d8c47ca91 100644 --- a/src/Commands/UnstageChangesForAmend.cs +++ b/src/Commands/UpdateIndexInfo.cs @@ -6,9 +6,9 @@ namespace SourceGit.Commands { - public class UnstageChangesForAmend + public class UpdateIndexInfo { - public UnstageChangesForAmend(string repo, List changes) + public UpdateIndexInfo(string repo, List changes) { _repo = repo; @@ -18,7 +18,7 @@ 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); @@ -44,7 +44,7 @@ public UnstageChangesForAmend(string repo, List changes) _patchBuilder.Append(c.Path); } - _patchBuilder.AppendLine(); + _patchBuilder.Append('\n'); } } @@ -60,10 +60,12 @@ public async Task ExecAsync() starter.RedirectStandardInput = true; starter.RedirectStandardOutput = false; starter.RedirectStandardError = true; + starter.StandardInputEncoding = new UTF8Encoding(false); + starter.StandardErrorEncoding = Encoding.UTF8; try { - using var proc = Process.Start(starter); + using var proc = Process.Start(starter)!; await proc.StandardInput.WriteAsync(_patchBuilder.ToString()); proc.StandardInput.Close(); @@ -72,18 +74,18 @@ public async Task ExecAsync() var rs = proc.ExitCode == 0; if (!rs) - App.RaiseException(_repo, err); + Models.Notification.Send(_repo, err, true); return rs; } catch (Exception e) { - 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/Worktree.cs b/src/Commands/Worktree.cs index af03029f6..7b70e2ab4 100644 --- a/src/Commands/Worktree.cs +++ b/src/Commands/Worktree.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.IO; +using System.Text; using System.Threading.Tasks; namespace SourceGit.Commands @@ -28,28 +28,32 @@ public Worktree(string repo) if (line.StartsWith("worktree ", StringComparison.Ordinal)) { last = new Models.Worktree() { FullPath = line.Substring(9).Trim() }; - last.RelativePath = Path.GetRelativePath(WorkingDirectory, last.FullPath); worktrees.Add(last); + 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; } } } @@ -59,26 +63,20 @@ public Worktree(string repo) 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.Quoted()} "; + 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) - Args += name; + builder.Append(name); + Args = builder.ToString(); return await ExecAsync().ConfigureAwait(false); } diff --git a/src/Converters/BoolConverters.cs b/src/Converters/BoolConverters.cs index 3563fb37c..5c5dd9047 100644 --- a/src/Converters/BoolConverters.cs +++ b/src/Converters/BoolConverters.cs @@ -1,14 +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 FuncValueConverter(x => x ? FontWeight.Bold : FontWeight.Normal); + 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/IntConverters.cs b/src/Converters/IntConverters.cs index f21c5d240..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.Preferences.Instance.SubjectGuideLength); - - public static readonly FuncValueConverter IsSubjectLengthGood = - new FuncValueConverter(v => v <= ViewModels.Preferences.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 3534c809f..81f5564a7 100644 --- a/src/Converters/InteractiveRebaseActionConverters.cs +++ b/src/Converters/InteractiveRebaseActionConverters.cs @@ -6,7 +6,7 @@ namespace SourceGit.Converters public static class InteractiveRebaseActionConverters { public static readonly FuncValueConverter ToIconBrush = - new FuncValueConverter(v => + new(v => { return v switch { @@ -20,9 +20,12 @@ public static class InteractiveRebaseActionConverters }); public static readonly FuncValueConverter ToName = - new FuncValueConverter(v => v.ToString()); + new(v => v.ToString()); - public static readonly FuncValueConverter CanEditMessage = - new FuncValueConverter(v => v == Models.InteractiveRebaseAction.Reword || v == Models.InteractiveRebaseAction.Squash); + public static readonly FuncValueConverter IsDrop = + new(v => v == Models.InteractiveRebaseAction.Drop); + + public static readonly FuncValueConverter ToOpacity = + new(v => v > Models.InteractiveRebaseAction.Reword ? 0.65 : 1.0); } } diff --git a/src/Converters/ListConverters.cs b/src/Converters/ListConverters.cs index 6f3ae98b0..e0c5967e8 100644 --- a/src/Converters/ListConverters.cs +++ b/src/Converters/ListConverters.cs @@ -7,9 +7,6 @@ namespace SourceGit.Converters { public static class ListConverters { - public static readonly FuncValueConverter Count = - new FuncValueConverter(v => v == null ? "0" : $"{v.Count}"); - public static readonly FuncValueConverter ToCount = new FuncValueConverter(v => v == null ? "(0)" : $"({v.Count})"); diff --git a/src/Converters/PathConverters.cs b/src/Converters/PathConverters.cs index ac1e61e52..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.AsSpan(prefixLen)}"; - - return v; - }); + new(Native.OS.GetRelativePathToHome); } } diff --git a/src/Converters/StringConverters.cs b/src/Converters/StringConverters.cs index bcadfae9f..4a6fd33d3 100644 --- a/src/Converters/StringConverters.cs +++ b/src/Converters/StringConverters.cs @@ -2,6 +2,7 @@ using System.Globalization; using Avalonia.Data.Converters; +using Avalonia.Input; using Avalonia.Styling; namespace SourceGit.Converters @@ -84,5 +85,11 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu public static readonly FuncValueConverter IsNotNullOrWhitespace = new FuncValueConverter(v => v != null && v.Trim().Length > 0); + + public static readonly FuncValueConverter ToFriendlyUpstream = + new FuncValueConverter(v => v is { Length: > 13 } ? v.Substring(13) : string.Empty); + + public static readonly FuncValueConverter FromKeyGesture = + new FuncValueConverter(v => v?.ToString("p", null) ?? string.Empty); } } diff --git a/src/Models/AvatarManager.cs b/src/Models/AvatarManager.cs index 69e12819b..9616ac64d 100644 --- a/src/Models/AvatarManager.cs +++ b/src/Models/AvatarManager.cs @@ -51,8 +51,11 @@ public void Start() 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) { string email = null; @@ -74,17 +77,21 @@ 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 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); Bitmap img = null; try { - using var client = new HttpClient(); - client.Timeout = TimeSpan.FromSeconds(2); - var rsp = client.GetAsync(url).Result; + var rsp = await client.GetAsync(url); if (rsp.IsSuccessStatusCode) { using (var stream = rsp.Content.ReadAsStream()) @@ -190,9 +197,6 @@ public void SetFromLocal(string email, string file) image = Bitmap.DecodeToWidth(stream, 128); } - if (image == null) - return; - _resources[email] = image; lock (_synclock) @@ -221,10 +225,7 @@ private string GetEmailHash(string email) { var lowered = email.ToLower(CultureInfo.CurrentCulture).Trim(); var hash = MD5.HashData(Encoding.Default.GetBytes(lowered)); - var builder = new StringBuilder(hash.Length * 2); - foreach (var c in hash) - builder.Append(c.ToString("x2")); - return builder.ToString(); + return Convert.ToHexStringLower(hash); } private void NotifyResourceChanged(string email, Bitmap image) diff --git a/src/Models/Bisect.cs b/src/Models/Bisect.cs index 2ed8beb25..286a02c4f 100644 --- a/src/Models/Bisect.cs +++ b/src/Models/Bisect.cs @@ -6,16 +6,19 @@ namespace SourceGit.Models public enum BisectState { None = 0, - WaitingForRange, - Detecting, + WaitingForFirstBad, + WaitingForCheckoutAnother, + WaitingForFirstGood, + WaitingForMark, } [Flags] public enum BisectCommitFlag { None = 0, - Good = 1 << 0, - Bad = 1 << 1, + Good, + Bad, + Skipped, } public class Bisect @@ -31,5 +34,11 @@ 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 350bc5b5a..47aa2153a 100644 --- a/src/Models/Branch.cs +++ b/src/Models/Branch.cs @@ -1,36 +1,14 @@ using System.Collections.Generic; -using System.Text.RegularExpressions; namespace SourceGit.Models { - public class BranchTrackStatus - { - public List Ahead { get; set; } = new List(); - public List Behind { get; set; } = new List(); - - public bool IsVisible => Ahead.Count > 0 || Behind.Count > 0; - - public override string ToString() - { - if (Ahead.Count == 0 && Behind.Count == 0) - return string.Empty; - - var track = ""; - if (Ahead.Count > 0) - track += $"{Ahead.Count}↑"; - if (Behind.Count > 0) - track += $" {Behind.Count}↓"; - return track.Trim(); - } - } - public enum BranchSortMode { Name = 0, CommitterDate, } - public partial class Branch + public class Branch { public string Name { get; set; } public string FullName { get; set; } @@ -40,18 +18,27 @@ public partial class Branch 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; - [GeneratedRegex(@"\s+")] - private static partial Regex REG_FIX_NAME(); - - public static string FixName(string name) + public string TrackStatusDescription { - return REG_FIX_NAME().Replace(name, "-"); + 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/Change.cs b/src/Models/Change.cs index 2ad448add..baf6e8500 100644 --- a/src/Models/Change.cs +++ b/src/Models/Change.cs @@ -60,7 +60,7 @@ 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 parts = Path.Split('\t', 2); if (parts.Length < 2) 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 61438424a..de299ed6b 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; - -using Avalonia; -using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.Models { @@ -10,23 +8,13 @@ public enum CommitSearchMethod { BySHA = 0, ByAuthor, - ByCommitter, ByMessage, ByPath, ByContent, } - public class Commit + public class Commit : ObservableObject { - // As retrieved by: git mktree Parents { get; set; } = new(); public List Decorators { get; set; } = new(); - public bool HasDecorators => Decorators.Count > 0; - - public string AuthorTimeStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Active.DateTime); - public string CommitterTimeStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString(DateTimeFormat.Active.DateTime); - public string AuthorTimeShortStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Active.DateOnly); - public string CommitterTimeShortStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString(DateTimeFormat.Active.DateOnly); public bool IsMerged { get; set; } = false; + public 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 int Color { get; set; } = 0; - public double Opacity => IsMerged ? 1 : OpacityForNotMerged; - public FontWeight FontWeight => IsCurrentHead ? FontWeight.Bold : FontWeight.Regular; - public Thickness Margin { get; set; } = new(0); - public IBrush Brush => CommitGraph.Pens[Color].Brush; + public bool HasDecorators => Decorators.Count > 0; + public string FirstParentToCompare => Parents.Count > 0 ? $"{SHA}^" : EmptyTreeHash.Guess(SHA); public string GetFriendlyName() { @@ -65,6 +52,14 @@ public string GetFriendlyName() return SHA[..10]; } + public void ParseParents(string data) + { + if (data.Length < 8) + return; + + Parents.AddRange(data.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + public void ParseDecorators(string data) { if (data.Length < 3) @@ -129,6 +124,8 @@ public void ParseDecorators(string data) return NumericSort.Compare(l.Name, r.Name); }); } + + private bool _isHighlightedInGraph = false; } public class CommitFullMessage diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index cb5696101..8557f271d 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -6,11 +6,14 @@ namespace SourceGit.Models { - public record CommitGraphLayout(double startY, double clipWidth, double rowHeight) + public record CommitGraphLayout(double StartY, double ClipWidth, double RowHeight); + + public enum CommitGraphHighlighting { - public double StartY { get; set; } = startY; - public double ClipWidth { get; set; } = clipWidth; - public double RowHeight { get; set; } = rowHeight; + All = 0, + CurrentBranchOnly, + SelectedCommitsOnly, + CurrentBranchAndSelectedCommits, } public class CommitGraph @@ -32,11 +35,11 @@ public static void SetPens(List colors, double thickness) s_penCount = colors.Count; } - public class Path(int color, bool isMerged) + public class Path(int color, bool isHighlighted) { public List Points { get; } = []; public int Color { get; } = color; - public bool IsMerged { get; } = isMerged; + public bool IsHighlighted { get; } = isHighlighted; } public class Link @@ -45,7 +48,7 @@ public class Link public Point Control; public Point End; public int Color; - public bool IsMerged; + public bool IsHighlighted; } public enum DotType @@ -60,14 +63,14 @@ public class Dot public DotType Type; public Point Center; public int Color; - public bool IsMerged; + 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; @@ -79,11 +82,28 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable var ended = new List(); var offsetY = -halfHeight; var colorPicker = new ColorPicker(); + var merged = new HashSet(); foreach (var commit in commits) { PathHelper major = null; - var isMerged = commit.IsMerged; + + // 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; @@ -91,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)) @@ -99,6 +120,7 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable { offsetX += unitWidth; major = l; + isHighlighted = major.IsHighlighted; if (commit.Parents.Count > 0) { @@ -115,9 +137,10 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable { l.End(major.LastX, offsetY, halfHeight); ended.Add(l); - } - isMerged = isMerged || l.IsMerged; + if (!isHighlighted && l.IsHighlighted) + isHighlighted = true; + } } else { @@ -134,6 +157,42 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable } ended.Clear(); + // 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) @@ -142,21 +201,21 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable if (commit.Parents.Count > 0) { - major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), 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); } } - else if (isMerged && !major.IsMerged && commit.Parents.Count > 0) + else if (isHighlighted && !major.IsHighlighted && commit.Parents.Count > 0) { - major.ReplaceMerged(); + 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, IsMerged = isMerged }; + var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted }; if (commit.IsCurrentHead) anchor.Type = DotType.Head; else if (commit.Parents.Count > 1) @@ -174,10 +233,10 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal)); if (parent != null) { - if (isMerged && !parent.IsMerged) + if (isHighlighted && !parent.IsHighlighted) { parent.Goto(parent.LastX, offsetY + halfHeight, halfHeight); - parent.ReplaceMerged(); + parent.Highlight(); temp.Paths.Add(parent.Path); } @@ -187,7 +246,7 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable End = new Point(parent.LastX, offsetY + halfHeight), Control = new Point(parent.LastX, position.Y), Color = parent.Path.Color, - IsMerged = isMerged, + IsHighlighted = isHighlighted, }); } else @@ -195,17 +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, colorPicker.Next(), 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); } } } - // 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. @@ -251,26 +309,25 @@ private class PathHelper public Path Path { get; private set; } public string Next { get; set; } public double LastX { get; private set; } + public bool IsHighlighted { get => Path.IsHighlighted; } - public bool IsMerged => Path.IsMerged; - - public PathHelper(string next, bool isMerged, int color, Point start) + public PathHelper(string next, bool IsHighlighted, int color, Point start) { Next = next; LastX = start.X; _lastY = start.Y; - Path = new Path(color, isMerged); + 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; LastX = to.X; _lastY = to.Y; - Path = new Path(color, isMerged); + Path = new Path(color, IsHighlighted); Path.Points.Add(start); Path.Points.Add(to); } @@ -351,9 +408,9 @@ public void End(double x, double y, double halfHeight) } /// - /// End the current path and create a new from the end. + /// End the current path and create a new highlighted from the end. /// - public void ReplaceMerged() + public void Highlight() { var color = Path.Color; Add(LastX, _lastY); diff --git a/src/Models/CommitLink.cs b/src/Models/CommitLink.cs index 08caad8d1..fa78f206c 100644 --- a/src/Models/CommitLink.cs +++ b/src/Models/CommitLink.cs @@ -5,8 +5,8 @@ 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) { @@ -20,26 +20,28 @@ public static List Get(List remotes) foreach (var remote in remotes) { - if (remote.TryGetVisitURL(out var url)) + if (remote.TryGetVisitURL(out var link)) { - var trimmedUrl = url.AsSpan(); - if (url.EndsWith(".git")) - trimmedUrl = url.AsSpan(0, url.Length - 4); + var uri = new Uri(link, UriKind.Absolute); + var host = uri.Host; + var route = uri.AbsolutePath.TrimStart('/'); - if (url.StartsWith("https://github.com/", StringComparison.Ordinal)) - outs.Add(new($"GitHub ({trimmedUrl[19..]})", $"{url}/commit/")); - else if (url.StartsWith("https://gitlab.", StringComparison.Ordinal)) - outs.Add(new($"GitLab ({trimmedUrl[(trimmedUrl[15..].IndexOf('/') + 16)..]})", $"{url}/-/commit/")); - else if (url.StartsWith("https://gitee.com/", StringComparison.Ordinal)) - outs.Add(new($"Gitee ({trimmedUrl[18..]})", $"{url}/commit/")); - else if (url.StartsWith("https://bitbucket.org/", StringComparison.Ordinal)) - outs.Add(new($"BitBucket ({trimmedUrl[22..]})", $"{url}/commits/")); - else if (url.StartsWith("https://codeberg.org/", StringComparison.Ordinal)) - outs.Add(new($"Codeberg ({trimmedUrl[21..]})", $"{url}/commit/")); - else if (url.StartsWith("https://gitea.org/", StringComparison.Ordinal)) - outs.Add(new($"Gitea ({trimmedUrl[18..]})", $"{url}/commit/")); - else if (url.StartsWith("https://git.sr.ht/", StringComparison.Ordinal)) - outs.Add(new($"sourcehut ({trimmedUrl[18..]})", $"{url}/commit/")); + 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/")); } } 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 531a16c07..bf2763a41 100644 --- a/src/Models/ConventionalCommitType.cs +++ b/src/Models/ConventionalCommitType.cs @@ -1,27 +1,15 @@ using System.Collections.Generic; +using System.IO; +using System.Text.Json; namespace SourceGit.Models { public class ConventionalCommitType { - public string Name { get; set; } - public string Type { get; set; } - public string Description { get; set; } - - public static readonly List Supported = [ - new("Features", "feat", "Adding a new feature"), - new("Bug Fixes", "fix", "Fixing a bug"), - new("Work In Progress", "wip", "Still being developed and not yet complete"), - new("Reverts", "revert", "Undoing a previous commit"), - new("Code Refactoring", "refactor", "Restructuring code without changing its external behavior"), - new("Performance Improvements", "perf", "Improves performance"), - new("Builds", "build", "Changes that affect the build system or external dependencies"), - new("Continuous Integrations", "ci", "Changes to CI configuration files and scripts"), - new("Documentations", "docs", "Updating documentation"), - new("Styles", "style", "Elements or code styles without changing the code logic"), - new("Tests", "test", "Adding or updating tests"), - new("Chores", "chore", "Other changes that don't modify src or test files"), - ]; + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string PrefillShortDesc { get; set; } = string.Empty; public ConventionalCommitType(string name, string type, string description) { @@ -29,5 +17,33 @@ public ConventionalCommitType(string name, string type, string description) 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 index d48b0c083..2be46c8f9 100644 --- a/src/Models/Count.cs +++ b/src/Models/Count.cs @@ -1,19 +1,4 @@ -using System; - -namespace SourceGit.Models +namespace SourceGit.Models { - public class Count : IDisposable - { - public int Value { get; set; } = 0; - - public Count(int value) - { - Value = value; - } - - public void Dispose() - { - // Ignore - } - } + public record Count(int Value); } diff --git a/src/Models/CustomAction.cs b/src/Models/CustomAction.cs index 1ed65b8b3..59652d32f 100644 --- a/src/Models/CustomAction.cs +++ b/src/Models/CustomAction.cs @@ -9,6 +9,8 @@ public enum CustomActionScope Commit, Branch, Tag, + Remote, + File, } public enum CustomActionControlType @@ -17,8 +19,12 @@ public enum CustomActionControlType PathSelector, CheckBox, ComboBox, + LocalBranchSelector, + RemoteBranchSelector, } + public record CustomActionTargetFile(string File, Commit Revision); + public class CustomActionControl : ObservableObject { public CustomActionControlType Type @@ -45,6 +51,12 @@ public string StringValue set => SetProperty(ref _stringValue, value); } + public string StringFormatter + { + get => _stringFormatter; + set => SetProperty(ref _stringFormatter, value); + } + public bool BoolValue { get => _boolValue; @@ -55,6 +67,7 @@ public bool BoolValue private string _label = string.Empty; private string _description = string.Empty; private string _stringValue = string.Empty; + private string _stringFormatter = string.Empty; private bool _boolValue = false; } diff --git a/src/Models/DateTimeFormat.cs b/src/Models/DateTimeFormat.cs index 16276c40c..d313d56ae 100644 --- a/src/Models/DateTimeFormat.cs +++ b/src/Models/DateTimeFormat.cs @@ -1,50 +1,77 @@ using System; using System.Collections.Generic; +using System.Globalization; namespace SourceGit.Models { public class DateTimeFormat { - public string DateOnly { get; set; } - public string DateTime { get; set; } + 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 => _example.ToString(DateTime); + get => DateTime.Now.ToString(DateFormat, _culture); } - public DateTimeFormat(string dateOnly, string dateTime) + private static readonly CultureInfo _culture = CreateCulture(); + + private static CultureInfo CreateCulture() { - DateOnly = dateOnly; - DateTime = dateTime; + var culture = (CultureInfo)CultureInfo.CurrentCulture.Clone(); + culture.DateTimeFormat.DateSeparator = "/"; + culture.DateTimeFormat.TimeSeparator = ":"; + return culture; } - public static int ActiveIndex + public DateTimeFormat(string date) { - get; - set; - } = 0; + DateFormat = date; + } - public static DateTimeFormat Active + public static string Format(ulong timestamp, bool dateOnly = false) { - get => Supported[ActiveIndex]; + var localTime = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); + return Format(localTime, dateOnly); } - public static readonly List Supported = new List + public static string Format(DateTime localTime, bool dateOnly = false) { - new DateTimeFormat("yyyy/MM/dd", "yyyy/MM/dd, HH:mm:ss"), - new DateTimeFormat("yyyy.MM.dd", "yyyy.MM.dd, HH:mm:ss"), - new DateTimeFormat("yyyy-MM-dd", "yyyy-MM-dd, HH:mm:ss"), - new DateTimeFormat("MM/dd/yyyy", "MM/dd/yyyy, HH:mm:ss"), - new DateTimeFormat("MM.dd.yyyy", "MM.dd.yyyy, HH:mm:ss"), - new DateTimeFormat("MM-dd-yyyy", "MM-dd-yyyy, HH:mm:ss"), - new DateTimeFormat("dd/MM/yyyy", "dd/MM/yyyy, HH:mm:ss"), - new DateTimeFormat("dd.MM.yyyy", "dd.MM.yyyy, HH:mm:ss"), - new DateTimeFormat("dd-MM-yyyy", "dd-MM-yyyy, HH:mm:ss"), - new DateTimeFormat("MMM d yyyy", "MMM d yyyy, HH:mm:ss"), - new DateTimeFormat("d MMM yyyy", "d MMM yyyy, HH:mm:ss"), - }; + var actived = Supported[ActiveIndex]; + if (dateOnly) + return localTime.ToString(actived.DateFormat, _culture); - private static readonly DateTime _example = new DateTime(2025, 1, 31, 8, 0, 0, DateTimeKind.Local); + var format = Use24Hours ? $"{actived.DateFormat} HH:mm:ss" : $"{actived.DateFormat} hh:mm:ss tt"; + return localTime.ToString(format, _culture); + } } } diff --git a/src/Models/DealWithLocalChanges.cs b/src/Models/DealWithLocalChanges.cs new file mode 100644 index 000000000..9775c61a4 --- /dev/null +++ b/src/Models/DealWithLocalChanges.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public enum DealWithLocalChanges + { + DoNothing = 0, + StashAndReapply, + Discard, + } +} diff --git a/src/Models/DiffOption.cs b/src/Models/DiffOption.cs index 2ecfe458f..af64b39a4 100644 --- a/src/Models/DiffOption.cs +++ b/src/Models/DiffOption.cs @@ -1,20 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Text; namespace SourceGit.Models { public class DiffOption { - /// - /// Enable `--ignore-cr-at-eol` by default? - /// - public static bool IgnoreCRAtEOL - { - get; - set; - } = true; - - public Change WorkingCopyChange => _workingCopyChange; + public bool IsLocalChange => _revisions.Count == 0; public bool IsUnstaged => _isUnstaged; public List Revisions => _revisions; public string Path => _path; @@ -27,7 +19,6 @@ public static bool IgnoreCRAtEOL /// public DiffOption(Change change, bool isUnstaged) { - _workingCopyChange = change; _isUnstaged = isUnstaged; _path = change.Path; _orgPath = change.OriginalPath; @@ -59,24 +50,67 @@ public DiffOption(Change change, bool isUnstaged) /// public DiffOption(Commit commit, Change change) { - var baseRevision = commit.Parents.Count == 0 ? Commit.EmptyTreeSHA1 : $"{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 ? Commit.EmptyTreeSHA1 : $"{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; + } } /// @@ -104,6 +138,9 @@ 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.Quoted()} "); @@ -112,11 +149,11 @@ public override string ToString() 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 = []; + private readonly bool _ignorePaths = false; } } diff --git a/src/Models/DiffResult.cs b/src/Models/DiffResult.cs index 95ffa99f1..ddb3d7e07 100644 --- a/src/Models/DiffResult.cs +++ b/src/Models/DiffResult.cs @@ -1,8 +1,5 @@ using System.Collections.Generic; using System.IO; -using System.Text.RegularExpressions; - -using Avalonia; using Avalonia.Media.Imaging; namespace SourceGit.Models @@ -16,7 +13,7 @@ public enum TextDiffLineType Deleted, } - public class TextInlineRange(int p, int n) + public class TextRange(int p, int n) { public int Start { get; set; } = p; public int End { get; set; } = p + n - 1; @@ -25,583 +22,37 @@ public class TextInlineRange(int p, int n) 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; - } - if (isOldSide) - { - rs.HasLeftChanges = true; - } - else - { - rs.HasChanges = true; - } - } - else if (line.Type == TextDiffLineType.Deleted) - { - if (isCombined) - { - rs.HasChanges = true; - break; - } - if (isOldSide) - { - rs.HasChanges = true; - } - else - { - rs.HasLeftChanges = true; - } - } - } - - if (!rs.HasLeftChanges) - { - for (int i = endLine; i < Lines.Count; i++) - { - var line = Lines[i]; - if (line.Type == TextDiffLineType.Added || line.Type == TextDiffLineType.Deleted) - { - rs.HasLeftChanges = true; - break; - } - } - } - - return rs; - } - - public void GenerateNewPatchFromSelection(Change change, string fileBlobGuid, TextDiffSelection selection, bool revert, string output) - { - var isTracked = !string.IsNullOrEmpty(fileBlobGuid); - var fileGuid = isTracked ? fileBlobGuid : "00000000"; - - using var writer = new StreamWriter(output); - writer.WriteLine($"diff --git a/{change.Path} b/{change.Path}"); - if (!revert && !isTracked) - writer.WriteLine("new file mode 100644"); - writer.WriteLine($"index 00000000...{fileGuid}"); - writer.WriteLine($"--- {(revert || isTracked ? $"a/{change.Path}" : "/dev/null")}"); - writer.WriteLine($"+++ b/{change.Path}"); - - var additions = selection.EndLine - selection.StartLine; - if (selection.StartLine != 1) - additions++; - - if (revert) - { - var totalLines = Lines.Count - 1; - writer.WriteLine($"@@ -0,{totalLines - additions} +0,{totalLines} @@"); - for (int i = 1; i <= totalLines; i++) - { - var line = Lines[i]; - if (line.Type != TextDiffLineType.Added) - continue; - writer.WriteLine($"{(selection.IsInRange(i) ? "+" : " ")}{line.Content}"); - } - } - else - { - writer.WriteLine($"@@ -0,0 +0,{additions} @@"); - for (int i = selection.StartLine - 1; i < selection.EndLine; i++) - { - var line = Lines[i]; - if (line.Type != TextDiffLineType.Added) - continue; - writer.WriteLine($"+{line.Content}"); - } - } - - writer.WriteLine("\\ No newline at end of file"); - writer.Flush(); - } - - public void GeneratePatchFromSelection(Change change, string fileTreeGuid, TextDiffSelection selection, bool revert, string output) - { - var orgFile = !string.IsNullOrEmpty(change.OriginalPath) ? change.OriginalPath : change.Path; - - using var writer = new StreamWriter(output); - writer.WriteLine($"diff --git a/{change.Path} b/{change.Path}"); - writer.WriteLine($"index 00000000...{fileTreeGuid} 100644"); - writer.WriteLine($"--- a/{orgFile}"); - writer.WriteLine($"+++ b/{change.Path}"); - - // If last line of selection is a change. Find one more line. - string tail = null; - 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 (line.Type == TextDiffLineType.Normal || - (revert && line.Type == TextDiffLineType.Added) || - (!revert && 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(writer, line, i, selection.StartLine, selection.EndLine, ignoreRemoves, ignoreAdds, revert, tail != null); - } - else if (line.Type == TextDiffLineType.Added) - { - if (revert) - writer.WriteLine($" {line.Content}"); - } - else if (line.Type == TextDiffLineType.Deleted) - { - if (!revert) - writer.WriteLine($" {line.Content}"); - } - else if (line.Type == TextDiffLineType.Normal) - { - writer.WriteLine($" {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(writer, line, i, selection.StartLine, selection.EndLine, selection.IgnoredDeletes, selection.IgnoredAdds, revert, tail != null)) - break; - } - else if (line.Type == TextDiffLineType.Normal) - { - writer.WriteLine($" {line.Content}"); - } - else if (line.Type == TextDiffLineType.Added) - { - writer.WriteLine($"+{line.Content}"); - } - else if (line.Type == TextDiffLineType.Deleted) - { - writer.WriteLine($"-{line.Content}"); - } - } - - writer.WriteLine($" {tail}"); - writer.Flush(); - } - - 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; - - using var writer = new StreamWriter(output); - writer.WriteLine($"diff --git a/{change.Path} b/{change.Path}"); - writer.WriteLine($"index 00000000...{fileTreeGuid} 100644"); - writer.WriteLine($"--- a/{orgFile}"); - writer.WriteLine($"+++ b/{change.Path}"); - - // If last line of selection is a change. Find one more line. - string tail = null; - 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(writer, line, i, selection.StartLine, selection.EndLine, ignoreRemoves, ignoreAdds, revert, isOldSide, tail != null); - } - else if (line.Type == TextDiffLineType.Added) - { - if (revert) - writer.WriteLine($" {line.Content}"); - } - else if (line.Type == TextDiffLineType.Deleted) - { - if (!revert) - writer.WriteLine($" {line.Content}"); - } - else if (line.Type == TextDiffLineType.Normal) - { - writer.WriteLine($" {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(writer, line, i, selection.StartLine, selection.EndLine, selection.IgnoredDeletes, selection.IgnoredAdds, revert, isOldSide, tail != null)) - break; - } - else if (line.Type == TextDiffLineType.Normal) - { - writer.WriteLine($" {line.Content}"); - } - else if (line.Type == TextDiffLineType.Added) - { - if (isOldSide) - { - if (revert) - { - writer.WriteLine($" {line.Content}"); - } - else - { - selection.IgnoredAdds++; - } - } - else - { - writer.WriteLine($"+{line.Content}"); - } - } - else if (line.Type == TextDiffLineType.Deleted) - { - if (isOldSide) - { - writer.WriteLine($"-{line.Content}"); - } - else - { - if (!revert) - { - writer.WriteLine($" {line.Content}"); - } - else - { - selection.IgnoredDeletes++; - } - } - } - } - - writer.WriteLine($" {tail}"); - writer.Flush(); - } - - private bool ProcessIndicatorForPatch(StreamWriter writer, 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; - - writer.WriteLine($"@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); - return true; - } - - private bool ProcessIndicatorForPatchSingleSide(StreamWriter writer, 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 || 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; - - writer.WriteLine($"@@ -{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 @@ -628,37 +79,34 @@ public class ImageDiff public string NewImageSize => New != null ? $"{New.PixelSize.Width} x {New.PixelSize.Height}" : "0 x 0"; } + public class EmptyFile + { + public const string SHA1 = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"; + public const string SHA256 = "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"; + } + 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 - { - get - { - if (string.IsNullOrEmpty(OldMode) && string.IsNullOrEmpty(NewMode)) - return string.Empty; - - var oldDisplay = string.IsNullOrEmpty(OldMode) ? "0" : OldMode; - var newDisplay = string.IsNullOrEmpty(NewMode) ? "0" : NewMode; - - return $"{oldDisplay} → {newDisplay}"; - } - } } } diff --git a/src/Models/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 ed9b9b08a..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,74 +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 -sw \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(8, "codium", "VSCodium", "VSCodium.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(9, "p4merge", "P4Merge", "p4merge.exe", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(10, "plastic_merge", "Plastic SCM", "mergetool.exe", "-s=\"$REMOTE\" -b=\"$BASE\" -d=\"$LOCAL\" -r=\"$MERGED\" --automatic", "-s=\"$LOCAL\" -d=\"$REMOTE\""), - new ExternalMerger(11, "meld", "Meld", "Meld.exe", "\"$LOCAL\" \"$BASE\" \"$REMOTE\" --output \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(12, "cursor", "Cursor", "Cursor.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$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(8, "cursor", "Cursor", "/Applications/Cursor.app/Contents/Resources/app/bin/cursor", "-n --wait \"$MERGED\"", "-n --wait --diff \"$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(8, "cursor", "Cursor", "cursor", "-n --wait \"$MERGED\"", "-n --wait --diff \"$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(';'); + return Finder.Split(';', StringSplitOptions.RemoveEmptyEntries); - var choices = Exec.Split(';', StringSplitOptions.RemoveEmptyEntries); - return Array.ConvertAll(choices, Path.GetFileName); + 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 377eba2f2..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 ExternalTool(string name, string icon, string execFile, Func execArgsGenerator = null) + public LaunchOption(string title, string args) + { + Title = title; + Args = args; + } + } + + public string Name { get; } + public string ExecFile { get; } + public Bitmap IconImage { get; } + public bool SupportOpenFolder { get; } + + public ExternalTool(string name, string icon, string execFile, Func> optionsGenerator, bool supportOpenFolder) { Name = name; - _execFile = execFile; - _execArgsGenerator = execArgsGenerator ?? (repo => repo.Quoted()); + ExecFile = execFile; + SupportOpenFolder = supportOpenFolder; + + _optionsGenerator = optionsGenerator; try { @@ -33,19 +49,25 @@ public ExternalTool(string name, string icon, string execFile, Func MakeLaunchOptions(string repo) { - Process.Start(new ProcessStartInfo() + return _optionsGenerator?.Invoke(repo); + } + + public void Launch(string args) + { + 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 @@ -62,40 +84,30 @@ public class VisualStudioInstance 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 @@ -114,7 +126,7 @@ public ExternalToolsFinder() if (File.Exists(customPathsConfig)) { using var stream = File.OpenRead(customPathsConfig); - _customPaths = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.ExternalToolPaths); + _customization = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.ExternalToolCustomization); } } catch @@ -122,41 +134,39 @@ public ExternalToolsFinder() // Ignore } - _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)) { - Tools.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)) - Tools.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) @@ -176,26 +186,53 @@ public void Cursor(Func platformFinder) public void FindJetBrainsFromToolbox(Func platformFinder) { - var exclude = new List { "fleet", "dotmemory", "dottrace", "resharper-u", "androidstudio" }; - var supportedIcons = 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)) { - using var stream = File.OpenRead(state); - var stateData = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.JetBrainsState); - foreach (var tool in stateData.Tools) + try { - if (exclude.Contains(tool.ToolId.ToLowerInvariant())) - continue; - - Tools.Add(new ExternalTool( - $"{tool.DisplayName} {tool.DisplayVersion}", - supportedIcons.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 index 05ade0ace..a648d4db2 100644 --- a/src/Models/GitFlow.cs +++ b/src/Models/GitFlow.cs @@ -1,5 +1,15 @@ -namespace SourceGit.Models +using System; +using System.Collections.Generic; + +namespace SourceGit.Models { + public enum GitFlowVersion + { + None = 0, + Legacy, + Next, + } + public enum GitFlowBranchType { None = 0, @@ -10,18 +20,74 @@ public enum GitFlowBranchType public class GitFlow { - public string Master { get; set; } = string.Empty; - public string Develop { get; set; } = string.Empty; + 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(Master) && - !string.IsNullOrEmpty(Develop) && + return !string.IsNullOrEmpty(ProductionBranch) && + !string.IsNullOrEmpty(DevelopmentBranch) && !string.IsNullOrEmpty(FeaturePrefix) && !string.IsNullOrEmpty(ReleasePrefix) && !string.IsNullOrEmpty(HotfixPrefix); diff --git a/src/Models/GitIgnoreFile.cs b/src/Models/GitIgnoreFile.cs index a23456b67..2f3540e8b 100644 --- a/src/Models/GitIgnoreFile.cs +++ b/src/Models/GitIgnoreFile.cs @@ -1,26 +1,36 @@ -using System.Collections.Generic; -using System.IO; -using Avalonia.Media; +using System; +using System.Collections.Generic; namespace SourceGit.Models { - public class GitIgnoreFile + public record GitIgnoreFile(string DisplayName, string FullPath, string Pattern, bool IsLocalOnly) { - public static readonly List Supported = [new(true), new(false)]; + public static List GetSupported(string repo, string gitDir, string pattern) + { + var supported = new List(); - public bool IsShared { get; set; } - public string File => IsShared ? ".gitignore" : "/info/exclude"; - public string Desc => IsShared ? "Shared" : "Private"; - public IBrush Brush => IsShared ? Brushes.Green : Brushes.Gray; + // .gitignore in repository root. + supported.Add(new(".gitignore", $"{repo}/.gitignore", pattern, false)); - public GitIgnoreFile(bool isShared) - { - IsShared = isShared; - } + // 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)); + } - public string GetFullPath(string repoPath, string gitDir) - { - return IsShared ? Path.Combine(repoPath, ".gitignore") : Path.Combine(gitDir, "info", "exclude"); + // .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 index 8aae63a3b..71fb4657e 100644 --- a/src/Models/GitVersions.cs +++ b/src/Models/GitVersions.cs @@ -16,5 +16,15 @@ public static class GitVersions /// 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/ICommandLog.cs b/src/Models/ICommandLog.cs index 34ec70316..28e1fcb39 100644 --- a/src/Models/ICommandLog.cs +++ b/src/Models/ICommandLog.cs @@ -1,5 +1,10 @@ namespace SourceGit.Models { + public interface ICommandLogReceiver + { + void OnReceiveCommandLog(string line); + } + public interface ICommandLog { void AppendLine(string line); diff --git a/src/Models/ImageDecoder.cs b/src/Models/ImageDecoder.cs index 6fe0f4287..5ff862eb3 100644 --- a/src/Models/ImageDecoder.cs +++ b/src/Models/ImageDecoder.cs @@ -6,5 +6,6 @@ public enum ImageDecoder Builtin, Pfim, Tiff, + StbImage, } } diff --git a/src/Models/InteractiveRebase.cs b/src/Models/InteractiveRebase.cs index d1710d4a9..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,15 @@ public enum InteractiveRebaseAction Drop, } + public enum InteractiveRebasePendingType + { + None = 0, + Target, + Pending, + Ignore, + Last, + } + public class InteractiveCommit { public Commit Commit { get; set; } = new Commit(); @@ -25,10 +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 index 001c65a65..86c8167ae 100644 --- a/src/Models/IpcChannel.cs +++ b/src/Models/IpcChannel.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -19,7 +21,7 @@ public IpcChannel() _singletonLock = File.Open(Path.Combine(Native.OS.DataDir, "process.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); IsFirstInstance = true; _server = new NamedPipeServerStream( - "SourceGitIPCChannel" + Environment.UserName, + GetPipeName(), PipeDirection.In, -1, PipeTransmissionMode.Byte, @@ -37,7 +39,7 @@ public void SendToFirstInstance(string cmd) { try { - using (var client = new NamedPipeClientStream(".", "SourceGitIPCChannel" + Environment.UserName, PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly)) + using (var client = new NamedPipeClientStream(".", GetPipeName(), PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly)) { client.Connect(1000); if (!client.IsConnected) @@ -67,6 +69,19 @@ public void Dispose() _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); @@ -80,7 +95,7 @@ private async void StartServer() if (!_cancellationTokenSource.IsCancellationRequested) { var line = await reader.ReadToEndAsync(_cancellationTokenSource.Token); - MessageReceived?.Invoke(line?.Trim()); + MessageReceived?.Invoke(line.Trim()); } _server.Disconnect(); diff --git a/src/Models/IssueTrackerRule.cs b/src/Models/IssueTracker.cs similarity index 90% rename from src/Models/IssueTrackerRule.cs rename to src/Models/IssueTracker.cs index 40bb295a3..b424707f6 100644 --- a/src/Models/IssueTrackerRule.cs +++ b/src/Models/IssueTracker.cs @@ -1,11 +1,16 @@ 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; @@ -21,12 +26,11 @@ public string RegexString { try { - _regex = null; _regex = new Regex(_regexString, RegexOptions.Multiline); } catch { - // Ignore errors. + _regex = null; } } @@ -70,6 +74,7 @@ public void Matches(InlineElementCollector outs, string message) } } + 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/Locales.cs b/src/Models/Locales.cs index 1788a9b22..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,9 +9,12 @@ 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"), @@ -20,6 +23,7 @@ public class Locale 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/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/NumericSort.cs b/src/Models/NumericSort.cs index baaf3da4f..433a921bd 100644 --- a/src/Models/NumericSort.cs +++ b/src/Models/NumericSort.cs @@ -6,6 +6,8 @@ public static class NumericSort { public static int Compare(string s1, string s2) { + var comparer = StringComparer.InvariantCultureIgnoreCase; + int len1 = s1.Length; int len2 = s2.Length; @@ -20,7 +22,7 @@ public static int Compare(string s1, string s2) bool isDigit1 = char.IsDigit(c1); bool isDigit2 = char.IsDigit(c2); if (isDigit1 != isDigit2) - return c1.CompareTo(c2); + return comparer.Compare(c1.ToString(), c2.ToString()); int subLen1 = 1; while (marker1 + subLen1 < len1 && char.IsDigit(s1[marker1 + subLen1]) == isDigit1) @@ -40,7 +42,7 @@ public static int Compare(string s1, string s2) if (isDigit1) result = (subLen1 == subLen2) ? string.CompareOrdinal(sub1, sub2) : (subLen1 - subLen2); else - result = string.Compare(sub1, sub2, StringComparison.OrdinalIgnoreCase); + 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 f8d76328d..000000000 --- a/src/Models/OpenAI.cs +++ /dev/null @@ -1,228 +0,0 @@ -using System; -using System.ClientModel; -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.OpenAI; -using CommunityToolkit.Mvvm.ComponentModel; -using OpenAI; -using OpenAI.Chat; - -namespace SourceGit.Models -{ - public partial class OpenAIResponse - { - public OpenAIResponse(Action onUpdate) - { - _onUpdate = onUpdate; - } - - public void Append(string text) - { - var buffer = text; - - if (_thinkTail.Length > 0) - { - _thinkTail.Append(buffer); - buffer = _thinkTail.ToString(); - _thinkTail.Clear(); - } - - buffer = REG_COT().Replace(buffer, ""); - - var startIdx = buffer.IndexOf('<'); - if (startIdx >= 0) - { - if (startIdx > 0) - OnReceive(buffer.Substring(0, startIdx)); - - var endIdx = buffer.IndexOf('>', startIdx + 1); - if (endIdx <= startIdx) - { - if (buffer.Length - startIdx <= 15) - _thinkTail.Append(buffer.AsSpan(startIdx)); - else - OnReceive(buffer.Substring(startIdx)); - } - else if (endIdx < startIdx + 15) - { - var tag = buffer.Substring(startIdx + 1, endIdx - startIdx - 1); - if (_thinkTags.Contains(tag)) - _thinkTail.Append(buffer.AsSpan(startIdx)); - else - OnReceive(buffer.Substring(startIdx)); - } - else - { - OnReceive(buffer.Substring(startIdx)); - } - } - else - { - OnReceive(buffer); - } - } - - public void End() - { - if (_thinkTail.Length > 0) - { - OnReceive(_thinkTail.ToString()); - _thinkTail.Clear(); - } - } - - private void OnReceive(string text) - { - if (!_hasTrimmedStart) - { - text = text.TrimStart(); - if (string.IsNullOrEmpty(text)) - return; - - _hasTrimmedStart = true; - } - - _onUpdate?.Invoke(text); - } - - [GeneratedRegex(@"<(think|thought|thinking|thought_chain)>.*?", RegexOptions.Singleline)] - private static partial Regex REG_COT(); - - private Action _onUpdate = null; - private StringBuilder _thinkTail = new StringBuilder(); - private HashSet _thinkTags = ["think", "thought", "thinking", "thought_chain"]; - private bool _hasTrimmedStart = false; - } - - public class OpenAIService : ObservableObject - { - public string Name - { - get => _name; - set => SetProperty(ref _name, value); - } - - public string Server - { - get => _server; - set => SetProperty(ref _server, value); - } - - public string ApiKey - { - get => _apiKey; - set => SetProperty(ref _apiKey, value); - } - - public string Model - { - get => _model; - set => SetProperty(ref _model, value); - } - - public bool Streaming - { - get => _streaming; - set => SetProperty(ref _streaming, value); - } - - public string AnalyzeDiffPrompt - { - get => _analyzeDiffPrompt; - set => SetProperty(ref _analyzeDiffPrompt, value); - } - - public string GenerateSubjectPrompt - { - get => _generateSubjectPrompt; - set => SetProperty(ref _generateSubjectPrompt, value); - } - - public OpenAIService() - { - AnalyzeDiffPrompt = """ - You are an expert developer specialist in creating commits. - Provide a super concise one sentence overall changes summary of the user `git diff` output following strictly the next rules: - - Do not use any code snippets, imports, file routes or bullets points. - - Do not mention the route of file that has been change. - - Write clear, concise, and descriptive messages that explain the MAIN GOAL made of the changes. - - Use the present tense and active voice in the message, for example, "Fix bug" instead of "Fixed bug.". - - Use the imperative mood, which gives the message a sense of command, e.g. "Add feature" instead of "Added feature". - - Avoid using general terms like "update" or "change", be specific about what was updated or changed. - - Avoid using terms like "The main goal of", just output directly the summary in plain text - """; - - GenerateSubjectPrompt = """ - You are an expert developer specialist in creating commits messages. - Your only goal is to retrieve a single commit message. - Based on the provided user changes, combine them in ONE SINGLE commit message retrieving the global idea, following strictly the next rules: - - Assign the commit {type} according to the next conditions: - feat: Only when adding a new feature. - fix: When fixing a bug. - docs: When updating documentation. - style: When changing elements styles or design and/or making changes to the code style (formatting, missing semicolons, etc.) without changing the code logic. - test: When adding or updating tests. - chore: When making changes to the build process or auxiliary tools and libraries. - revert: When undoing a previous commit. - refactor: When restructuring code without changing its external behavior, or is any of the other refactor types. - - Do not add any issues numeration, explain your output nor introduce your answer. - - Output directly only one commit message in plain text with the next format: {type}: {commit_message}. - - Be as concise as possible, keep the message under 50 characters. - """; - } - - public async Task ChatAsync(string prompt, string question, CancellationToken cancellation, Action onUpdate) - { - var server = new Uri(_server); - var key = new ApiKeyCredential(_apiKey); - var oaiClient = _server.Contains("openai.azure.com/", StringComparison.Ordinal) - ? new AzureOpenAIClient(server, key) - : new OpenAIClient(key, new() { Endpoint = server }); - var client = oaiClient.GetChatClient(_model); - var messages = new List(); - messages.Add(_model.Equals("o1-mini", StringComparison.Ordinal) ? new UserChatMessage(prompt) : new SystemChatMessage(prompt)); - messages.Add(new UserChatMessage(question)); - - try - { - var rsp = new OpenAIResponse(onUpdate); - - if (_streaming) - { - var updates = client.CompleteChatStreamingAsync(messages, null, cancellation); - - await foreach (var update in updates) - { - if (update.ContentUpdate.Count > 0) - rsp.Append(update.ContentUpdate[0].Text); - } - } - else - { - var completion = await client.CompleteChatAsync(messages, null, cancellation); - - if (completion.Value.Content.Count > 0) - rsp.Append(completion.Value.Content[0].Text); - } - - rsp.End(); - } - catch - { - if (!cancellation.IsCancellationRequested) - throw; - } - } - - private string _name; - private string _server; - private string _apiKey; - private string _model; - private bool _streaming = true; - private string _analyzeDiffPrompt; - private string _generateSubjectPrompt; - } -} diff --git a/src/Models/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 6e36cfb9e..12681b4b4 100644 --- a/src/Models/Remote.cs +++ b/src/Models/Remote.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text.RegularExpressions; +using System.Web; namespace SourceGit.Models { @@ -8,14 +9,17 @@ public partial class Remote { [GeneratedRegex(@"^https?://[^/]+/.+[^/\.]$")] private static partial Regex REG_HTTPS(); + [GeneratedRegex(@"^git://[^/]+/.+[^/\.]$")] private static partial Regex REG_GIT(); + [GeneratedRegex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] private static partial Regex REG_SSH1(); + [GeneratedRegex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] private static partial Regex REG_SSH2(); - [GeneratedRegex(@"^git@([\w\.\-]+):([\w\-/~%]+/[\w\-\.%]+)\.git$")] + [GeneratedRegex(@"^git@([\w\.\-]+):([\w\.\-/~%]+/[\w\-\.%]+)\.git$")] private static partial Regex REG_TO_VISIT_URL_CAPTURE(); private static readonly Regex[] URL_FORMATS = [ @@ -27,6 +31,7 @@ public partial class Remote public string Name { get; set; } public string URL { get; set; } + public bool DisableAutoFetch { get; set; } public static bool IsSSH(string url) { @@ -60,22 +65,75 @@ 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 4e51b368a..506e6ded7 100644 --- a/src/Models/RepositorySettings.cs +++ b/src/Models/RepositorySettings.cs @@ -1,6 +1,10 @@ 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,113 +18,35 @@ public string DefaultRemote set; } = string.Empty; - public bool EnableReflog - { - get; - set; - } = false; - - public bool EnableFirstParentInHistories - { - get; - set; - } = false; - - public bool EnableTopoOrderInHistories - { - get; - set; - } = false; - - public bool OnlyHighlightCurrentBranchInHistories - { - get; - set; - } = false; - - public BranchSortMode LocalBranchSortMode - { - get; - set; - } = BranchSortMode.Name; - - public BranchSortMode RemoteBranchSortMode - { - get; - set; - } = BranchSortMode.Name; - - public TagSortMode TagSortMode - { - get; - set; - } = TagSortMode.CreatorDate; - - public bool IncludeUntrackedInLocalChanges - { - get; - set; - } = true; - - public bool EnableForceOnFetch - { - get; - set; - } = false; - - public bool FetchWithoutTags - { - get; - set; - } = false; - - public bool PreferRebaseInsteadOfMerge - { - get; - set; - } = true; - - public bool CheckSubmodulesOnPush + public int PreferredMergeMode { get; set; - } = true; + } = 0; - public bool PushAllTags + public string ConventionalTypesOverride { get; set; - } = false; + } = string.Empty; - public bool PushToRemoteWhenCreateTag + public bool EnableRecursiveWhenAutoUpdatingSubmodules { get; set; } = true; - public bool PushToRemoteWhenDeleteTag + public bool AskBeforeAutoUpdatingSubmodules { get; set; } = false; - public bool CheckoutBranchOnCreateBranch - { - get; - set; - } = true; - - public bool UpdateSubmodulesOnCheckoutBranch - { - get; - set; - } = true; - - public AvaloniaList HistoriesFilters + public string PreferredOpenAIService { get; set; - } = []; + } = "---"; public AvaloniaList CommitTemplates { @@ -128,310 +54,66 @@ public AvaloniaList CommitTemplates set; } = []; - public AvaloniaList CommitMessages - { - get; - set; - } = []; - - public AvaloniaList IssueTrackerRules - { - get; - set; - } = []; - public AvaloniaList CustomActions { get; set; } = []; - public bool EnableAutoFetch + public static RepositorySettings Get(string gitCommonDir) { - 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 int AutoFetchInterval - { - get; - set; - } = 10; - - public bool EnableSignOffForCommit - { - get; - set; - } = false; - - public bool IncludeUntrackedWhenStash - { - get; - set; - } = true; - - public bool OnlyStagedWhenStash - { - get; - set; - } = false; - - public int ChangesAfterStashing - { - get; - set; - } = 0; - - public string PreferredOpenAIService - { - get; - set; - } = "---"; - - public bool IsLocalBranchesExpandedInSideBar - { - get; - set; - } = true; - - public bool IsRemotesExpandedInSideBar - { - get; - set; - } = false; - - public bool IsTagsExpandedInSideBar - { - get; - set; - } = false; - - public bool IsSubmodulesExpandedInSideBar - { - get; - set; - } = false; - - public bool IsWorktreeExpandedInSideBar - { - get; - set; - } = false; - - public List ExpandedBranchNodesInSideBar - { - get; - set; - } = []; - - public int PreferredMergeMode - { - get; - set; - } = 0; - - public string LastCommitMessage - { - get; - set; - } = string.Empty; - - public Dictionary CollectHistoriesFilters() - { - var map = new Dictionary(); - foreach (var filter in HistoriesFilters) - map.Add(filter.Pattern, filter.Mode); - return map; - } - - public bool UpdateHistoriesFilter(string pattern, FilterType type, FilterMode mode) - { - // Clear all filters when there's a filter that has different mode. - if (mode != FilterMode.None) + 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); + } + catch + { + setting = new(); } - - return false; - } - - foreach (var filter in HistoriesFilters) - { - if (filter.Type != type) - continue; - - if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) - return false; } - HistoriesFilters.Add(new Filter(pattern, type, mode)); - return true; - } - - public void RemoveChildrenBranchFilters(string pattern) - { - var dirty = new List(); - var prefix = $"{pattern}/"; - - foreach (var filter in HistoriesFilters) + // Serialize setting again to make sure there are no unnecessary whitespaces. + Task.Run(() => { - if (filter.Type == FilterType.Tag) - continue; - - if (filter.Pattern.StartsWith(prefix, StringComparison.Ordinal)) - dirty.Add(filter); - } + var formatted = JsonSerializer.Serialize(setting, JsonCodeGen.Default.RepositorySettings); + setting._orgHash = HashContent(formatted); + }); - foreach (var filter in dirty) - HistoriesFilters.Remove(filter); + setting._file = fullpath; + _cache.Add(fullpath, setting); + return setting; } - public string BuildHistoriesFilter() + public void Save() { - var includedRefs = new List(); - var excludedBranches = new List(); - var excludedRemotes = new List(); - var excludedTags = new List(); - foreach (var filter in HistoriesFilters) + try { - if (filter.Type == FilterType.LocalBranch) - { - if (filter.Mode == FilterMode.Included) - includedRefs.Add(filter.Pattern); - else if (filter.Mode == FilterMode.Excluded) - excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}\" --decorate-refs-exclude=\"{filter.Pattern}\""); - } - else if (filter.Type == FilterType.LocalBranchFolder) - { - if (filter.Mode == FilterMode.Included) - includedRefs.Add($"--branches={filter.Pattern.AsSpan(11)}/*"); - else if (filter.Mode == FilterMode.Excluded) - excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\""); - } - else if (filter.Type == FilterType.RemoteBranch) + var content = JsonSerializer.Serialize(this, JsonCodeGen.Default.RepositorySettings); + var hash = HashContent(content); + if (!hash.Equals(_orgHash, StringComparison.Ordinal)) { - if (filter.Mode == FilterMode.Included) - includedRefs.Add(filter.Pattern); - else if (filter.Mode == FilterMode.Excluded) - excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}\" --decorate-refs-exclude=\"{filter.Pattern}\""); - } - else if (filter.Type == FilterType.RemoteBranchFolder) - { - if (filter.Mode == FilterMode.Included) - includedRefs.Add($"--remotes={filter.Pattern.AsSpan(13)}/*"); - else if (filter.Mode == FilterMode.Excluded) - excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\""); - } - else if (filter.Type == FilterType.Tag) - { - if (filter.Mode == FilterMode.Included) - includedRefs.Add($"refs/tags/{filter.Pattern}"); - else if (filter.Mode == FilterMode.Excluded) - excludedTags.Add($"--exclude=\"{filter.Pattern}\" --decorate-refs-exclude=\"refs/tags/{filter.Pattern}\""); + var tmpfile = $"{_file}.tmp"; + File.WriteAllText(tmpfile, content); + File.Move(tmpfile, _file, true); + _orgHash = hash; } } - - var builder = new StringBuilder(); - if (includedRefs.Count > 0) + catch { - foreach (var r in includedRefs) - { - builder.Append(r); - builder.Append(' '); - } + // Ignore save errors } - else if (excludedBranches.Count + excludedRemotes.Count + excludedTags.Count > 0) - { - foreach (var b in excludedBranches) - { - builder.Append(b); - builder.Append(' '); - } - - builder.Append("--exclude=HEAD --branches "); - - foreach (var r in excludedRemotes) - { - builder.Append(r); - builder.Append(' '); - } - - builder.Append("--exclude=origin/HEAD --remotes "); - - foreach (var t in excludedTags) - { - builder.Append(t); - builder.Append(' '); - } - - builder.Append("--tags "); - } - - return builder.ToString(); - } - - public void PushCommitMessage(string message) - { - message = message.Trim().ReplaceLineEndings("\n"); - var existIdx = CommitMessages.IndexOf(message); - if (existIdx == 0) - return; - - if (existIdx > 0) - { - CommitMessages.Move(existIdx, 0); - return; - } - - if (CommitMessages.Count > 9) - CommitMessages.RemoveRange(9, CommitMessages.Count - 9); - - CommitMessages.Insert(0, message); - } - - public IssueTrackerRule AddIssueTracker(string name, string regex, string url) - { - var rule = new IssueTrackerRule() - { - Name = name, - RegexString = regex, - URLTemplate = url, - }; - - IssueTrackerRules.Add(rule); - return rule; - } - - public void RemoveIssueTracker(IssueTrackerRule rule) - { - if (rule != null) - IssueTrackerRules.Remove(rule); } public CustomAction AddNewCustomAction() @@ -460,5 +142,15 @@ public void MoveCustomActionDown(CustomAction act) if (idx < CustomActions.Count - 1) CustomActions.Move(idx + 1, idx); } + + private static string HashContent(string source) + { + var hash = MD5.HashData(Encoding.Default.GetBytes(source)); + return Convert.ToHexStringLower(hash); + } + + private static Dictionary _cache = new(); + private string _file = string.Empty; + private string _orgHash = string.Empty; } } diff --git a/src/Models/RepositoryStatus.cs b/src/Models/RepositoryStatus.cs new file mode 100644 index 000000000..c7c498ae5 --- /dev/null +++ b/src/Models/RepositoryStatus.cs @@ -0,0 +1,29 @@ +namespace SourceGit.Models +{ + public class RepositoryStatus + { + public string CurrentBranch { get; set; } = string.Empty; + public int Ahead { get; set; } = 0; + public int Behind { get; set; } = 0; + public int LocalChanges { get; set; } = 0; + + public bool IsTrackingStatusVisible + { + get + { + return Ahead > 0 || Behind > 0; + } + } + + public string TrackingDescription + { + get + { + if (Ahead > 0) + return Behind > 0 ? $"{Ahead}↑ {Behind}↓" : $"{Ahead}↑"; + + return Behind > 0 ? $"{Behind}↓" : string.Empty; + } + } + } +} diff --git a/src/Models/RepositoryUIStates.cs b/src/Models/RepositoryUIStates.cs new file mode 100644 index 000000000..723fe900b --- /dev/null +++ b/src/Models/RepositoryUIStates.cs @@ -0,0 +1,514 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using Avalonia.Collections; + +namespace SourceGit.Models +{ + public class RepositoryUIStates + { + public HistoryShowFlags HistoryShowFlags + { + get; + set; + } = HistoryShowFlags.None; + + public bool IsAuthorColumnVisibleInHistory + { + get; + set; + } = true; + + public bool IsSHAColumnVisibleInHistory + { + get; + set; + } = true; + + public bool IsAuthorTimeColumnVisibleInHistory + { + get; + set; + } = false; + + public bool IsCommitTimeColumnVisibleInHistory + { + get; + set; + } = true; + + public double AuthorColumnWidth + { + get; + set; + } = 120; + + public bool EnableTopoOrderInHistory + { + get; + set; + } = false; + + public CommitGraphHighlighting GraphHighlighting + { + get; + set; + } = CommitGraphHighlighting.All; + + public BranchSortMode LocalBranchSortMode + { + get; + set; + } = BranchSortMode.Name; + + public BranchSortMode RemoteBranchSortMode + { + get; + set; + } = BranchSortMode.Name; + + public bool ShowTagsAsTree + { + get; + set; + } = false; + + public TagSortMode TagSortMode + { + get; + set; + } = TagSortMode.CreatorDate; + + public bool ShowSubmodulesAsTree + { + get; + set; + } = false; + + public bool IncludeUntrackedInLocalChanges + { + get; + set; + } = true; + + public bool EnableForceOnFetch + { + get; + set; + } = false; + + public bool FetchAllRemotes + { + get; + set; + } = false; + + public bool FetchWithoutTags + { + get; + set; + } = false; + + public bool PreferRebaseInsteadOfMerge + { + get; + set; + } = true; + + public bool CheckSubmodulesOnPush + { + get; + set; + } = true; + + public bool PushAllTags + { + get; + set; + } = false; + + public bool CreateAnnotatedTag + { + get; + set; + } = true; + + public bool PushToRemoteWhenCreateTag + { + get; + set; + } = true; + + public bool PushToRemoteWhenDeleteTag + { + get; + set; + } = false; + + public bool CheckoutBranchOnCreateBranch + { + get; + set; + } = true; + + public bool EnableSignOffForCommit + { + get; + set; + } = false; + + public bool NoVerifyOnCommit + { + get; + set; + } = false; + + public bool IncludeUntrackedWhenStash + { + get; + set; + } = true; + + public bool OnlyStagedWhenStash + { + get; + set; + } = false; + + public int ChangesAfterStashing + { + get; + set; + } = 0; + + public bool IsLocalBranchesExpandedInSideBar + { + get; + set; + } = true; + + public bool IsRemotesExpandedInSideBar + { + get; + set; + } = false; + + public bool IsTagsExpandedInSideBar + { + get; + set; + } = false; + + public bool IsSubmodulesExpandedInSideBar + { + get; + set; + } = false; + + public bool IsWorktreeExpandedInSideBar + { + get; + set; + } = false; + + public List ExpandedBranchNodesInSideBar + { + get; + set; + } = []; + + public string LastCommitMessage + { + get; + set; + } = string.Empty; + + public AvaloniaList HistoryFilters + { + get; + set; + } = []; + + public bool IsHistoryFiltersCollapsed + { + get; + set; + } = false; + + public List RecentCommitMessages + { + get; + set; + } = []; + + public static RepositoryUIStates Load(string gitDir) + { + var fileInfo = new FileInfo(Path.Combine(gitDir, "sourcegit.uistates")); + var fullpath = fileInfo.FullName; + + RepositoryUIStates states; + if (!File.Exists(fullpath)) + { + states = new RepositoryUIStates(); + } + else + { + try + { + using var stream = File.OpenRead(fullpath); + states = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.RepositoryUIStates); + } + catch + { + states = new RepositoryUIStates(); + } + } + + states._file = fullpath; + return states; + } + + public void Save() + { + try + { + var content = JsonSerializer.Serialize(this, JsonCodeGen.Default.RepositoryUIStates); + var tmpfile = $"{_file}.tmp"; + File.WriteAllText(tmpfile, content); + File.Move(tmpfile, _file, true); + } + catch + { + // Ignore save errors + } + } + + public FilterMode GetHistoryFilterMode(string pattern = null) + { + if (string.IsNullOrEmpty(pattern)) + return HistoryFilters.Count == 0 ? FilterMode.None : HistoryFilters[0].Mode; + + foreach (var filter in HistoryFilters) + { + if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + return filter.Mode; + } + + return FilterMode.None; + } + + public Dictionary GetHistoryFiltersMap() + { + var map = new Dictionary(); + foreach (var filter in HistoryFilters) + map.Add(filter.Pattern, filter.Mode); + return map; + } + + public bool UpdateHistoryFilters(string pattern, FilterType type, FilterMode mode) + { + // Clear all filters when there's a filter that has different mode. + if (mode != FilterMode.None) + { + var clear = false; + foreach (var filter in HistoryFilters) + { + if (filter.Mode != mode) + { + clear = true; + break; + } + } + + if (clear) + { + HistoryFilters.Clear(); + HistoryFilters.Add(new HistoryFilter(pattern, type, mode)); + return true; + } + } + else + { + for (int i = 0; i < HistoryFilters.Count; i++) + { + var filter = HistoryFilters[i]; + if (filter.Type == type && filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + { + HistoryFilters.RemoveAt(i); + return true; + } + } + + return false; + } + + foreach (var filter in HistoryFilters) + { + if (filter.Type != type) + continue; + + if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + return false; + } + + HistoryFilters.Add(new HistoryFilter(pattern, type, mode)); + return true; + } + + public void RemoveHistoryFilter(string pattern, FilterType type) + { + foreach (var filter in HistoryFilters) + { + if (filter.Type == type && filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + { + HistoryFilters.Remove(filter); + break; + } + } + } + + public void RenameBranchFilter(string oldName, string newName) + { + foreach (var filter in HistoryFilters) + { + if (filter.Type == FilterType.LocalBranch && + filter.Pattern.Equals(oldName, StringComparison.Ordinal)) + { + filter.Pattern = $"refs/heads/{newName}"; + break; + } + } + } + + public void RemoveBranchFiltersByPrefix(string pattern) + { + var dirty = new List(); + var prefix = $"{pattern}/"; + + foreach (var filter in HistoryFilters) + { + if (filter.Type == FilterType.Tag) + continue; + + if (filter.Pattern.StartsWith(prefix, StringComparison.Ordinal)) + dirty.Add(filter); + } + + foreach (var filter in dirty) + HistoryFilters.Remove(filter); + } + + public string BuildHistoryParams(string gitDir) + { + var builder = new StringBuilder(); + + if (EnableTopoOrderInHistory) + builder.Append("--topo-order "); + else + builder.Append("--date-order "); + + if (HistoryShowFlags.HasFlag(HistoryShowFlags.Reflog)) + builder.Append("--reflog "); + + if (HistoryShowFlags.HasFlag(HistoryShowFlags.FirstParentOnly)) + builder.Append("--first-parent "); + + if (HistoryShowFlags.HasFlag(HistoryShowFlags.SimplifyByDecoration)) + builder.Append("--simplify-by-decoration "); + + var mode = GetHistoryFilterMode(); + if (mode == FilterMode.None) + builder.Append("--branches --remotes --tags HEAD"); + else if (mode == FilterMode.Included) + BuildHistoryParamsForIncluded(builder); + else + BuildHistoryParamsForExcluded(builder, gitDir); + + return builder.ToString(); + } + + private void BuildHistoryParamsForIncluded(StringBuilder builder) + { + foreach (var filter in HistoryFilters) + { + if (filter.Type == FilterType.LocalBranch) + builder.Append(filter.Pattern).Append(' '); + else if (filter.Type == FilterType.LocalBranchFolder) + builder.Append($"--branches={filter.Pattern.AsSpan(11)}/* "); + else if (filter.Type == FilterType.RemoteBranch) + builder.Append(filter.Pattern).Append(' '); + else if (filter.Type == FilterType.RemoteBranchFolder) + builder.Append($"--remotes={filter.Pattern.AsSpan(13)}/* "); + else if (filter.Type == FilterType.Tag) + builder.Append($"refs/tags/{filter.Pattern} "); + } + } + + private void BuildHistoryParamsForExcluded(StringBuilder builder, string gitDir) + { + var excludedBranches = new List(); + var excludedRemotes = new List(); + var excludedTags = new List(); + foreach (var filter in HistoryFilters) + { + if (filter.Type == FilterType.LocalBranch) + excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}\" --decorate-refs-exclude=\"{filter.Pattern}\" "); + else if (filter.Type == FilterType.LocalBranchFolder) + excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\" "); + else if (filter.Type == FilterType.RemoteBranch) + excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}\" --decorate-refs-exclude=\"{filter.Pattern}\" "); + else if (filter.Type == FilterType.RemoteBranchFolder) + excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\" "); + else if (filter.Type == FilterType.Tag) + excludedTags.Add($"--exclude=\"{filter.Pattern}\" --decorate-refs-exclude=\"refs/tags/{filter.Pattern}\" "); + } + + foreach (var b in excludedBranches) + builder.Append(b); + + builder.Append("--branches "); + + var isInProgress = File.Exists(Path.Combine(gitDir, "CHERRY_PICK_HEAD")) || + Directory.Exists(Path.Combine(gitDir, "rebase-merge")) || + Directory.Exists(Path.Combine(gitDir, "rebase-apply")) || + File.Exists(Path.Combine(gitDir, "REVERT_HEAD")) || + File.Exists(Path.Combine(gitDir, "MERGE_HEAD")); + if (isInProgress) + builder.Append("HEAD "); + + foreach (var r in excludedRemotes) + builder.Append(r); + + builder.Append("--exclude=origin/HEAD --remotes "); + + foreach (var t in excludedTags) + builder.Append(t); + + builder.Append("--tags "); + } + + public void AddRecentCommitMessage(string message) + { + message = message.Trim().ReplaceLineEndings("\n"); + var existIdx = RecentCommitMessages.IndexOf(message); + if (existIdx == 0) + return; + + if (existIdx > 0) + { + RecentCommitMessages.RemoveAt(existIdx); + RecentCommitMessages.Insert(0, message); + return; + } + + if (RecentCommitMessages.Count > 9) + RecentCommitMessages.RemoveRange(9, RecentCommitMessages.Count - 9); + + RecentCommitMessages.Insert(0, message); + } + + private string _file = string.Empty; + } +} diff --git a/src/Models/RevisionFile.cs b/src/Models/RevisionFile.cs index 29a23efa7..9a9fe7d20 100644 --- a/src/Models/RevisionFile.cs +++ b/src/Models/RevisionFile.cs @@ -39,5 +39,6 @@ public class RevisionSubmodule { public Commit Commit { get; set; } = null; public CommitFullMessage FullMessage { get; set; } = null; + public int UncommittedChanges { get; set; } = 0; } } diff --git a/src/Models/SelfUpdate.cs b/src/Models/SelfUpdate.cs index 05fa61247..9cf95a148 100644 --- a/src/Models/SelfUpdate.cs +++ b/src/Models/SelfUpdate.cs @@ -12,24 +12,28 @@ public class Version [JsonPropertyName("tag_name")] public string TagName { get; set; } + [JsonPropertyName("published_at")] + public DateTime PublishedAt { get; set; } + [JsonPropertyName("body")] public string Body { get; set; } - public bool IsNewVersion + [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() { - 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; - } - } + var assembly = Assembly.GetExecutingAssembly().GetName(); + CurrentVersion = assembly.Version ?? new System.Version(); } } diff --git a/src/Models/ShellOrTerminal.cs b/src/Models/ShellOrTerminal.cs index 7dfb22373..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,18 +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("warp", "Warp", ""), - new ShellOrTerminal("ghostty", "Ghostty", ""), - new ShellOrTerminal("kitty", "kitty", "") + 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 @@ -57,19 +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("ptyxis", "Ptyxis", "ptyxis"), + 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 bc01e9db1..93439a40a 100644 --- a/src/Models/Stash.cs +++ b/src/Models/Stash.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; namespace SourceGit.Models { @@ -10,24 +9,7 @@ public class Stash public List Parents { get; set; } = []; public ulong Time { get; set; } = 0; public string Message { get; set; } = ""; - - public string Subject - { - get - { - return Message.Split('\n', 2)[0].Trim(); - } - } - - public string TimeStr - { - get - { - return DateTime.UnixEpoch - .AddSeconds(Time) - .ToLocalTime() - .ToString(DateTimeFormat.Active.DateTime); - } - } + 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 700c9311b..4ef9927fc 100644 --- a/src/Models/Statistics.cs +++ b/src/Models/Statistics.cs @@ -2,18 +2,11 @@ using System.Collections.Generic; using System.Globalization; -using LiveChartsCore; -using LiveChartsCore.Defaults; -using LiveChartsCore.SkiaSharpView; -using LiveChartsCore.SkiaSharpView.Painting; - -using SkiaSharp; - namespace SourceGit.Models { public enum StatisticsMode { - All, + All = 0, ThisMonth, ThisWeek, } @@ -24,45 +17,103 @@ public class StatisticsAuthor(User user, int count) public int Count { get; set; } = count; } + public class StatisticsSamples + { + public DateTime StartTime { get; } + public DateTime EndTime { get; } + public int Count { get; } + public int MaxValue { get; } + public bool HasSpecialUser => _user != null; + + public StatisticsSamples(StatisticsMode mode, DateTime start, DateTime end, Dictionary all, int maxValue) + { + _mode = mode; + _all = all; + + StartTime = start; + EndTime = end; + + if (maxValue < 8) + MaxValue = 8; + else if (maxValue < 16) + MaxValue = 16; + else if (maxValue < 24) + MaxValue = 24; + else + MaxValue = (int)(Math.Floor(maxValue / 6.0) * 8.0); + + if (maxValue == 0) + Count = 0; + else + Count = mode switch + { + StatisticsMode.All => (end.Year - start.Year) * 12 + (end.Month - start.Month) + 1, + _ => all.Count, + }; + } + + public void 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 StatisticsMode Mode { get; } public int Total { get; set; } = 0; public List Authors { get; set; } = new(); - public List Series { get; set; } = new(); - public List XAxes { get; set; } = new(); - public List YAxes { get; set; } = new(); - public StatisticsAuthor SelectedAuthor { get => _selectedAuthor; set => ChangeAuthor(value); } public StatisticsReport(StatisticsMode mode, DateTime start) { - _mode = mode; - - YAxes.Add(new Axis() - { - TextSize = 10, - MinLimit = 0, - SeparatorsPaint = new SolidColorPaint(new SKColor(0x40808080)) { StrokeThickness = 1 } - }); + Mode = mode; if (mode == StatisticsMode.ThisWeek) { - for (int i = 0; i < 7; i++) - _mapSamples.Add(start.AddDays(i), 0); + _minSampleTime = start; + _maxSampleTime = start.AddDays(6); - XAxes.Add(new DateTimeAxis(TimeSpan.FromDays(1), v => WEEKDAYS[(int)v.DayOfWeek]) { TextSize = 10 }); + for (int i = 0; i < 7; i++) + _all.Add(start.AddDays(i), 0); } else if (mode == StatisticsMode.ThisMonth) { var now = DateTime.Now; var maxDays = DateTime.DaysInMonth(now.Year, now.Month); + _minSampleTime = start; + _maxSampleTime = start.AddDays(maxDays - 1); for (int i = 0; i < maxDays; i++) - _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); } } @@ -71,15 +122,24 @@ public void AddCommit(DateTime time, User author) Total++; DateTime normalized; - if (_mode == StatisticsMode.ThisWeek || _mode == StatisticsMode.ThisMonth) + 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; @@ -95,10 +155,9 @@ public void AddCommit(DateTime time, User author) } else { - _mapUserSamples.Add(author, new Dictionary - { - { normalized, 1 } - }); + var added = new Dictionary(); + added.Add(normalized, 1); + _mapUserSamples.Add(author, added); } } @@ -107,76 +166,30 @@ public void Complete() foreach (var kv in _mapUsers) Authors.Add(new StatisticsAuthor(kv.Key, kv.Value)); - Authors.Sort((l, r) => r.Count - l.Count); - - var samples = new List(); - foreach (var kv in _mapSamples) - samples.Add(new DateTimePoint(kv.Key, kv.Value)); - - Series.Add( - new ColumnSeries() - { - Values = samples, - Stroke = null, - Fill = null, - Padding = 1, - } - ); - _mapUsers.Clear(); - _mapSamples.Clear(); - } - - public void ChangeColor(uint color) - { - _fillColor = color; - - var fill = new SKColor(color); - - if (Series.Count > 0 && Series[0] is ColumnSeries total) - total.Fill = new SolidColorPaint(_selectedAuthor == null ? fill : fill.WithAlpha(51)); - - if (Series.Count > 1 && Series[1] is ColumnSeries user) - user.Fill = new SolidColorPaint(fill); - } - - public void ChangeAuthor(StatisticsAuthor author) - { - if (author == _selectedAuthor) - return; + Authors.Sort((l, r) => r.Count - l.Count); - _selectedAuthor = author; - Series.RemoveRange(1, Series.Count - 1); - if (author == null || !_mapUserSamples.TryGetValue(author.User, out var userSamples)) + foreach (var kv in _all) { - ChangeColor(_fillColor); - return; + if (kv.Value > _maxSampleValue) + _maxSampleValue = kv.Value; } + } - var samples = new List(); - foreach (var kv in userSamples) - samples.Add(new DateTimePoint(kv.Key, kv.Value)); - - Series.Add( - new ColumnSeries() - { - Values = samples, - Stroke = null, - Fill = null, - Padding = 1, - } - ); - - ChangeColor(_fillColor); + public StatisticsSamples GetSamples(StatisticsAuthor withUser) + { + var samples = new StatisticsSamples(Mode, _minSampleTime, _maxSampleTime, _all, _maxSampleValue); + if (withUser != null && _mapUserSamples.TryGetValue(withUser.User, out var userSamples)) + samples.WithUser(userSamples); + return samples; } - private static readonly string[] WEEKDAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; - private StatisticsMode _mode; + private DateTime _minSampleTime = DateTime.MaxValue; + private DateTime _maxSampleTime = DateTime.MinValue; + private int _maxSampleValue = 0; + private Dictionary _all = new(); private Dictionary _mapUsers = new(); - private Dictionary _mapSamples = new(); private Dictionary> _mapUserSamples = new(); - private StatisticsAuthor _selectedAuthor = null; - private uint _fillColor = 255; } public class Statistics @@ -207,7 +220,7 @@ public void AddCommit(string author, double timestamp) _users.Add(email, user); } - var time = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); + var time = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().Date; if (time >= _thisWeekStart) Week.AddCommit(time, user); diff --git a/src/Models/Tag.cs b/src/Models/Tag.cs index 87944637a..5e3ed7b41 100644 --- a/src/Models/Tag.cs +++ b/src/Models/Tag.cs @@ -1,6 +1,4 @@ -using CommunityToolkit.Mvvm.ComponentModel; - -namespace SourceGit.Models +namespace SourceGit.Models { public enum TagSortMode { @@ -8,20 +6,13 @@ public enum TagSortMode Name, } - public class Tag : ObservableObject + 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 index 87822fb11..12280006e 100644 --- a/src/Models/TemplateEngine.cs +++ b/src/Models/TemplateEngine.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Text; using System.Text.RegularExpressions; @@ -285,7 +286,7 @@ private string ParseReplacement() switch (c) { case ESCAPE: - // allow to escape only } + // allow to escape only right-brace if (Peek() == VARIABLE_END) { esc = true; @@ -349,14 +350,10 @@ private static string EvalVariable(Context context, RegexVariable variable) private delegate string VariableGetter(Context context); private static readonly IReadOnlyDictionary s_variables = new Dictionary() { - // legacy variables {"branch_name", GetBranchName}, {"files_num", GetFilesCount}, {"files", GetFiles}, - // - {"BRANCH", GetBranchName}, - {"FILES_COUNT", GetFilesCount}, - {"FILES", GetFiles}, + {"pure_files", GetPureFiles}, }; private static string GetBranchName(Context context) @@ -377,13 +374,19 @@ private static string GetFiles(Context context) 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() { - // legacy variables {"files", GetFilesSliced}, - // - {"FILES", GetFilesSliced}, + {"pure_files", GetPureFilesSliced} }; private static string GetFilesSliced(Context context, int count) @@ -400,5 +403,20 @@ private static string GetFilesSliced(Context context, int count) 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 bc1873e25..e49a6d032 100644 --- a/src/Models/TextInlineChange.cs +++ b/src/Models/TextInlineChange.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Globalization; namespace SourceGit.Models { @@ -26,6 +27,13 @@ private enum Edit AddedLeft, } + 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; @@ -100,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; } @@ -302,5 +313,33 @@ private static void AddChunk(List chunks, Dictionary hashes, } 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 e9903890b..5bb50da69 100644 --- a/src/Models/TextMateHelper.cs +++ b/src/Models/TextMateHelper.cs @@ -26,8 +26,11 @@ public static class GrammarUtility 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); @@ -52,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)) @@ -61,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 { @@ -70,7 +81,9 @@ 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, List Extensions, string File) @@ -87,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); } @@ -121,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 d3faedf0d..a1b984a04 100644 --- a/src/Models/User.cs +++ b/src/Models/User.cs @@ -4,35 +4,23 @@ 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 parts = data.Split('±', 2); if (parts.Length < 2) - parts = [string.Empty, data]; - - Name = parts[0]; - Email = parts[1]; - _hash = data.GetHashCode(); - } - - public override bool Equals(object obj) - { - return obj is User other && Name == other.Name && Email == other.Email; - } - - public override int GetHashCode() - { - return _hash; + { + Email = data; + } + else + { + Name = parts[0]; + Email = parts[1]; + } } public static User FindOrAdd(string data) @@ -46,6 +34,5 @@ public override string ToString() } private static ConcurrentDictionary _caches = new ConcurrentDictionary(); - private readonly int _hash; } } diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs index 9ba7ee9cb..ce18fe4be 100644 --- a/src/Models/Watcher.cs +++ b/src/Models/Watcher.cs @@ -8,9 +8,27 @@ namespace SourceGit.Models { public class Watcher : IDisposable { + public class LockContext : IDisposable + { + public LockContext(Watcher target) + { + _target = target; + Interlocked.Increment(ref _target._lockCount); + } + + public void Dispose() + { + Interlocked.Decrement(ref _target._lockCount); + } + + private Watcher _target; + } + public Watcher(IRepository repo, string fullpath, string gitDir) { _repo = repo; + _root = new DirectoryInfo(fullpath).FullName; + _watchers = new List(); var testGitDir = new DirectoryInfo(Path.Combine(fullpath, ".git")).FullName; var desiredDir = new DirectoryInfo(gitDir).FullName; @@ -19,13 +37,13 @@ public Watcher(IRepository repo, string fullpath, string gitDir) var combined = new FileSystemWatcher(); combined.Path = fullpath; combined.Filter = "*"; - combined.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.CreationTime; + 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 = true; + combined.EnableRaisingEvents = false; _watchers.Add(combined); } @@ -34,13 +52,13 @@ public Watcher(IRepository repo, string fullpath, string gitDir) var wc = new FileSystemWatcher(); wc.Path = fullpath; wc.Filter = "*"; - wc.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.CreationTime; + 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 = true; + wc.EnableRaisingEvents = false; var git = new FileSystemWatcher(); git.Path = gitDir; @@ -51,51 +69,58 @@ public Watcher(IRepository repo, string fullpath, string gitDir) git.Renamed += OnGitDirChanged; git.Changed += OnGitDirChanged; git.Deleted += OnGitDirChanged; - git.EnableRaisingEvents = true; + 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 SetEnabled(bool enabled) + public IDisposable Lock() { - if (enabled) - { - if (_lockCount > 0) - _lockCount--; - } - else - { - _lockCount++; - } + return new LockContext(this); } - public void SetSubmodules(List submodules) + public void MarkBranchUpdated() { - lock (_lockSubmodule) - { - _submodules.Clear(); - foreach (var submodule in submodules) - _submodules.Add(submodule.Path); - } + Interlocked.Exchange(ref _updateBranch, 0); + Interlocked.Exchange(ref _updateWC, 0); + } + + public void MarkTagUpdated() + { + Interlocked.Exchange(ref _updateTags, 0); } - public void MarkBranchDirtyManually() + public void MarkWorkingCopyUpdated() { - _updateBranch = DateTime.Now.ToFileTime() - 1; + Interlocked.Exchange(ref _updateWC, 0); } - public void MarkTagDirtyManually() + public void MarkStashUpdated() { - _updateTags = DateTime.Now.ToFileTime() - 1; + Interlocked.Exchange(ref _updateStashes, 0); } - public void MarkWorkingCopyDirtyManually() + public void MarkSubmodulesUpdated() { - _updateWC = DateTime.Now.ToFileTime() - 1; + Interlocked.Exchange(ref _updateSubmodules, 0); } public void Dispose() @@ -113,57 +138,91 @@ public void Dispose() 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); } + } - if (_updateSubmodules > 0 || _repo.MayHaveSubmodules()) + if (refreshWC) + { + Interlocked.Exchange(ref _updateWC, -1); + _repo.RefreshWorkingCopyChanges(); + } + else + { + var oldUpdateWC = Interlocked.Exchange(ref _updateWC, -1); + if (oldUpdateWC > 0) { - _updateSubmodules = 0; - Task.Run(_repo.RefreshSubmodules); + if (now > oldUpdateWC) + _repo.RefreshWorkingCopyChanges(); + else + Interlocked.CompareExchange(ref _updateWC, oldUpdateWC, -1); } - - Task.Run(_repo.RefreshBranches); - Task.Run(_repo.RefreshCommits); - Task.Run(_repo.RefreshWorkingCopyChanges); - Task.Run(_repo.RefreshWorktrees); } - if (_updateWC > 0 && now > _updateWC) + if (refreshSubmodules) { - _updateWC = 0; - Task.Run(_repo.RefreshWorkingCopyChanges); + Interlocked.Exchange(ref _updateSubmodules, -1); + _repo.RefreshSubmodules(); } - - if (_updateSubmodules > 0 && now > _updateSubmodules) + else { - _updateSubmodules = 0; - Task.Run(_repo.RefreshSubmodules); + 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; - Task.Run(_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; - Task.Run(_repo.RefreshTags); - Task.Run(_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) @@ -178,7 +237,7 @@ private void OnRepositoryChanged(object o, FileSystemEventArgs e) if (name.StartsWith(".git/", StringComparison.Ordinal)) HandleGitDirFileChanged(name.Substring(5)); else - HandleWorkingCopyFileChanged(name); + HandleWorkingCopyFileChanged(name, e.FullPath); } private void OnGitDirChanged(object o, FileSystemEventArgs e) @@ -201,7 +260,7 @@ private void OnWorkingCopyChanged(object o, FileSystemEventArgs e) name.EndsWith("/.git", StringComparison.Ordinal)) return; - HandleWorkingCopyFileChanged(name); + HandleWorkingCopyFileChanged(name, e.FullPath); } private void HandleGitDirFileChanged(string name) @@ -216,23 +275,24 @@ private void HandleGitDirFileChanged(string name) if (name.EndsWith("/HEAD", StringComparison.Ordinal) || name.EndsWith("/ORIG_HEAD", StringComparison.Ordinal)) { - _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); - _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); + var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + Interlocked.Exchange(ref _updateSubmodules, desired); + Interlocked.Exchange(ref _updateWC, desired); } } else if (name.Equals("MERGE_HEAD", StringComparison.Ordinal) || name.Equals("AUTO_MERGE", StringComparison.Ordinal)) { if (_repo.MayHaveSubmodules()) - _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); + 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) || @@ -240,52 +300,65 @@ private void HandleGitDirFileChanged(string name) name.StartsWith("refs/remotes/", StringComparison.Ordinal) || (name.StartsWith("worktrees/", StringComparison.Ordinal) && name.EndsWith("/HEAD", StringComparison.Ordinal))) { - _updateBranch = DateTime.Now.AddSeconds(.5).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 HandleWorkingCopyFileChanged(string name) + private void HandleWorkingCopyFileChanged(string name, string fullpath) { if (name.StartsWith(".vs/", StringComparison.Ordinal)) return; if (name.Equals(".gitmodules", StringComparison.Ordinal)) { - _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); - _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); + var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + Interlocked.Exchange(ref _updateSubmodules, desired); + Interlocked.Exchange(ref _updateWC, desired); return; } - lock (_lockSubmodule) + 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 List _watchers = []; - 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 Lock _lockSubmodule = new(); - private List _submodules = new List(); + private readonly IRepository _repo; + private readonly string _root; + private List _watchers; + private Timer _timer; + + private long _lockCount; + private long _updateWC; + private long _updateBranch; + private long _updateSubmodules; + private long _updateStashes; + private long _updateTags; } } diff --git a/src/Models/Worktree.cs b/src/Models/Worktree.cs index 26f88a8a8..27a9415d5 100644 --- a/src/Models/Worktree.cs +++ b/src/Models/Worktree.cs @@ -1,40 +1,12 @@ -using System; -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 RelativePath { get; set; } = string.Empty; public string Head { get; set; } = string.Empty; public bool IsBare { get; set; } = false; public bool IsDetached { get; set; } = false; - - public bool IsLocked - { - get => _isLocked; - set => SetProperty(ref _isLocked, value); - } - - public string Name - { - get - { - if (IsDetached) - return $"detached HEAD at {Head.AsSpan(10)}"; - - if (Branch.StartsWith("refs/heads/", StringComparison.Ordinal)) - return Branch.Substring(11); - - if (Branch.StartsWith("refs/remotes/", StringComparison.Ordinal)) - return Branch.Substring(13); - - return Branch; - } - } - - private bool _isLocked = false; + public bool IsLocked { get; set; } = false; } } diff --git a/src/Native/Linux.cs b/src/Native/Linux.cs index f6eb4ebf3..a28d88ebe 100644 --- a/src/Native/Linux.cs +++ b/src/Native/Linux.cs @@ -20,6 +20,8 @@ public void SetupApp(AppBuilder builder) public void SetupWindow(Window window) { + window.BorderThickness = new Thickness(0); + if (OS.UseSystemWindowFrame) { window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.Default; @@ -33,6 +35,22 @@ public void SetupWindow(Window window) } } + 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() { return FindExecutable("git"); @@ -54,19 +72,25 @@ public string FindTerminal(Models.ShellOrTerminal shell) finder.VSCodeInsiders(() => FindExecutable("code-insiders")); finder.VSCodium(() => FindExecutable("codium")); finder.Cursor(() => FindExecutable("cursor")); - finder.Fleet(() => FindJetBrainsFleet(localAppDataDir)); finder.FindJetBrainsFromToolbox(() => Path.Combine(localAppDataDir, "JetBrains/Toolbox")); finder.SublimeText(() => FindExecutable("subl")); - finder.Zed(() => FindExecutable("zeditor")); + 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.Quoted()); + 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)) { @@ -80,20 +104,15 @@ public void OpenInFileManager(string path, bool select) } } - 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; - var terminal = OS.ShellOrTerminal; var startInfo = new ProcessStartInfo(); startInfo.WorkingDirectory = cwd; - startInfo.FileName = terminal; - - if (terminal.EndsWith("wezterm", StringComparison.OrdinalIgnoreCase)) - startInfo.Arguments = $"start --cwd {cwd.Quoted()}"; - else if (terminal.EndsWith("ptyxis", StringComparison.OrdinalIgnoreCase)) - startInfo.Arguments = $"--new-window --working-directory={cwd.Quoted()}"; + startInfo.FileName = OS.ShellOrTerminal; + startInfo.Arguments = args; try { @@ -101,7 +120,7 @@ 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); } } @@ -113,7 +132,7 @@ public void OpenWithDefaultEditor(string file) proc.WaitForExit(); if (proc.ExitCode != 0) - App.RaiseException("", $"Failed to open: {file}"); + Models.Notification.Send("", $"Failed to open: {file}", true); proc.Close(); } @@ -130,13 +149,8 @@ private string FindExecutable(string filename) return test; } - return string.Empty; - } - - private string FindJetBrainsFleet(string localAppDataDir) - { - var path = Path.Combine(localAppDataDir, "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 a021a16d8..54a2a8a72 100644 --- a/src/Native/MacOS.cs +++ b/src/Native/MacOS.cs @@ -1,7 +1,9 @@ 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; @@ -42,6 +44,14 @@ public void SetupWindow(Window window) { window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.SystemChrome; window.ExtendClientAreaToDecorationsHint = true; + window.BorderThickness = new Thickness(0); + } + + public string GetDataDir() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "SourceGit"); } public string FindGitExecutable() @@ -62,15 +72,7 @@ public string FindGitExecutable() public string FindTerminal(Models.ShellOrTerminal shell) { - return shell.Type switch - { - "mac-terminal" => "Terminal", - "iterm2" => "iTerm", - "warp" => "Warp", - "ghostty" => "Ghostty", - "kitty" => "kitty", - _ => string.Empty, - }; + return shell.Exec; } public List FindExternalTools() @@ -81,7 +83,6 @@ public string FindTerminal(Models.ShellOrTerminal shell) finder.VSCodeInsiders(() => "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code"); finder.VSCodium(() => "/Applications/VSCodium.app/Contents/Resources/app/bin/codium"); finder.Cursor(() => "/Applications/Cursor.app/Contents/Resources/app/bin/cursor"); - finder.Fleet(() => Path.Combine(home, "Applications/Fleet.app/Contents/MacOS/Fleet")); 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"); @@ -93,7 +94,7 @@ 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.Quoted()); @@ -101,7 +102,7 @@ public void OpenInFileManager(string path, bool select) 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; @@ -113,4 +114,175 @@ public void OpenWithDefaultEditor(string file) Process.Start("open", file.Quoted()); } } + + [SupportedOSPlatform("macOS")] + public static class MacOSUtilities + { + [StructLayout(LayoutKind.Sequential)] + public struct CGPoint + { + public double X; + public double Y; + + public CGPoint(double x, double y) + { + X = x; + Y = y; + } + + public override int GetHashCode() + { + return HashCode.Combine(X, Y); + } + + public override bool Equals([NotNullWhen(true)] object obj) + { + if (obj is not CGPoint point) + return false; + + return X == point.X && Y == point.Y; + } + + public static bool operator ==(CGPoint left, CGPoint right) => left.Equals(right); + public static bool operator !=(CGPoint left, CGPoint right) => !left.Equals(right); + } + + [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 27fce1c3f..ceaa69c84 100644 --- a/src/Native/OS.cs +++ b/src/Native/OS.cs @@ -2,9 +2,11 @@ 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; @@ -17,12 +19,13 @@ 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); } @@ -58,18 +61,60 @@ public static Version GitVersion 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; @@ -79,51 +124,27 @@ public static bool UseSystemWindowFrame static OS() { if (OperatingSystem.IsWindows()) - { _backend = new Windows(); - } else if (OperatingSystem.IsMacOS()) - { _backend = new MacOS(); - } else if (OperatingSystem.IsLinux()) - { _backend = new Linux(); - } else - { - throw new Exception("Platform unsupported!!!"); - } - } - - public static void SetupApp(AppBuilder builder) - { - _backend.SetupApp(builder); + throw new PlatformNotSupportedException(); } public static void SetupDataDir() { - if (OperatingSystem.IsWindows()) - { - var execFile = Process.GetCurrentProcess().MainModule!.FileName; - var portableDir = Path.Combine(Path.GetDirectoryName(execFile), "data"); - if (Directory.Exists(portableDir)) - { - DataDir = portableDir; - return; - } - } - - var osAppDataDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - if (string.IsNullOrEmpty(osAppDataDir)) - DataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sourcegit"); - else - DataDir = Path.Combine(osAppDataDir, "SourceGit"); - + DataDir = _backend.GetDataDir(); if (!Directory.Exists(DataDir)) Directory.CreateDirectory(DataDir); } + public static void SetupApp(AppBuilder builder) + { + _backend.SetupApp(builder); + } + public static void SetupExternalTools() { ExternalTools = _backend.FindExternalTools(); @@ -134,6 +155,35 @@ public static void SetupForWindow(Window window) _backend.SetupWindow(window); } + public static void LogException(Exception ex) + { + if (ex == null) + return; + + var crashDir = Path.Combine(DataDir, "crashes"); + if (!Directory.Exists(crashDir)) + Directory.CreateDirectory(crashDir); + + var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + var file = Path.Combine(crashDir, $"{time}.log"); + using var writer = new StreamWriter(file); + writer.WriteLine($"Crash::: {ex.GetType().FullName}: {ex.Message}"); + writer.WriteLine(); + writer.WriteLine("----------------------------"); + writer.WriteLine($"Version: {Assembly.GetExecutingAssembly().GetName().Version}"); + writer.WriteLine($"OS: {Environment.OSVersion}"); + writer.WriteLine($"Framework: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}"); + writer.WriteLine($"Source: {ex.Source}"); + writer.WriteLine($"Thread Name: {Thread.CurrentThread.Name ?? "Unnamed"}"); + writer.WriteLine($"App Start Time: {Process.GetCurrentProcess().StartTime}"); + writer.WriteLine($"Exception Time: {DateTime.Now}"); + writer.WriteLine($"Memory Usage: {Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024} MB"); + writer.WriteLine("----------------------------"); + writer.WriteLine(); + writer.WriteLine(ex); + writer.Flush(); + } + public static string FindGitExecutable() { return _backend.FindGitExecutable(); @@ -146,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) @@ -184,12 +284,26 @@ public static string GetAbsPath(string root, string sub) 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; } @@ -205,7 +319,7 @@ private static void UpdateGitVersion() try { - using var proc = Process.Start(start); + using var proc = Process.Start(start)!; var rs = proc.StandardOutput.ReadToEnd(); proc.WaitForExit(); if (proc.ExitCode == 0 && !string.IsNullOrWhiteSpace(rs)) @@ -221,6 +335,42 @@ private static void UpdateGitVersion() 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 diff --git a/src/Native/Windows.cs b/src/Native/Windows.cs index 69e8d842c..2ec98ca40 100644 --- a/src/Native/Windows.cs +++ b/src/Native/Windows.cs @@ -17,26 +17,6 @@ namespace SourceGit.Native [SupportedOSPlatform("windows")] internal class Windows : OS.IBackend { - internal struct RECT - { - public int left; - public int top; - public int right; - public int bottom; - } - - [StructLayout(LayoutKind.Sequential)] - internal struct MARGINS - { - public int cxLeftWidth; - public int cxRightWidth; - public int cyTopHeight; - public int cyBottomHeight; - } - - [DllImport("dwmapi.dll")] - private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins); - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, SetLastError = false)] private static extern bool PathFindOnPath([In, Out] StringBuilder pszFile, [In] string[] ppszOtherDirs); @@ -49,68 +29,29 @@ internal struct MARGINS [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = false)] private static extern int SHOpenFolderAndSelectItems(IntPtr pidlFolder, int cild, IntPtr apidl, int dwFlags); - [DllImport("user32.dll")] - private static extern bool GetWindowRect(IntPtr hwnd, out RECT lpRect); - public void SetupApp(AppBuilder builder) { - // Fix drop shadow issue on Windows 10 - if (!OperatingSystem.IsWindowsVersionAtLeast(10, 22000)) - { - 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.Classes.Add("fix_maximized_padding"); - - Win32Properties.AddWndProcHookCallback(window, (IntPtr hWnd, uint msg, IntPtr _, IntPtr lParam, ref bool handled) => - { - // Custom WM_NCHITTEST - if (msg == 0x0084) - { - handled = true; - - if (window.WindowState == WindowState.FullScreen || window.WindowState == WindowState.Maximized) - return 1; // HTCLIENT - - var p = IntPtrToPixelPoint(lParam); - GetWindowRect(hWnd, out var rcWindow); - - var borderThickness = (int)(4 * window.RenderScaling); - int y = 1; - int x = 1; - if (p.X >= rcWindow.left && p.X < rcWindow.left + borderThickness) - x = 0; - else if (p.X < rcWindow.right && p.X >= rcWindow.right - borderThickness) - x = 2; - - if (p.Y >= rcWindow.top && p.Y < rcWindow.top + borderThickness) - y = 0; - else if (p.Y < rcWindow.bottom && p.Y >= rcWindow.bottom - borderThickness) - y = 2; - - var zone = y * 3 + x; - return zone switch - { - 0 => 13, // HTTOPLEFT - 1 => 12, // HTTOP - 2 => 14, // HTTOPRIGHT - 3 => 10, // HTLEFT - 4 => 1, // HTCLIENT - 5 => 11, // HTRIGHT - 6 => 16, // HTBOTTOMLEFT - 7 => 15, // HTBOTTOM - _ => 17, - }; - } + window.BorderThickness = new Thickness(1); + window.Padding = new Thickness(0); + } - return IntPtr.Zero; - }); + 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() @@ -143,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; @@ -186,66 +127,65 @@ public string FindTerminal(Models.ShellOrTerminal shell) finder.VSCode(FindVSCode); finder.VSCodeInsiders(FindVSCodeInsiders); finder.VSCodium(FindVSCodium); - finder.Cursor(FindCursor); - finder.Fleet(() => Path.Combine(localAppDataDir, @"Programs\Fleet\Fleet.exe")); + FindVisualStudio(finder); + finder.Cursor(() => Path.Combine(localAppDataDir, @"Programs\Cursor\Cursor.exe")); finder.FindJetBrainsFromToolbox(() => Path.Combine(localAppDataDir, @"JetBrains\Toolbox")); finder.SublimeText(FindSublimeText); - FindVisualStudio(finder); + finder.Zed(FindZed); return finder.Tools; } public void OpenBrowser(string url) { - var info = new ProcessStartInfo("cmd", $"""/c start "" {url.Quoted()}"""); + 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.Quoted()}"; - + 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) @@ -256,26 +196,27 @@ public void OpenWithDefaultEditor(string file) Process.Start(start); } - private void FixWindowFrameOnWin10(Window w) + #region HELPER_METHODS + private List GenerateVSProjectLaunchOptions(string path) { - // Schedule the DWM frame extension to run in the next render frame - // to ensure proper timing with the window initialization sequence - Dispatcher.UIThread.Post(() => - { - var platformHandle = w.TryGetPlatformHandle(); - if (platformHandle == null) - return; - - var margins = new MARGINS { cxLeftWidth = 1, cxRightWidth = 1, cyTopHeight = 1, cyBottomHeight = 1 }; - DwmExtendFrameIntoClientArea(platformHandle.Handle, ref margins); - }, DispatcherPriority.Render); - } + var root = new DirectoryInfo(path); + if (!root.Exists) + return null; - private PixelPoint IntPtrToPixelPoint(IntPtr param) - { - var v = IntPtr.Size == 4 ? param.ToInt32() : (int)(param.ToInt64() & 0xFFFFFFFF); - return new PixelPoint((short)(v & 0xffff), (short)(v >> 16)); + 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() @@ -389,7 +330,7 @@ private void FindVisualStudio(Models.ExternalToolsFinder finder) try { - using var proc = Process.Start(startInfo); + using var proc = Process.Start(startInfo)!; var output = proc.StandardOutput.ReadToEnd(); proc.WaitForExit(); @@ -400,7 +341,7 @@ private void FindVisualStudio(Models.ExternalToolsFinder finder) { var exec = instance.ProductPath; var icon = instance.IsPrerelease ? "vs-preview" : "vs"; - finder.TryAdd(instance.DisplayName, icon, () => exec, GenerateCommandlineArgsForVisualStudio); + finder.TryAdd(instance.DisplayName, icon, () => exec, GenerateVSProjectLaunchOptions, false); } } } @@ -410,62 +351,66 @@ private void FindVisualStudio(Models.ExternalToolsFinder finder) } } - private string FindCursor() + private string FindZed() { - var cursorPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Programs", - "Cursor", - "Cursor.exe"); + var currentUser = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.CurrentUser, + Microsoft.Win32.RegistryView.Registry64); + + // NOTE: this is the official Zed Preview reg data. + var preview = currentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{F70E4811-D0E2-4D88-AC99-D63752799F95}_is1"); + if (preview != null) + return preview.GetValue("DisplayIcon") as string; - if (File.Exists(cursorPath)) - return cursorPath; + var findInPath = new StringBuilder("zed.exe", 512); + if (PathFindOnPath(findInPath, null)) + return findInPath.ToString(); return string.Empty; } #endregion + } - private void OpenFolderAndSelectFile(string folderPath) + [SupportedOSPlatform("windows")] + public static class Win64Utilities + { + [StructLayout(LayoutKind.Sequential)] + internal struct MARGINS { - var pidl = ILCreateFromPathW(folderPath); - - try - { - SHOpenFolderAndSelectItems(pidl, 0, 0, 0); - } - finally - { - ILFree(pidl); - } + public int cxLeftWidth; + public int cxRightWidth; + public int cyTopHeight; + public int cyBottomHeight; } - private string GenerateCommandlineArgsForVisualStudio(string repo) - { - var sln = FindVSSolutionFile(new DirectoryInfo(repo), 4); - return string.IsNullOrEmpty(sln) ? repo.Quoted() : sln.Quoted(); - } + [DllImport("dwmapi.dll")] + private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins); - private string FindVSSolutionFile(DirectoryInfo dir, int leftDepth) + public static void FixWindowFrame(Window w) { - var files = dir.GetFiles(); - foreach (var f in files) + if (w.WindowState == WindowState.Maximized) + { + w.BorderThickness = new Thickness(0); + w.Padding = new Thickness(8, 6, 8, 8); + } + else { - if (f.Name.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)) - return f.FullName; + 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/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 015c149a7..6852b6a51 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -1,34 +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 + 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 926c-229 0-414-186-414-414S283 98 512 98s414 186 414 414-186 414-414 414zm0-73c189 0 341-153 341-341S701 171 512 171 171 323 171 512s153 341 341 341zm-6-192L284 439l52-52 171 171 171-171L728 439l-222 222z M512 57c251 0 455 204 455 455S763 967 512 967 57 763 57 512 261 57 512 57zm181 274c-11-11-29-11-40 0L512 472 371 331c-11-11-29-11-40 0-11 11-11 29 0 40L471 512 331 653c-11 11-11 29 0 40 11 11 29 11 40 0l141-141 141 141c11 11 29 11 40 0 11-11 11-29 0-40L552 512l141-141c11-11 11-29 0-40z M591 907A85 85 0 01427 875h114a299 299 0 0050 32zM725 405c130 0 235 105 235 235s-105 235-235 235-235-105-235-235 105-235 235-235zM512 64a43 43 0 0143 43v24c126 17 229 107 264 225A298 298 0 00725 341l-4 0A235 235 0 00512 213l-5 0c-125 4-224 104-228 229l-0 6v167a211 211 0 01-26 101l-4 7-14 23h211a298 298 0 0050 85l-276-0a77 77 0 01-66-39c-13-22-14-50-2-73l2-4 22-36c10-17 16-37 17-57l0-7v-167C193 287 313 153 469 131V107a43 43 0 0139-43zm345 505L654 771a149 149 0 00202-202zM725 491a149 149 0 00-131 220l202-202A149 149 0 00725 491z M797 829a49 49 0 1049 49 49 49 0 00-49-49zm147-114A49 49 0 10992 764a49 49 0 00-49-49zM928 861a49 49 0 1049 49A49 49 0 00928 861zm-5-586L992 205 851 64l-71 71a67 67 0 00-94 0l235 235a67 67 0 000-94zm-853 128a32 32 0 00-32 50 1291 1291 0 0075 112L288 552c20 0 25 21 8 37l-93 86a1282 1282 0 00120 114l100-32c19-6 28 15 14 34l-40 55c26 19 53 36 82 53a89 89 0 00115-20 1391 1391 0 00256-485l-188-188s-306 224-595 198z M1280 704c0 141-115 256-256 256H288C129 960 0 831 0 672c0-126 80-232 192-272A327 327 0 01192 384c0-177 143-320 320-320 119 0 222 64 277 160C820 204 857 192 896 192c106 0 192 86 192 192 0 24-5 48-13 69C1192 477 1280 580 1280 704zm-493-128H656V352c0-18-14-32-32-32h-96c-18 0-32 14-32 32v224h-131c-29 0-43 34-23 55l211 211c12 12 33 12 45 0l211-211c20-20 6-55-23-55z 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 - M684 736 340 736l0-53 344 1-0 53zM552 565l-213-2 0-53 212 2-0 53zM684 392 340 392l0-53 344 1-0 53zM301 825c-45 0-78-9-100-27-22-18-33-43-33-75v-116c0-22-4-37-12-45-7-9-20-13-40-13v-61c19 0 32-4 40-12 8-9 12-24 12-46v-116c0-32 11-57 33-75 22-18 56-27 100-27h24v61h-24a35 35 0 00-27 12 41 41 0 00-11 29v116c0 35-10 60-31 75a66 66 0 01-31 14c11 2 22 6 31 14 20 17 31 42 31 75v116c0 12 4 22 11 29 7 8 16 12 27 12h24v61h-24zM701 764h24c10 0 19-4 27-12a41 41 0 0011-29v-116c0-33 10-58 31-75 9-7 19-12 31-14a66 66 0 01-31-14c-20-15-31-40-31-75v-116a41 41 0 00-11-29 35 35 0 00-27-12h-24v-61h24c45 0 78 9 100 27 22 18 33 43 33 75v116c0 22 4 37 11 46 8 8 21 12 40 12v61c-19 0-33 4-40 13-7 8-11 23-11 45v116c0 32-11 57-33 75-22 18-55 27-100 27h-24v-61z + M277 708h469a43 43 0 110 85h-469a43 43 0 110-85zM331 85h362c34 0 63 0 86 2 24 2 46 6 67 17a171 171 0 0175 75c11 21 15 43 17 67C939 268 939 297 939 331v362c0 34 0 63-2 86-2 24-6 46-17 67a171 171 0 01-75 75c-21 11-43 15-67 17-23 2-51 2-86 2H331c-34 0-63 0-86-2-24-2-46-6-67-17a171 171 0 01-75-75c-11-21-15-43-17-67C85 756 85 727 85 693V331c0-34 0-63 2-86 2-24 6-46 17-67a171 171 0 0175-75c21-11 43-15 67-17C268 85 297 85 331 85zM252 172c-19 1-28 4-35 8a85 85 0 00-37 37c-3 7-6 16-8 35C171 271 171 296 171 333v358c0 37 0 61 2 81 1 19 4 28 8 35a85 85 0 0037 37c7 3 16 6 35 8C271 853 296 853 333 853h358c37 0 61 0 81-2 19-1 28-4 35-8a85 85 0 0037-37c3-7 6-16 8-35 2-19 2-44 2-81V333c0-37 0-61-2-81-1-19-4-28-8-35a85 85 0 00-37-37c-7-3-16-6-35-8C753 171 728 171 691 171H333c-37 0-61 0-81 2z M128 854h768v86H128zM390 797c13 13 29 19 48 19s35-6 45-19l291-288c26-22 26-64 0-90L435 83l-61 61L426 192l-272 269c-22 22-22 64 0 90l237 246zm93-544 211 211-32 32H240l243-243zM707 694c0 48 38 86 86 86 48 0 86-38 86-86 0-22-10-45-26-61L794 576l-61 61c-13 13-26 35-26 58z + M325 312l-60 60L404 513 265 652l60 60 200-200L325 312zm194 345h236v97h-236v-97zM29 77v870h968V77H29zm870 774H125V173h774v678z M0 512M1024 512M512 0M512 1024M796 471A292 292 0 00512 256a293 293 0 00-284 215H0v144h228A293 293 0 00512 832a291 291 0 00284-217H1024V471h-228M512 688A146 146 0 01366 544A145 145 0 01512 400c80 0 146 63 146 144A146 146 0 01512 688 M796 561a5 5 0 014 7l-39 90a5 5 0 004 7h100a5 5 0 014 8l-178 247a5 5 0 01-9-4l32-148a5 5 0 00-5-6h-89a5 5 0 01-4-7l86-191a5 5 0 014-3h88zM731 122a73 73 0 0173 73v318a54 54 0 00-8-1H731V195H244v634h408l-16 73H244a73 73 0 01-73-73V195a73 73 0 0173-73h488zm-219 366v73h-195v-73h195zm146-146v73H317v-73h341z M645 448l64 64 220-221L704 64l-64 64 115 115H128v90h628zM375 576l-64-64-220 224L314 960l64-64-116-115H896v-90H262z + M64 128h896v64H64V128zm832 128h-64v512h64V256zm-175 281a151 151 0 00-23-50 112 112 0 00-39-34 117 117 0 00-55-12c-13 0-24 2-35 5a113 113 0 00-29 13 110 110 0 00-23 20l-11 17V293H448V768h57v-37l8 11c6 7 12 12 19 17 7 5 16 9 25 12 10 3 20 4 32 4 23 0 43-5 60-14 17-9 32-22 44-38 12-16 21-35 26-56 6-22 9-45 9-70a240 240 0 00-8-62zM627 498c10 5 18 11 25 20 7 9 12 20 16 33 3 11 5 24 6 38l-0 8c0 21-2 40-6 56a121 121 0 01-18 39c-7 10-17 18-27 23-21 10-49 11-68 1a81 81 0 01-25-19 81 81 0 01-14-23s-11-29-11-61c0-32 11-64 11-64 4-11 9-20 14-28 7-9 16-17 27-22 11-6 23-8 38-8 12 0 23 2 33 7zM960 832H64v64h896v-64zM180 640l-47 130H64l2-5 156-454h60l159 458H372L322 640H180zm72-229h-1l-58 176h118l-58-176z M608 0q48 0 88 23t63 63 23 87v70h55q35 0 67 14t57 38 38 57 14 67V831q0 34-14 66t-38 57-57 38-67 13H426q-34 0-66-13t-57-38-38-57-14-66v-70h-56q-34 0-66-14t-57-38-38-57-13-67V174q0-47 23-87T109 23 196 0h412m175 244H426q-46 0-86 22T278 328t-26 85v348H608q47 0 86-22t63-62 25-85l1-348m-269 318q18 0 31 13t13 31-13 31-31 13-31-13-13-31 13-31 31-13m0-212q13 0 22 9t11 22v125q0 14-9 23t-22 10-23-7-11-22l-1-126q0-13 10-23t23-10z M896 811l-128 0c-23 0-43-19-43-43 0-23 19-43 43-43l107 0c13 0 21-9 21-21L896 107c0-13-9-21-21-21L448 85c-13 0-21 9-21 21l0 21c0 23-19 43-43 43-23 0-43-19-43-43L341 85c0-47 38-85 85-85l469 0c47 0 85 38 85 85l0 640C981 772 943 811 896 811zM683 299l0 640c0 47-38 85-85 85L128 1024c-47 0-85-38-85-85L43 299c0-47 38-85 85-85l469 0C644 213 683 252 683 299zM576 299 149 299c-13 0-21 9-21 21l0 597c0 13 9 21 21 21l427 0c13 0 21-9 21-21L597 320C597 307 589 299 576 299z + M339 297c17-23 25-51 25-83 2-42-12-79-43-108S255 62 215 65c-32 0-60 8-84 25s-42 39-54 67-15 56-10 86 19 55 40 76c21 21 47 35 76 41v303c-30 6-55 20-76 41-21 21-35 47-40 76-5 30-2 58 10 86s30 50 54 67 52 25 83 25 59-8 84-25c25-17 45-39 57-67 6-19 10-39 10-61 0-30-8-57-25-83-21-34-52-55-92-64v-299l25-6c28-13 50-32 67-57zm-45 471c8 15 12 30 11 46-1 16-6 31-16 46-10 15-23 25-40 32s-34 8-51 5-32-11-46-24-22-28-24-45c-6-28-1-53 18-75s41-33 68-33c17 0 32 4 46 13 14 8 25 20 33 35zM167 288c-15-11-26-24-33-41-7-17-10-34-6-51 3-17 11-32 24-45s28-21 46-25 36-3 53 5c17 7 30 18 40 32 10 14 15 29 16 46 1 17-3 33-11 48-8 15-20 27-33 35-14 8-29 13-46 13s-33-5-48-16zm615 45c2-19-1-38-10-57-11-28-29-50-54-67s-53-25-83-25h-111l76-76-45-41-127 127v41l127 127 45-41-76-76h111c23 0 44 8 62 25 18 17 27 38 27 64v89h57V332zm0 449H960v-61H782V542h-61v178H543v61h178v178h61V781z M280 145l243 341 0-0 45 63-0 0 79 110a143 143 0 11-36 75l-88-123-92 126c1 4 1 9 1 13l0 5a143 143 0 11-36-95l82-113L221 188l60-43zm473 541a70 70 0 100 140 70 70 0 000-140zm-463 0a70 70 0 100 140 70 70 0 000-140zM772 145l59 43-232 319-45-63L772 145z + M853 137h-102V102h-68v34H341V102h-68v34h-102C133 137 102 167 102 205v68h819V205c0-38-31-68-68-68zM102 341v512C102 891 133 922 171 922h683C891 922 922 891 922 853v-512H102zM444 819h-68V512H307v-68h137V819zm273-273a102 102 0 01-30 72l-97 97 0 0a34 34 0 00-10 24v12H717V819H512v-80a102 102 0 0130-72l97-97a34 34 0 0010-24h0C649 527 633 512 614 512s-34 15-34 34H512c0-57 46-102 102-102s102 46 102 102h-0 0z M128 183C128 154 154 128 183 128h521c30 0 55 26 55 55v38c0 17-17 34-34 34s-34-17-34-34v-26H196v495h26c17 0 34 17 34 34s-17 34-34 34h-38c-30 0-55-26-55-55V183zM380 896h-34c-26 0-47-21-47-47v-90h68V828h64V896H380c4 0 0 0 0 0zM759 828V896h90c26 0 47-21 47-47v-90h-68V828h-68zM828 435H896V346c0-26-21-47-47-47h-90v68H828v68zM435 299v68H367V439H299V346C299 320 320 299 346 299h90zM367 649H299v-107h68v107zM546 367V299h107v68h-107zM828 546H896v107h-68v-107zM649 828V896h-107v-68h107zM730 508v188c0 17-17 34-34 34h-188c-17 0-34-17-34-34s17-34 34-34h102l-124-124c-13-13-13-34 0-47 13-13 34-13 47 0l124 124V512c0-17 17-34 34-34 21-4 38 9 38 30z M889 0H135c-32 0-59 26-59 59v906c0 32 26 59 59 59h753c32 0 59-26 59-59v-906c1-33-26-59-58-59zm-165 177c31 0 56 25 56 56s-25 56-56 56-56-25-56-56 25-56 56-56zm-212 0c31 0 56 25 56 56S543 288 512 288s-56-25-56-56S481 177 512 177zm-212 0c31 0 56 25 56 56s-25 56-56 56-56-25-56-56 25-56 56-56zm209 606H285c-25 0-44-20-44-44 0-25 20-44 44-44h224c25 0 44 20 44 44 0 24-20 44-44 44zm230-212H285c-25 0-44-20-44-44 0-25 20-44 44-44h453c25 0 44 20 44 44 1 24-20 44-44 44z M854 307 611 73c-6-6-14-9-22-9H296c-4 0-8 4-8 8v56c0 4 4 8 8 8h277l219 211V824c0 4 4 8 8 8h56c4 0 8-4 8-8V330c0-9-4-17-10-23zM553 201c-6-6-14-9-23-9H192c-18 0-32 14-32 32v704c0 18 14 32 32 32h512c18 0 32-14 32-32V397c0-9-3-17-9-23L553 201zM568 753c0 4-3 7-8 7h-225c-4 0-8-3-8-7v-42c0-4 3-7 8-7h225c4 0 8 3 8 7v42zm0-220c0 4-3 7-8 7H476v85c0 4-3 7-7 7h-42c-4 0-7-3-7-7V540h-85c-4 0-8-3-8-7v-42c0-4 3-7 8-7H420v-85c0-4 3-7 7-7h42c4 0 7 3 7 7V484h85c4 0 8 3 8 7v42z @@ -36,6 +42,7 @@ 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 @@ -46,21 +53,22 @@ 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 - M505 74c-145 3-239 68-239 68-12 8-15 25-7 37 9 13 25 15 38 6 0 0 184-136 448 2 12 7 29 3 36-10 8-13 3-29-12-37-71-38-139-56-199-63-23-3-44-3-65-3m17 111c-254-3-376 201-376 201-8 12-5 29 7 37 12 8 29 4 39-10 0 0 103-178 329-175 226 3 325 173 325 173 8 12 24 17 37 9 14-8 17-24 9-37 0 0-117-195-370-199m-31 106c-72 5-140 31-192 74C197 449 132 603 204 811c5 14 20 21 34 17 14-5 21-20 16-34-66-191-7-316 79-388 84-69 233-85 343-17 54 34 96 93 118 151 22 58 20 114 3 141-18 28-54 38-86 30-32-8-58-31-59-80-1-73-58-118-118-125-57-7-123 24-140 92-32 125 49 302 238 361 14 4 29-3 34-17 4-14-3-29-18-34-163-51-225-206-202-297 10-41 46-55 84-52 37 4 69 26 69 73 2 70 48 117 100 131 52 13 112-3 144-52 33-50 28-120 3-188-26-68-73-136-140-178a356 356 0 00-213-52m15 104v0c-76 3-152 42-195 125-56 106-31 215 7 293 38 79 90 131 90 131 10 11 27 11 38 0s11-26 0-38c0 0-46-47-79-116s-54-157-8-244c48-90 133-111 208-90 76 22 140 88 138 186-2 15 9 28 24 29 15 1 27-10 29-27 3-122-79-210-176-239a246 246 0 00-75-9m9 213c-15 0-26 13-26 27 0 0 1 63 36 124 36 61 112 119 244 107 15-1 26-13 25-28-1-15-14-26-30-25-116 11-165-33-193-81-28-47-29-98-29-98a27 27 0 00-27-27z - m211 611a142 142 0 00-90-4v-190a142 142 0 0090-4v198zm0 262v150h-90v-146a142 142 0 0090-4zm0-723a142 142 0 00-90-4v-146h90zm-51 246a115 115 0 11115-115 115 115 0 01-115 115zm0 461a115 115 0 11115-115 115 115 0 01-115 115zm256-691h563v90h-563zm0 461h563v90h-563zm0-282h422v90h-422zm0 474h422v90h-422z + 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 + 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 - 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 + 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 @@ -85,12 +93,14 @@ M908 366h-25V248a18 18 0 00-0-2 20 20 0 00-5-13L681 7 681 7a19 19 0 00-4-3c-0-0-1-1-1-1a29 29 0 00-4-2L671 1a24 24 0 00-5-1H181a40 40 0 00-40 40v326h-25c-32 0-57 26-57 57v298c0 32 26 57 57 57h25v204c0 22 18 40 40 40H843a40 40 0 0040-40v-204h25c32 0 57-26 57-57V424a57 57 0 00-57-57zM181 40h465v205c0 11 9 20 20 20h177v101H181V40zm413 527c0 89-54 143-134 143-81 0-128-61-128-138 0-82 52-143 132-143 84 0 129 63 129 138zm-440 139V433h62v220h108v52h-170zm690 267H181v-193H843l0 193zm18-280a305 305 0 01-91 15c-50 0-86-12-111-37-25-23-39-59-38-99 0-90 66-142 155-142 35 0 62 7 76 13l-13 49c-15-6-33-12-63-12-51 0-90 29-90 88 0 56 35 89 86 89 14 0 25-2 30-4v-57h-42v-48h101v143zM397 570c0 53 25 91 66 91 42 0 65-40 65-92 0-49-23-91-66-91-42 0-66 40-66 93z M192 192m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM192 512m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM192 832m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM864 160H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 480H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 800H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32z M824 645V307c0-56-46-102-102-102h-102V102l-154 154 154 154V307h102v338c-46 20-82 67-82 123 0 72 61 133 133 133 72 0 133-61 133-133 0-56-36-102-82-123zm-51 195c-41 0-72-31-72-72s31-72 72-72c41 0 72 31 72 72s-31 72-72 72zM384 256c0-72-61-133-133-133-72 0-133 61-133 133 0 56 36 102 82 123v266C154 666 118 712 118 768c0 72 61 133 133 133 72 0 133-61 133-133 0-56-36-102-82-123V379C348 358 384 312 384 256zM323 768c0 41-31 72-72 72-41 0-72-31-72-72s31-72 72-72c41 0 72 31 72 72zM251 328c-41 0-72-31-72-72s31-72 72-72c41 0 72 31 72 72s-31 72-72 72z + M688 544a48 48 0 110-96 48 48 0 010 96M512 544a48 48 0 110-96 48 48 0 010 96m-176 0a48 48 0 11-0-96A48 48 0 01336 544M805 192H219C186 192 160 219 160 253v471c0 34 26 61 59 61h189c6 0 16 6 18 11l38 72 3 5c11 14 27 23 44 23h0c17 0 33-8 45-23l41-77c2-5 12-11 18-11h190c32 0 59-27 59-61V253C864 219 838 192 805 192 + M544 150H418v-127h127v126zM799 530h-635c-35 0-64-29-64-64v-180c0-35 29-64 64-64h637l119 151-121 157zm-196-210H358v114h247v-114zM416 1021h127v-420H417v420z M0 512M1024 512M512 0M512 1024M64 576h896V448H64z M896 64H128C96 64 64 96 64 128v768c0 32 32 64 64 64h768c32 0 64-32 64-64V128c0-32-32-64-64-64z m-64 736c0 16-17 32-32 32H224c-18 0-32-12-32-32V224c0-16 16-32 32-32h576c15 0 32 16 32 32v576zM512 384c-71 0-128 57-128 128s57 128 128 128 128-57 128-128-57-128-128-128z M0 512M1024 512M512 0M512 1024M813 448c-46 0-83 37-83 83 0 46 37 83 83 83 46 0 83-37 83-83 0-46-37-83-83-83zM211 448C165 448 128 485 128 531c0 46 37 83 83 83 46 0 83-37 83-83 0-46-37-83-83-83zM512 448c-46 0-83 37-83 83 0 46 37 83 83 83 46 0 83-37 83-83C595 485 558 448 512 448z - M299 811 299 725 384 725 384 811 299 811M469 811 469 725 555 725 555 811 469 811M640 811 640 725 725 725 725 811 640 811M299 640 299 555 384 555 384 640 299 640M469 640 469 555 555 555 555 640 469 640M640 640 640 555 725 555 725 640 640 640M299 469 299 384 384 384 384 469 299 469M469 469 469 384 555 384 555 469 469 469M640 469 640 384 725 384 725 469 640 469M299 299 299 213 384 213 384 299 299 299M469 299 469 213 555 213 555 299 469 299M640 299 640 213 725 213 725 299 640 299Z M64 363l0 204 265 0L329 460c0-11 6-18 14-20C349 437 355 437 362 441c93 60 226 149 226 149 33 22 34 60 0 82 0 0-133 89-226 149-14 9-32-3-32-18l-1-110L64 693l0 117c0 41 34 75 75 75l746 0c41 0 75-34 75-74L960 364c0-0 0-1 0-1L64 363zM64 214l0 75 650 0-33-80c-16-38-62-69-103-69l-440 0C97 139 64 173 64 214z M683 409v204L1024 308 683 0v191c-413 0-427 526-427 526c117-229 203-307 427-307zm85 492H102V327h153s38-63 114-122H51c-28 0-51 27-51 61v697c0 34 23 61 51 61h768c28 0 51-27 51-61V614l-102 100v187z + 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 @@ -102,8 +112,7 @@ M592 768h-160c-27 0-48-21-48-48V384h-175c-36 0-53-43-28-68L485 11c15-15 40-15 55 0l304 304c25 25 7 68-28 68H640v336c0 27-21 48-48 48zm432-16v224c0 27-21 48-48 48H48c-27 0-48-21-48-48V752c0-27 21-48 48-48h272v16c0 62 50 112 112 112h160c62 0 112-50 112-112v-16h272c27 0 48 21 48 48zm-248 176c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40zm128 0c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40z M563 555c0 28-23 51-51 51-28 0-51-23-51-51L461 113c0-28 23-51 51-51s51 23 51 51L563 555 563 555zM85 535c0-153 81-287 201-362 24-15 55-8 70 16C371 214 363 245 340 260 248 318 187 419 187 535c0 180 146 325 325 325 180-0 325-146 325-325 0-119-64-223-160-280-24-14-32-46-18-70 14-24 46-32 70-18 125 74 210 211 210 367 0 236-191 427-427 427C276 963 85 772 85 535 M277 85a149 149 0 00-43 292v230a32 32 0 0064 0V555h267A160 160 0 00725 395v-12a149 149 0 10-64-5v17a96 96 0 01-96 96H299V383A149 149 0 00277 85zM228 720a32 32 0 00-37-52 150 150 0 00-53 68 32 32 0 1060 23 85 85 0 0130-39zm136-52a32 32 0 00-37 52 86 86 0 0130 39 32 32 0 1060-23 149 149 0 00-53-68zM204 833a32 32 0 10-55 32 149 149 0 0063 58 32 32 0 0028-57 85 85 0 01-36-33zm202 32a32 32 0 00-55-32 85 85 0 01-36 33 32 32 0 0028 57 149 149 0 0063-58z - M467 556c0-0 0-1 0-1C467 555 467 556 467 556zM467 556c0-0 0-0 0-0C467 556 467 556 467 556zM467 556c-0 0-0 0-0 0C467 557 467 557 467 556zM468 549C468 532 468 541 468 549L468 549zM468 549c0 1-0 1-0 2C468 551 468 550 468 549zM468 552c-0 1-0 2-0 3C467 554 468 553 468 552zM736 549C736 532 736 541 736 549L736 549zM289 378l0 179 89 0c-1 80-89 89-89 89l45 45c0 0 129-15 134-134L467 378 289 378zM959 244l0 536c0 99-80 179-179 179L244 959c-99 0-179-80-179-179L65 244c0-99 80-179 179-179l536 0C879 65 959 145 959 244zM869 289c0-74-60-134-134-134L289 155c-74 0-134 60-134 134l0 447c0 74 60 134 134 134l447 0c74 0 134-60 134-134L869 289zM557 557l89 0c-1 80-89 89-89 89l45 45c0 0 129-15 134-134L735 378 557 378 557 557z - m224 154a166 166 0 00-166 166v192a166 166 0 00166 166h64v-76h-64a90 90 0 01-90-90v-192a90 90 0 0190-90h320a90 90 0 0190 90v192a90 90 0 01-90 90h-128v77h128a166 166 0 00166-167v-192a166 166 0 00-166-166h-320zm166 390a90 90 0 0190-90h128v-76h-128a166 166 0 00-166 166v192a166 166 0 00166 166h320a166 166 0 00166-166v-192a166 166 0 00-166-166h-64v77h64a90 90 0 0190 90v192a90 90 0 01-90 90h-320a90 90 0 01-90-90v-192z + 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 @@ -113,19 +122,21 @@ 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 - M653 435l-26 119H725c9 0 13 4 13 13v47c0 9-4 13-13 13h-107l-21 115c0 9-4 13-13 13h-47c-9 0-13-4-13-13l21-111H427l-21 115c0 9-4 13-13 13H346c-9 0-13-4-13-13l21-107h-85c-4-9-9-21-13-34v-38c0-9 4-13 13-13h98l26-119H294c-9 0-13-4-13-13V375c0-9 4-13 13-13h115l13-81c0-9 4-13 13-13h43c9 0 13 4 13 13L469 363h119l13-81c0-9 4-13 13-13h47c9 0 13 4 13 13l-13 77h85c9 0 13 4 13 13v47c0 9-4 13-13 13h-98v4zM512 0C230 0 0 230 0 512c0 145 60 282 166 375L90 1024H512c282 0 512-230 512-512S794 0 512 0zm-73 559h124l26-119h-128l-21 119z + 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 @@ -148,7 +159,7 @@ M153 154h768v768h-768v-768zm64 64v640h640v-640h-640z M796 231v727H64V231h732zm-82 78H146V880h567V309zM229 66H960v732H796v-82h82V148h-567v82h-82V66z M248 221a77 77 0 00-30-21c-18-7-40-10-68-5a224 224 0 00-45 13c-5 2-10 5-15 8l-3 2v68l11-9c10-8 21-14 34-19 13-5 26-7 39-7 12 0 21 3 28 10 6 6 9 16 9 29l-62 9c-14 2-26 6-36 11a80 80 0 00-25 20c-7 8-12 17-15 27-6 21-6 44 1 65a70 70 0 0041 43c10 4 21 6 34 6a80 80 0 0063-28v22h64V298c0-16-2-31-6-44a91 91 0 00-18-33zm-41 121v15c0 8-1 15-4 22a48 48 0 01-24 29 44 44 0 01-33 2 29 29 0 01-10-6 25 25 0 01-6-9 30 30 0 01-2-12c0-5 1-9 2-14a21 21 0 015-9 28 28 0 0110-7 83 83 0 0120-5l42-6zm323-68a144 144 0 00-16-42 87 87 0 00-28-29 75 75 0 00-41-11 73 73 0 00-44 14c-6 5-12 11-17 17V64H326v398h59v-18c8 10 18 17 30 21 6 2 13 3 21 3 16 0 31-4 43-11 12-7 23-18 31-31a147 147 0 0019-46 248 248 0 006-57c0-17-2-33-5-49zm-55 49c0 15-1 28-4 39-2 11-6 20-10 27a41 41 0 01-15 15 37 37 0 01-36 1 44 44 0 01-13-12 59 59 0 01-9-18A76 76 0 01384 352v-33c0-10 1-20 4-29 2-8 6-15 10-22a43 43 0 0115-13 37 37 0 0119-5 35 35 0 0132 18c4 6 7 14 9 23 2 9 3 20 3 31zM154 634a58 58 0 0120-15c14-6 35-7 49-1 7 3 13 6 20 12l21 17V572l-6-4a124 124 0 00-58-14c-20 0-38 4-54 11-16 7-30 17-41 30-12 13-20 29-26 46-6 17-9 36-9 57 0 18 3 36 8 52 6 16 14 30 24 42 10 12 23 21 38 28 15 7 32 10 50 10 15 0 28-2 39-5 11-3 21-8 30-14l5-4v-57l-13 6a26 26 0 01-5 2c-3 1-6 2-8 3-2 1-15 6-15 6-4 2-9 3-14 4a63 63 0 01-38-4 53 53 0 01-20-14 70 70 0 01-13-24 111 111 0 01-5-34c0-13 2-26 5-36 3-10 8-19 14-26zM896 384h-256V320h288c21 1 32 12 32 32v384c0 18-12 32-32 32H504l132 133-45 45-185-185c-16-21-16-25 0-45l185-185L637 576l-128 128H896V384z - 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/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/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/Locales/de_DE.axaml b/src/Resources/Locales/de_DE.axaml index 4f7fd7c62..57a344c63 100644 --- a/src/Resources/Locales/de_DE.axaml +++ b/src/Resources/Locales/de_DE.axaml @@ -5,146 +5,172 @@ Info Über SourceGit - 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 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 Was auschecken: Neuen Branch erstellen Existierender Branch - OpenAI Assistent - Neu generieren - Verwende OpenAI, um Commit-Nachrichten zu generieren - Als Commit-Nachricht verwenden - Patch - Patch-Datei: + 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 + Ignoriere Leerzeichen-Änderungen Patch anwenden Leerzeichen: Stash anwenden Nach dem Anwenden löschen Änderungen des Index wiederherstellen Stash: - Archivieren... + 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 - Bild laden... + Bild laden… Aktualisieren BINÄRE DATEI WIRD NICHT UNTERSTÜTZT!!! Bisect Abbrechen Schlecht - Bisecting. Ist der aktuelle HEAD gut oder schlecht? Gut Überspringen - Bisecting. Aktuellen Commit als gut oder schlecht markieren und einen anderen auschecken. + 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 ${0}$ 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 + PR erstellen… + PR für Upstream ${0}$ erstellen… Benutzerdefinierte Aktion - Lösche ${0}$... - Lösche alle ausgewählten {0} Branches + 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 ${0}$ zurück auf ${1}$... - Setze verfolgten Branch... - Branch Vergleich - Ungültiger Upstream! + Rebase ${0}$ auf ${1}$… + Benenne ${0}$ um… + Setze ${0}$ zurück auf ${1}$… + Zu ${0}$ wechseln (Worktree) + Setze verfolgten Branch… + {0} Commit(s) voraus + {0} Commit(s) voraus, {1} Commit(s) zurück + {0} Commit(s) zurück + Ungültig + REMOTE + STATUS + VERFOLGT + URL + WORKTREE ABBRECHEN Auf Vorgänger-Revision zurücksetzen Auf diese Revision zurücksetzen Generiere Commit-Nachricht - 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 - Commit: - Warnung: Beim Auschecken eines Commits wird dein HEAD losgelöst (detached) sein! Lokale Änderungen: - Verwerfen - Stashen & wieder anwenden - Alle Submodule updaten Branch: - Dein aktueller HEAD enthält Commit(s) ohne Verbindung zu einem Branch/Tag. Möchtest du trotzdem fortfahren? - Auschecken & Fast-Forward - Fast-Forward zu: + 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: + Übergeordnetes Verzeichnis: Submodule initialisieren und aktualisieren - Repository URL: + Repository-URL: SCHLIESSEN Editor Commit auschecken Diesen Commit cherry-picken - Mehrere cherry-picken - Mit HEAD vergleichen - Mit Worktree vergleichen - Author + Mehrere cherry-picken… + Vergleiche mit HEAD + Vergleiche mit Worktree + Autor Commit-Nachricht Committer SHA Betreff Benutzerdefinierte Aktion - Merge in ${0}$ hinein - Merge ... + 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 + Als Patch speichern… ÄNDERUNGEN geänderte Datei(en) - Änderungen durchsuchen... + Änderungen durchsuchen… DATEIEN - LFS DATEI - Dateien durchsuchen... - Submodule + LFS-DATEI + Dateien durchsuchen… + Submodul INFORMATION AUTOR - NACHFOLGER 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 @@ -152,98 +178,117 @@ SHA Unterzeichner: Im Browser öffnen - Details - Betreff - Commit-Nachricht - Repository Einstellungen - COMMIT TEMPLATE - Du kannst ${files_num}, ${branch_name}, ${files} und ${files:N} verwenden, wobei N die maximale Anzahl an auszugebenden Dateipfaden ist. - Template Inhalt: - Template Name: + Commit-Nachricht eingeben. Leerzeile zum Trennen von Betreff und Beschreibung verwenden! + BETREFF + Vergleichen + Repository-Einstellungen + COMMIT-VORLAGE + Vordefinierte Parameter: + +${branch_name} Name des aktuellen lokalen Branches. +${files_num} Anzahl der geänderten Dateien +${files} Pfade der geänderten Dateien +${files:N} Maximale Anzahl N an Pfaden geänderter Dateien +${pure_files} Wie ${files}, aber nur die reinen Dateinamen +${pure_files:N} Wie ${files:N}, aber ohne Ordner + Vorlageninhalt: + Vorlagenname: BENUTZERDEFINIERTE AKTION Argumente: - Vordefinierte Parameter: ${REPO} - Repository Pfad; ${BRANCH} - selektierter Branch; ${SHA} - Hash des selektierten Commits; ${TAG} - selektiertes Tag + 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 - $1, $2 ... können als Argumente in Eingabe-Steuerelementen benutzt werden Name: Geltungsbereich: Branch Commit + Datei + Remote Repository Tag Auf Beenden der Aktion warten - Email Adresse - Email Adresse + E-Mail-Adresse + E-Mail-Adresse GIT + Vor dem Auto-Aktualisieren von Submodulen fragen Remotes automatisch fetchen Minute(n) - Standard Remote - Bevorzugter Merge Modus + Konventionelle Commit-Typen + Standard-Remote + Bevorzugter Merge-Modus TICKETSYSTEM - Beispiel für Azure DevOps Regel 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 Azure-DevOps-Regel hinzufügen + Beispiel für Gerrit-Change-ID hinzufügen + Beispiel für Gitee-Issue-Regel hinzufügen + Beispiel für Gitee-Pull-Request-Regel hinzufügen + Beispiel für GitHub-Regel hinzufügen + Beispiel für Gitlab-Issue-Regel hinzufügen + Beispiel für Gitlab-Merge-Request-Regel hinzufügen Beispiel für Jira-Regel hinzufügen Neue Regel - 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 Bearbeite Steuerelemente für benutzerdefinierte Aktionen Wert bei Markierung: - Wenn markiert, wird dieser Wert in Kommandozeilen-Argumenten benutzt + 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 + Nutze ‚|‘, um Einträge zu trennen + Die vordefinierten Parameter ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE} und ${TAG} bleiben hier verwendbar Typ: - Arbeitsplätze + Arbeitsumgebungen Farbe Name - Zuletzt geöffnete Tabs beim Starten wiederherstellen + 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 App neustarten, um Änderungen zu übernehmen. + 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 Gesamten Text kopieren Ganzen Pfad kopieren Pfad kopieren - Branch erstellen... + Branch erstellen… Basierend auf: Erstellten Branch auschecken Lokale Änderungen: - Verwerfen - Stashen & wieder anwenden Neuer Branch-Name: - Branch-Name - Leerzeichen werden durch Bindestriche ersetzt. + Einen Branch-Namen eingeben Lokalen Branch erstellen Überschreibe existierenden Branch - Tag erstellen... + Tag erstellen… Neuer Tag auf: Mit GPG signieren Anmerkung: @@ -251,70 +296,80 @@ 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 - De-initialisiere Submodul - Erzwinge De-Initialisierung, selbst wenn es lokale Änderungen enthält. + 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: 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 + 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 - Dateimodus geändert - Erste Differenz - Ignoriere Leerzeichenänderungen - Überblenden - Nebeneinander - Wischen - Letzte Differenz - LFS OBJEKT ÄNDERUNG + Erster Unterschied + Ignoriere Leerzeichen-Änderungen + ÜBEREINANDER + DIFFERENZ + NEBENEINANDER + WISCHEN + Letzter Unterschied + LFS-OBJEKT-ÄNDERUNG NEU - Nächste Änderung - KEINE ÄNDERUNG ODER NUR ZEILEN-ENDE ÄNDERUNGEN + Nächster Unterschied + KEINE ÄNDERUNG ODER NUR ZEILENENDE-ÄNDERUNGEN ALT - Vorherige Änderung + Vorheriger Unterschied Als Patch speichern Zeige versteckte Symbole Nebeneinander SUBMODUL GELÖSCHT NEU - Seiten wechseln - Syntax Hervorhebung + Seiten tauschen + Syntax-Hervorhebung Zeilenumbruch - Aktiviere Block-Navigation - Ö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 + 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: @@ -324,117 +379,117 @@ Dieses Repository Fetch Alle Remotes fetchen - Aktiviere '--force' Option + Überschreiben lokaler Refs erzwingen Ohne Tags fetchen Remote: Remote-Änderungen fetchen Als unverändert betrachten - Verwerfen... - Verwerfe {0} Dateien... + Benutzerdefinierte Aktion + Verwerfen… + Verwerfe {0} Dateien… Löse mit ${0}$ - Als Patch speichern... + Als Patch speichern… Stagen - {0} Dateien stagen - Stash... - {0} Dateien stashen... - Unstage + Stage {0} Dateien + Stashen… + Stashe {0} Dateien… + Unstagen {0} Dateien unstagen - "Meine" verwenden (checkout --ours) - "Ihre" verwenden (checkout --theirs) - Datei Historie - ÄNDERUNGEN + Meine verwenden (checkout --ours) + Deren verwenden (checkout --theirs) + Dateiverlauf + ÄNDERUNG INHALT - Git-Flow + Git Flow Development-Branch: Feature: - Feature-Prefix: - FLOW - Feature abschließen - FLOW - Hotfix abschließen - FLOW - Release abschließen + Feature-Präfix: + FLOW – Feature fertigstellen + FLOW – Hotfix fertigstellen + FLOW – Release fertigstellen Ziel: - Push zu Remote(s) nach Abschluss 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 - Führt `git lfs fetch` aus um Git LFS Objekte herunterzuladen. Das aktualisiert nicht die Arbeitskopie. - LFS Objekte fetchen - 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 vom lokalen Speicher zu löschen + Führt `git lfs prune` aus, um alte LFS-Dateien aus dem lokalen Speicher zu löschen Pull - Führt `git lfs pull` aus um alle Git LFS Dasteien für aktuellen Ref & Checkout herunterzuladen - LFS Objekte pullen + Führt `git lfs pull` aus, um alle Git-LFS-Dateien für aktuellen Ref & Checkout herunterzuladen + LFS-Objekte pullen Push - Pushe große Dateien in der Warteschlange zum Git LFS Endpunkt - LFS Objekte pushen + 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 + VERLAUF AUTOR - AUTOR ZEITPUNKT + 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 Klone neues Repository - Aktuellen Tab schließen - Zum nächsten Tab wechseln - Zum vorherigen Tab wechseln - Neuen Tab erstellen + Aktuelle Registerkarte schließen + Zur nächsten Registerkarte wechseln + Zur vorherigen Registerkarte wechseln + Neue Registerkarte erstellen Einstellungen öffnen - Aktiven Arbeitsplatz wechseln - Aktiven Tab wechseln + Auswahlmenü für Arbeitsumgebungen anzeigen + Aktive Registerkarte wechseln + Herein- / herauszoomen REPOSITORY Gestagte Änderungen committen Gestagte Änderungen committen und pushen Alle Änderungen stagen und committen Fetch, wird direkt ausgeführt - Dashboard Modus (Standard) + Dashboard-Modus (Standard) + Springe zum Vorgänger des ausgewählten Commits + Befehlspalette öffnen Commit-Suchmodus Pull, wird direkt ausgeführt Push, wird direkt ausgeführt Erzwinge Neuladen des Repositorys - Wechsle zu 'Änderungen' - Wechsle zu 'Verlauf' - Wechsle zu 'Stashes' - TEXTEDITOR - Suchpanel schließen - Suche nächste Übereinstimmung - Suche vorherige Übereinstimmung - Öffne mit externem Diff/Merge Tool - Öffne Suchpanel + Wechsle zu ‚Änderungen‘ + Wechsle zu ‚Verlauf‘ + Wechsle zu ‚Stashes‘ Verwerfen Stagen Unstagen @@ -452,119 +507,147 @@ Lokale Änderungen stashen & wieder anwenden Auf: Drag & Drop zum Anordnen der Commits - Ziel Branch: + Ziel-Branch: Link kopieren - In Browser öffnen + Im Browser öffnen + Befehle FEHLER INFO - Arbeitsplätze - Tabs + Repositorys öffnen + Registerkarten + Arbeitsumgebungen Branch mergen Merge-Nachricht anpassen Ziel-Branch: - Merge Option: + 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: - Verschiebe Repository Knoten + 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 in Merge Tool - Ö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 Vor {0} Tagen Vor 1 Stunde Vor {0} Stunden Gerade eben Letzter Monat - Leztes Jahr + Letztes Jahr Vor {0} Minuten Vor {0} Monaten Vor {0} Jahren Gestern - Verwende 'Shift+Enter' um eine neue Zeile einzufügen. 'Enter' ist das Kürzel für den OK Button Einstellungen - OPEN AI - System-Prompt für Diff Analyse - API Schlüssel - System-Prompt für Erstellung von Commit-Nachricht - Modell + AI + API-Schlüssel Name + Der eingegebene Wert ist der Name der Umgebungsvariable, aus der der API-Schlüssel gelesen wird Server - Streaming aktivieren DARSTELLUNG Standardschriftart - Editor Tab Breite + Editor-Tab-Breite Schriftgröße Standard Texteditor - Monospace-Schriftart - Verwende die Monospace-Schriftart nur im Texteditor + Festbreiten-Schriftart Design Design-Anpassungen - Fixe Tab-Breite in Titelleiste - Verwende nativen Fensterrahmen - DIFF/MERGE TOOL + 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 + Pfad zum Diff- / Merge-Tool Tool ALLGEMEIN - Beim Starten nach Updates suchen + Beim Programmstart nach Aktualisierungen suchen Datumsformat + Aktiviere kompakte Ordner im Änderungsbaum Sprache Commit-Historie - Zeige Autor Zeitpunkt anstatt Commit Zeitpunkt - Zeige Nachfolger in den Commit Details - Zeige Tags im Commit Verlauf + 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 Email - Globale Git Benutzer Email + Benutzer-E-Mail + Globale Git Benutzer-E-Mail Aktiviere --prune beim Fetchen - Aktiviere --ignore-cr-at-eol beim Diff - Diese App setzt Git (>= 2.25.1) voraus + Aktiviere --ignore-cr-at-eol für Text-Diff + Dieses Programm setzt Git (>= 2.25.1) voraus Installationspfad - Aktiviere HTTP SSL Verifizierung + Aktiviere HTTP-SSL-Verifizierung + git-credential-libsecret statt git-credential-manager verwenden Benutzername - Globaler Git Benutzername - Git Version - GPG SIGNIERUNG + Globaler Git-Benutzername + Git-Version + GPG-SIGNIERUNG Commit-Signierung - GPG Format - GPG Installationspfad - Pfad zum GPG Programm + GPG-Format + GPG-Installationspfad + Pfad zum GPG-Programm Tag-Signierung - Benutzer Signierungsschlüssel - GPG Benutzer Signierungsschlüssel + Benutzer-Signierungsschlüssel + GPG-Signierungsschlüssel des Benutzers INTEGRATIONEN - SHELL/TERMINAL + SHELL / TERMINAL + Argumente + Bitte verwende ‚.‘ für das aktuelle Verzeichnis Pfad - Shell/Terminal - Remote löschen + Shell / Terminal + Remote bereinigen Ziel: - Worktrees löschen - Worktree Informationen in `$GIT_COMMON_DIR/worktrees` löschen + Worktrees bereinigen + Worktree-Informationen in `$GIT_COMMON_DIR/worktrees` bereinigen Pull Remote-Branch: Lokaler Branch: Lokale Änderungen: - Verwerfen - Stashen & wieder anwenden - Alle Submodule aktualisieren Remote: Pull (Fetch & Merge) Rebase anstatt Merge verwenden @@ -584,6 +667,8 @@ 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 @@ -591,12 +676,13 @@ 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 @@ -608,21 +694,22 @@ 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 Löschen - Repository Einstellungen + Repository-Einstellungen WEITER Benutzerdefinierte Aktionen Keine benutzerdefinierten Aktionen + Dashboard Alle Änderungen verwerfen Öffne im Datei-Browser - Suche Branches/Tags/Submodule + Suche Branches / Tags / Submodule Sichtbarkeit im Verlauf Aufheben Im Verlauf ausblenden @@ -630,99 +717,106 @@ LAYOUT Horizontal Vertikal - COMMIT SORTIERUNG - Commit Zeitpunkt - Topologie + COMMIT-SORTIERUNG + Commit-Zeitpunkt + Topologisch LOKALE BRANCHES + Mehr Optionen… Zum HEAD wechseln Erstelle Branch BENACHRICHTIGUNGEN LÖSCHEN - Nur aktuellen Branch hervorheben + Als Ordner öffnen Öffne in {0} Öffne in externen Tools REMOTES - REMOTE HINZUFÜGEN + Remote hinzufügen + AUFLÖSEN Commit suchen Autor - Committer Inhalt Commit-Nachricht Pfad SHA Aktueller Branch + Nur dekorierte Commits Nur ersten Vorgänger anzeigen + ANZEIGE-FLAGS Verlorene Commits anzeigen - Submodule als Baum anzeigen - Zeige Tags als Baum + 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 Namen - Sortiere + Nach Name + Sortieren Öffne im Terminal - Verwende relative Zeitangeben Logs ansehen - Öffne '{0}' im Browser + Ö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: - Reset Branch (ohne Checkout) + Branch zurücksetzen (ohne Checkout) Auf: Branch: - Zeige im Datei-Explorer + Zeigen im Datei-Explorer Commit rückgängig machen Commit: - Commit Änderungen rückgängig machen - Commit-Nachricht umformulieren - 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 keine Updates verfügbar. + 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-Verfolgung aufheben Upstream: SHA kopieren Zum Commit wechseln - Squash Commits - In: - SSH privater Schlüssel: - Pfad zum privaten SSH Schlüssel + Privater SSH-Schlüssel: + Pfad zum privaten SSH-Schlüssel START Stash - Inklusive nicht-verfolgter Dateien + Nicht-verfolgte Dateien einbeziehen Name: - Optional. Informationen zu diesem Stash + 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 - Als Patch speichern... + Als Patch speichern… Stash entfernen Entfernen: - Stashes + STASHES ÄNDERUNGEN STASHES Statistiken @@ -733,31 +827,45 @@ COMMITS: SUBMODULE Submodul hinzufügen - Relativen Pfad - De-initialisiere Submodul + BRANCH + Branch + Relativer Pfad + Deinitialisiere Submodul Untergeordnete Submodule fetchen - Öffne Submodul Repository + Verlauf + Verschieben nach + Öffne Submodul-Repository Relativer Pfad: - Relativer Pfad 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 + TAGGER + ZEIT + Vergleiche 2 Tags + Vergleichen mit… + Vergleichen mit HEAD + Nachricht + Name + Tagger + Namen des Tags kopieren Benutzerdefinierte Aktion - Lösche ${0}$... - Merge ${0}$ in ${1}$ hinein... - Pushe ${0}$... + Lösche ${0}$… + Lösche ausgewählte {0} Tags… + Pushe ${0}$… Submodule aktualisieren Alle Submodule - Initialisiere wenn nötig - Rekursiv + Bei Bedarf initialisieren Submodul: + Auf verfolgten Remote-Branch des Submoduls aktualisieren URL: Logs ALLES LÖSCHEN @@ -772,51 +880,57 @@ DRAG & DROP VON ORDNER UNTERSTÜTZT. BENUTZERDEFINIERTE GRUPPIERUNG UNTERSTÜTZT. Bearbeiten Verschiebe in eine andere Gruppe - Öffne alle Repositories + Öffne alle Repositorys Öffne Repository Öffne Terminal - Standard Klon-Ordner erneut nach Repositories durchsuchen - Suche Repositories... - Änderungen + Standard Klon-Ordner erneut nach Repositorys durchsuchen + Suche Repositorys… + ÄNDERUNGEN Git Ignore Ignoriere alle *{0} Dateien - Ignoriere *{0} Datein 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 - Du erzeugst einen Commit auf einem losgelösten (detached) HEAD. Möchtest du trotzdem fortfahren? + 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 - EXTERNES MERGE-TOOL ÖFFNEN + MERGE + Merge mit externem Tool ALLE KONFLIKTE IN EXTERNEM MERGE-TOOL ÖFFNEN - DATEI KONFLIKTE GELÖST + DATEI-KONFLIKTE SIND GELÖST MEINE VERSION VERWENDEN - IHRE VERSION VERWENDEN - NICHT-VERFOLGTE DATEIEN INKLUDIEREN + DEREN VERSION VERWENDEN + NICHT-VERFOLGTE DATEIEN EINBEZIEHEN KEINE BISHERIGEN COMMIT-NACHRICHTEN - KEINE COMMIT TEMPLATES + KEINE COMMIT-VORLAGEN + Ohne Überprüfung Autor zurücksetzen - SignOff + Abzeichnen GESTAGED UNSTAGEN ALLES UNSTAGEN - UNSTAGED + UNGESTAGED STAGEN ALLES STAGEN ALS UNVERÄNDERT BETRACHTETE ANZEIGEN - Template: ${0}$ - ARBEITSPLATZ: - Arbeitsplätze konfigurieren... + 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 ebc38d78f..676bd57a0 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -1,6 +1,8 @@ About About SourceGit + Release Date: {0} + Release Notes Opensource & Free Git GUI Client Add File(s) To Ignore Pattern: @@ -16,13 +18,19 @@ Create New Branch Existing Branch AI Assistant + MODEL RE-GENERATE Use AI to generate commit message - APPLY AS COMMIT MESSAGE - Patch - Patch File: + Use + Hide SourceGit + Hide Others + Show All + 3-Way Merge Select .patch file to apply Ignore whitespace changes + Source: + File + Clipboard Apply Patch Whitespace: Apply Stash @@ -44,37 +52,58 @@ Bisect Abort Bad - Bisecting. Is current HEAD good or bad? Good Skip - Bisecting. Mark current commit as good or bad and checkout another one. + 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 ${0}$ - Compare with Worktree + Compare selected 2 branches + Compare with... + Compare with HEAD + Compare with ${0}$ Copy Branch Name + Create PR... + Create PR for upstream ${0}$... Custom Action Delete ${0}$... - Delete selected {0} branches + 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}$... - Merge selected {0} branches into current - Pull ${0}$ + Merge selected {0} branches into current... + Pull ${0}$... Pull ${0}$ into ${1}$... - Push ${0}$ + Push ${0}$... Rebase ${0}$ on ${1}$... Rename ${0}$... Reset ${0}$ to ${1}$... + Switch to ${0}$ (worktree) Set Tracking Branch... - Branch Compare - Invalid upstream! + {0} commit(s) ahead + {0} commit(s) ahead, {1} commit(s) behind + {0} commit(s) behind + Invalid + REMOTE + STATUS + TRACKING + URL + WORKTREE CANCEL Reset to Parent Revision Reset to This Revision Generate commit message + Merge (Built-in) + Merge (External) + Reset File(s) to ${0}$ CHANGE DISPLAY MODE Show as File and Dir List Show as Path List @@ -83,17 +112,18 @@ Submodule: URL: Checkout Branch - Checkout Commit - Commit: - Warning: By doing a commit checkout, your Head will be detached Local Changes: - Discard - Stash & Reapply - Update all submodules Branch: 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): @@ -105,6 +135,8 @@ Clone Remote Repository Extra Parameters: Additional arguments to clone repository. Optional. + Bookmark: + Group: Local Name: Repository name. Optional. Parent Folder: @@ -112,41 +144,52 @@ Repository URL: CLOSE Editor - Checkout Commit - Cherry-Pick Commit - Cherry-Pick ... + Branches + Branches & Tags + Repository Custom Actions + Revision Files + Checkout Commit... + Cherry-Pick Commit... + Cherry-Pick... Compare with HEAD Compare with Worktree Author + Author Time Message Committer + Committer Time SHA Subject Custom Action - Interactively Rebase ${0}$ on ${1}$ - Merge to ${0}$ - Merge ... - Push ${0}$ to ${1}$ - Rebase ${0}$ on ${1}$ - Reset ${0}$ to ${1}$ - 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 Children into ${0}$ CHANGES changed file(s) Search Changes... + Collapse details FILES LFS File Search Files... Submodule INFORMATION AUTHOR - CHILDREN 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 @@ -155,46 +198,74 @@ SHA Signer: Open in Browser - Description + COL + Enter commit message. Please use an empty-line to separate subject and description! SUBJECT - Enter commit 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 - You can use ${files_num}, ${branch_name}, ${files} and ${files:N} where N is the max number of file paths to output. + 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: - Built-in parameters: ${REPO} - repository's path; ${BRANCH} - selected branch; ${SHA} - selected commit's hash; ${TAG} - selected tag + 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 - You can use $1, $2 ... in arguments for input control values Name: Scope: Branch Commit + File + Remote Repository Tag Wait for action exit Email Address Email address GIT + Ask before auto-updating submodules Fetch remotes automatically Minute(s) + Conventional Commit Types Default Remote + Enable `--recursive` when auto-updating submodules Preferred Merge Mode ISSUE TRACKER - Add Sample Azure DevOps Rule - Add Sample Gitee Issue Rule - Add Sample Gitee Pull Request Rule - Add Sample GitHub Rule - Add Sample GitLab Issue Rule - Add Sample GitLab Merge Request Rule - Add Sample Jira Rule + 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 @@ -212,8 +283,12 @@ Is Folder: Label: Options: - Use '|' as delimitter for 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 @@ -221,7 +296,8 @@ CONTINUE Empty commit detected! Do you want to continue (--allow-empty)? STAGE ALL & COMMIT - Empty commit detected! Do you want to continue (--allow-empty) or stage all then commit? + 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 @@ -233,17 +309,15 @@ Type of Change: Copy Copy All Text + Copy as Patch Copy Full Path Copy Path Create Branch... Based On: Check out the created branch Local Changes: - Discard - Stash & Reapply New Branch Name: Enter branch name. - Spaces will be replaced with dashes. Create Local Branch Overwrite existing branch Create Tag... @@ -254,21 +328,28 @@ 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: @@ -283,10 +364,11 @@ Tag: Delete from remote repositories BINARY DIFF - File Mode Changed + EMPTY FILE First Difference - Ignore All Whitespace Changes + Ignore Whitespace Changes BLEND + DIFFERENCE SIDE-BY-SIDE SWIPE Last Difference @@ -302,22 +384,29 @@ SUBMODULE DELETED NEW + + {0} Uncommitted Changes Swap Syntax Highlighting Line Word Wrap - Enable Block-Navigation Open in Merge Tool Show All Lines Decrease Number of Visible Lines Increase Number of Visible Lines SELECT FILE TO VIEW CHANGES Dir History + Has Local Changes + Mismatched with Upstream + Already Up-To-Date Discard Changes All local changes in working copy. Changes: Include ignored files + Include modified/deleted files + Include untracked files {0} changes will be discarded You can't undo this action!!! + Edit Branch's Description + Target: Bookmark: New Name: Target: @@ -332,6 +421,7 @@ Remote: Fetch Remote Changes Assume unchanged + Custom Action Discard... Discard {0} files... Resolve Using ${0}$ @@ -347,15 +437,25 @@ File History CHANGE CONTENT + File mode change: + Deleted file mode: + Directory + Executable + New file mode: + Normal + Submodule + Symlink + Unknown Git-Flow Development Branch: Feature: Feature Prefix: - FLOW - Finish Feature - FLOW - Finish Hotfix - FLOW - Finish Release + Finish ${0}$ + Finish Feature + Finish Hotfix + Finish Release Target: - Push to remote(s) after performing finish + Rebase before merging Squash during merge Hotfix: Hotfix Prefix: @@ -364,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... @@ -387,6 +489,8 @@ 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 @@ -399,49 +503,62 @@ Remote: Track files named '{0}' Track all *{0} files + 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 + SHOW COLUMNS Hold 'Ctrl' or 'Shift' to select multiple commits. Hold ⌘ or ⇧ to select multiple commits. TIPS: + Open in separate window + Commit Detail + Revision Compare Keyboard Shortcuts Reference GLOBAL - Clone new repository + Clone remote repository Close current tab Go to next tab Go to previous tab Create new tab + Open local repository Open Preferences dialog - Switch active workspace + Show workspace dropdown menu Switch active tab + Zoom in/out REPOSITORY Commit staged changes Commit and push staged changes Stage all changes and commit + Create new branch Fetch, starts directly Dashboard mode (Default) + Goto child of selected commit + Goto parent of selected commit + Open command palette Commit search mode Pull, starts directly Push, starts directly Force to reload this repository - Switch to 'Changes' - Switch to 'History' - Switch to 'Stashes' - TEXT EDITOR - Close search panel - Find next match - Find previous match - Open with external diff/merge tool - Open search panel + Expand/Collapse details panel in `HISTORY` + Switch to 'LOCAL CHANGES' + Switch to 'HISTORY' + Switch to 'STASHES' Discard Stage Unstage Initialize Repository + Do you want to run `git init` command under this path? + Open repository failed. Reason: Path: Cherry-Pick in progress. Processing commit @@ -453,20 +570,44 @@ Reverting commit Interactive Rebase Stash & reapply local changes + Bypass the pre-rebase hook On: Drag-drop to reorder commits Target Branch: Copy Link Open in Browser + Commands ERROR NOTICE - Workspaces + New Version Available + Open Repositories Tabs + Workspaces Merge Branch Customize merge message Into: Merge Option: Source: + Testing for merge... + Merge can be done without conflicts + Unknown error occurs while testing for merge + Merge will cause conflicts + First Mine, then Theirs + First Theirs, then Mine + USE BOTH + All conflicts resolved + {0} conflict(s) remaining + MINE + Next Conflict + Previous Conflict + RESULT + SAVE & STAGE + THEIRS + Merge Conflicts + Discard unsaved changes? + USE MINE + USE THEIRS + UNDO Merge (Multiple) Commit all changes Strategy: @@ -477,17 +618,26 @@ Move Repository Node Select parent node for: Name: + NO Git has NOT been configured. Please to go [Preferences] and configure it first. + Open + Default Editor (System) Open Data Storage Directory - Open in Merge Tool - Open with... + Open File + Open in External Merge Tool + Open Local Repository + Bookmark: + Group: + Folder: Optional. Create New Tab - Bookmark Close Tab Close Other Tabs Close Tabs to the Right Copy Repository Path + Edit + Move to Workspace + Refresh Repositories Paste {0} days ago @@ -500,16 +650,15 @@ {0} months ago {0} years ago Yesterday - Use 'Shift+Enter' to input a new line. 'Enter' is the hotkey of OK button Preferences AI - Analyze Diff Prompt + Additional Prompt (Use `-` to list your requirements) API Key - Generate Subject Prompt Model + Fetch available model-ids automatically Name + Entered value is the name to load API key from ENV Server - Enable Streaming APPEARANCE Default Font Editor Tab Width @@ -517,36 +666,47 @@ Default Editor Monospace Font - Use monospace font only in text editor 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 author time instead of commit time in graph - Show children in the commit details + 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 - Enable --prune on fetch - Enable --ignore-cr-at-eol in diff + 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 @@ -558,6 +718,8 @@ User's gpg signing key INTEGRATION SHELL/TERMINAL + Arguments + Please use '.' to indicate working directory Path Shell/Terminal Prune Remote @@ -568,9 +730,6 @@ Remote Branch: Into: Local Changes: - Discard - Stash & Reapply - Update all submodules Remote: Pull (Fetch & Merge) Use rebase instead of merge @@ -590,10 +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: + 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: @@ -601,8 +767,10 @@ Repository URL: Remote git repository URL Copy URL + Custom Action Delete... Edit... + Enable Auto-Fetch Fetch Open In Browser Prune @@ -626,13 +794,17 @@ CONTINUE Custom Actions No Custom Actions + Dashboard Discard all changes Open in File Browser Search Branches/Tags/Submodules Visibility in Graph + Collapse Filters Unset Hide in commit graph + Expand Filters Filter in commit graph + filter(s) enabled LAYOUT Horizontal Vertical @@ -640,23 +812,26 @@ Commit Date Topologically LOCAL BRANCHES + More options... Navigate to HEAD Create Branch CLEAR NOTIFICATIONS - Only highlight current branch + Open as Folder Open in {0} Open in External Tools REMOTES Add Remote + RESOLVE Search Commit Author - Committer Content Message Path SHA Current Branch - Show first-parent only + Decorated commits only + First-parent only + SHOW FLAGS Show lost commits Show Submodules as Tree Show Tags as Tree @@ -666,12 +841,11 @@ Add Submodule Update Submodule TAGS - New Tag + Create Tag By Creator Date By Name Sort Open in Terminal - Use relative time View Logs Visit '{0}' in Browser WORKTREES @@ -689,18 +863,20 @@ Revert Commit Commit: Commit revert changes - Reword Commit Message Running. Please wait... SAVE Save As... Patch has been saved successfully! Scan Repositories Root Dir: + Scan another custom directory Check for Updates... New version of this software is available: + Current Version: Check for updates failed! Download Skip This Version + New Version Release Date: Software Update There are currently no updates available. Set Submodule's Branch @@ -714,8 +890,6 @@ Upstream: Copy SHA Go to - Squash Commits - Into: SSH Private Key: Private SSH key store path START @@ -724,10 +898,12 @@ Message: Optional. Message of this stash Mode: - Only staged changes + Only staged files Both staged and unstaged changes of selected file(s) will be stashed!!! Stash Local Changes Apply + Apply Changes + Checkout New Branch Copy Message Drop Save as Patch... @@ -745,6 +921,7 @@ SUBMODULES Add Submodule BRANCH + Branch Relative Path De-initialize Fetch nested submodules @@ -763,18 +940,29 @@ 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}$... Update Submodules All submodules Initialize as needed - Traverse submodules recursively Submodule: + Update nested submodules Update to submodule's remote tracking branch URL: Logs @@ -801,8 +989,11 @@ Ignore *{0} files in the same folder Ignore untracked files in this folder Ignore this file only + Ignore all untracked files in the same folder Amend You can stage this file now. + Clear History + Are you sure you want to clear all commit message history? This action cannot be undone. COMMIT COMMIT & PUSH Template/History @@ -812,14 +1003,17 @@ 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 - OPEN EXTERNAL MERGETOOL + 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 @@ -833,8 +1027,13 @@ 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 4106c7423..ac2e7d76f 100644 --- a/src/Resources/Locales/es_ES.axaml +++ b/src/Resources/Locales/es_ES.axaml @@ -5,6 +5,8 @@ Acerca de Acerca de SourceGit + Fecha de Release: {0} + Notas de la versión (Release) Cliente Git GUI de código abierto y gratuito Agregar Archivo(s) Para Ignorar Patrón: @@ -20,13 +22,19 @@ Crear Nueva Rama Rama Existente Asistente OpenAI + Modelo RE-GENERAR Usar OpenAI para generar mensaje de commit - APLICAR CÓMO MENSAJE DE COMMIT - Aplicar Parche - Archivo del Parche: + Usar + Ocultar SourceGit + Ocultar Otros + Mostrar Todo + Merge a 3 vías (3-Way) Seleccionar archivo .patch para aplicar Ignorar cambios de espacios en blanco + Fuente: + Archivo + Portapapeles Aplicar Parche Espacios en Blanco: Aplicar Stash @@ -39,6 +47,7 @@ Revisión: Archivar SourceGit Askpass + Introduce la frase de contraseña: ARCHIVOS ASUMIDOS COMO SIN CAMBIOS NO HAY ARCHIVOS ASUMIDOS COMO SIN CAMBIOS Cargar Imagen... @@ -47,22 +56,32 @@ Bisect Abortar Malo - Bisecting. ¿Es el HEAD actual bueno o malo? Bueno Saltar - Bisecting. Marcar el commit actual cómo bueno o malo y revisar otro. + 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 ${0}$ - Comparar con Worktree - Copiar Nombre de Rama + Comparar las 2 ramas seleccionadas + Comparar con... + Comparar con HEAD + Comparar con ${0}$ + Copiar Nombre de la Rama + Crear PR... + Crear PR para upstream ${0}$... Acción personalizada Eliminar ${0}$... Eliminar {0} ramas seleccionadas + Editar la descripción para ${0}$... Fast-Forward a ${0}$ Fetch ${0}$ en ${1}$... Git Flow - Finalizar ${0}$ + Hacer Rebase interactivamente ${0}$ en ${1}$ Merge ${0}$ en ${1}$... Hacer merge de las ramas {0} seleccionadas hacia la rama actual Pull ${0}$ @@ -71,29 +90,44 @@ Rebase ${0}$ en ${1}$... Renombrar ${0}$... Resetear ${0}$ a ${1}$... + Cambiar a ${0}$ (worktree) Establecer Rama de Seguimiento... - Comparar Ramas - ¡Upstream inválido! + {0} commit(s) adelante + {0} commit(s) adelante, {1} commit(s) detrás + {0} commit(s) detrás + Inválido + REMOTO + ESTADO + SEGUIMIENTO + URL + WORKTREE CANCELAR Resetear a Revisión Padre Resetear a Esta Revisión Generar mensaje de commit + Merge (Incorporado) + Merge (Externa) + Resetear Archivo(s) a ${0}$ CAMBIAR MODO DE VISUALIZACIÓN Mostrar como Lista de Archivos y Directorios Mostrar como Lista de Rutas Mostrar como Árbol de Sistema de Archivos + Cambiar la URL del Submódulo + Submódulo: + URL: Checkout Rama - Checkout Commit - Commit: - Advertencia: Al hacer un checkout de commit, tu Head se separará Cambios Locales: - Descartar - Stash & Reaplicar - Actualizar todos los submódulos Rama: ¡Tu HEAD actual contiene commit(s) que no están conectados a ningunas ramas/etiquetas! ¿Quieres continuar? - Checkout & Fast-Forward + 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): @@ -105,6 +139,8 @@ Clonar Repositorio Remoto Parámetros Adicionales: Argumentos adicionales para clonar el repositorio. Opcional. + Marcador: + Grupo: Nombre Local: Nombre del repositorio. Opcional. Carpeta Padre: @@ -112,72 +148,118 @@ URL del Repositorio: CERRAR Editor + Ramas + Ramas y Etiquetas + Acciones Personalizadas del Repositorio + Archivos de Revisión Checkout Commit Cherry-Pick Este Commit Cherry-Pick ... Comparar con HEAD Comparar con Worktree Autor + Fecha/Hora del Autor + Mensaje Committer + Fecha/Hora del Committer SHA Asunto Acción personalizada - Merge a ${0}$ + 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 Parche... - Squash en Parent CAMBIOS archivo(s) modificado(s) Buscar Cambios... + Colapsar detalles ARCHIVOS Archivo LFS Buscar Archivos... Submódulo INFORMACIÓN AUTOR - HIJOS 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 - 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 - Introducir asunto del commit + Comparar + CAMBIOS + COMMITS + SOLO COMMITS A LA IZQUIERDA + SOLO COMMITS A LA DERECHA + Tips: Puedes hacer cherry-pick a los commits seleccionados si el otro lado está en HEAD (usando el menú contextual). Configurar Repositorio PLANTILLA DE COMMIT + Parámetros incorporados: + + ${branch_name} Nombre de la rama local actual. + ${files_num} Número de archivos modificados + ${files} Rutas de archivos modificados + ${files:N} Número N máximo de rutas de archivos modificados + ${pure_files} Cómo ${files}, pero solo nombres de archivos puros + ${pure_files:N} Cómo ${files:N}, pero sin carpetas Contenido de la Plantilla: Nombre de la Plantilla: ACCIÓN PERSONALIZADA Argumentos: - Parámetros incorporados: ${REPO} - ruta del repositorio; ${BRANCH} - rama seleccionada; ${SHA} - hash del commit seleccionado; ${TAG} - etiqueta seleccionada + 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 - Puedes usar $1, $2 ... en argumentos, para valores de los controles de entrada Nombre: Alcance: Rama Commit + Archivo + Remoto Repositorio Etiqueta Esperar la acción de salida Dirección de Email Dirección de email GIT + Preguntar antes de actualizar automáticamente los submódulos Fetch remotos automáticamente Minuto(s) + Tipos de Commit Convencionales Remoto por Defecto + Habilitar `--recursive` cuando se actualicen automáticamente los submódulos Modo preferido de Merge SEGUIMIENTO DE INCIDENCIAS Añadir Regla de Ejemplo para Azure DevOps + Añadir Regla de "Gerrit Change-Id Commit" Añadir Regla de Ejemplo para Incidencias de Gitee Añadir Regla de Ejemplo para Pull Requests de Gitee Añadir Regla de Ejemplo para GitHub @@ -187,6 +269,7 @@ 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 @@ -194,8 +277,8 @@ 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 @@ -203,15 +286,24 @@ 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 & COMMIT + 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: @@ -221,17 +313,15 @@ Tipo de Cambio: Copiar Copiar Todo el Texto + Copiar como Parche Copiar Ruta Completa Copiar Ruta Crear Rama... Basado En: Checkout de la rama creada Cambios Locales: - Descartar - Stash & Reaplicar Nombre de la Nueva Rama: Introduzca el nombre de la rama. - Los espacios serán reemplazados con guiones. Crear Rama Local Sobrescribir la rama existente Crear Etiqueta... @@ -248,15 +338,22 @@ 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: @@ -271,13 +368,19 @@ Etiqueta: Eliminar de los repositorios remotos DIFERENCIA BINARIA - 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 Parche Mostrar símbolos ocultos @@ -285,21 +388,29 @@ SUBMÓDULO BORRADO NUEVO + + {0} Cambios Sin confirmar Intercambiar Resaltado de Sintaxis Ajuste de Línea - Habilitar navegación en bloque Abrir en Herramienta de Merge Mostrar Todas las Líneas Disminuir Número de Líneas Visibles Aumentar Número de Líneas Visibles SELECCIONA ARCHIVO PARA VER CAMBIOS + 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: @@ -313,7 +424,8 @@ Fetch sin etiquetas Remoto: Fetch Cambios Remotos - Asumir sin cambios + Asumir como sin cambios + Acción Personalizada Descartar... Descartar {0} archivos... Resolver usando ${0}$ @@ -329,15 +441,25 @@ Historial de Archivos CAMBIO CONTENIDO + Cambiar modo archivo: + Modo archivo eliminado: + Directorio + Ejecutable + Modo archivo nuevo: + Normal + Submódulo + Enlace simbólico (Symlink) + Desconocido Git-Flow Rama de Desarrollo: Feature: Prefijo de Feature: + Finalizar ${0}$ FLOW - Finalizar Feature FLOW - Finalizar Hotfix FLOW - Finalizar Release Destino: - Push al/los remoto(s) después de Finalizar + Hacer rebase antes de fusionar Squash durante el merge Hotfix: Prefijo de Hotfix: @@ -346,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 @@ -369,6 +493,8 @@ 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 @@ -381,16 +507,26 @@ Remoto: Seguir archivos llamados '{0}' Seguir todos los archivos *{0} + Seleccionar Commit Historias 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 Clonar repositorio nuevo @@ -398,32 +534,35 @@ Ir a la siguiente página Ir a la página anterior Crear nueva página + Abrir repositorio local Abrir diálogo de preferencias - Cambiar espacio de trabajo activo + Mostrar menú desplegable del espacio de trabajo Cambiar página activa + Acercar/Alejar REPOSITORIO Commit cambios staged Commit y push cambios staged Stage todos los cambios y commit + Crear nueva rama Fetch, empieza directamente Modo Dashboard (Por Defecto) + Ir al hijo del commit seleccionado + Ir al padre del commit seleccionado + Abrir paleta de comandos Modo de búsqueda de commits Pull, empieza directamente Push, empieza directamente Forzar a recargar este repositorio + Expandir/Colapsar el panel de detalles en el `HISTORIAL`/`HISTORY` Cambiar a 'Cambios' Cambiar a 'Historias' Cambiar a 'Stashes' - EDITOR DE TEXTO - Cerrar panel de búsqueda - Buscar siguiente coincidencia - Buscar coincidencia anterior - Abrir con herramienta diff/merge externa - Abrir panel de búsqueda Descartar Stage Unstage Inicializar Repositorio + ¿Quieres correr el comando `git init` en esta ruta? + Falló la apertura del repositorio. Razón: Ruta: Cherry-Pick en progreso. Procesando commit @@ -434,38 +573,75 @@ Revert en progreso. Haciendo revert del commit Rebase Interactivo - Stash & reaplicar cambios locales + 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 - Espacios de trabajo + Nueva Versión Disponible + Abrir Repositorios Páginas + Espacios de trabajo Merge Rama Personalizar mensaje de merge En: Opción de Merge: Rama Fuente: - Merge (Multiplo) + Pruebas para hacer merge... + El merge se puede realizar sin conflictos + Error desconocido ocurrido al probar el merge + Hacer merge causará conflictos + Primero Míos, después Suyos + Primero Suyos, después Míos + USAR AMBOS + Todos los conflictos resueltos + {0} conflicto(s) restantes + MÍOS + Conflicto Siguiente + Conflicto Previo + RESULTADO + GUARDAR y hacer STAGE + SUYOS + Conflictos de Merge + ¿Descartar cambios sin guardar? + USAR MÍOS + USAR SUYOS + DESHACER + Merge (Multiple) Commit todos los cambios Estrategia: Destino: + Mover Submódulo + Mover A: + Submódulo: Mover Nodo del Repositorio Seleccionar nodo padre para: Nombre: + NO Git NO ha sido configurado. Por favor, ve a [Preferencias] y configúralo primero. + Abrir + Editor por defecto (Sistema) Abrir Directorio de Datos de la App + Abrir Archivo Abrir en Herramienta de Merge - Abrir Con... + 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 Hace {0} días @@ -478,16 +654,15 @@ Hace {0} meses Hace {0} años Ayer - Usa 'Shift+Enter' para introducir una nueva línea. 'Enter' es el atajo del botón OK Preferencias OPEN AI - Analizar Diff Prompt + Prompt adicional (Usa `-` para listar tus requerimientos) Clave API - Generar Subject Prompt Modelo + Traer automáticamente los model-ids disponibles Nombre + El valor ingresado es el nombre de la clave API a cargar desde ENV Servidor - Activar Transmisión APARIENCIA Fuente por defecto Ancho de la Pestaña del Editor @@ -495,36 +670,47 @@ Por defecto Editor Fuente Monospace - Usar solo fuente monospace en el editor de texto 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 hora del autor en lugar de la hora del commit en el gráfico - Mostrar hijos en los detalles de commit + 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 de usuario + 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 - Nombre de usuario + 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 @@ -536,6 +722,8 @@ 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 @@ -546,9 +734,6 @@ Rama Remota: En: Cambios Locales: - Descartar - Stash & Reaplicar - Actualizar todos los submódulos Remoto: Pull (Fetch & Merge) Usar rebase en lugar de merge @@ -556,6 +741,7 @@ Asegurarse de que los submódulos se hayan hecho push Forzar push Rama Local: + NUEVO Remoto: Revisión: Push Revisión al Remoto @@ -567,10 +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: + 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: @@ -578,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) @@ -603,13 +798,17 @@ CONTINUAR Acciones Personalizadas No hay ninguna Acción Personalizada + Dashboard Descartar todos los cambios Abrir en el Explorador Buscar Ramas/Etiquetas/Submódulos Visibilidad en el Gráfico + Colapsar Filtros Desestablecer Ocultar en el Gráfico de Commits + Expandir Filtros Filtrar en el Gráfico de Commits + filtro(s) activado(s) DISPOSICIÓN Horizontal Vertical @@ -617,21 +816,27 @@ Fecha de Commit Topológicamente RAMAS LOCALES + Más opciones... Navegar a HEAD Crear Rama LIMPIAR NOTIFICACIONES + Abrir como Carpeta Abrir en {0} Abrir en Herramientas Externas REMOTOS AÑADIR REMOTO + RESOLVER Buscar Commit Autor - Committer Contenido Mensaje Ruta SHA Rama Actual + Sólo los commits decorados + Mostrar sólo el primer padre + MOSTRAR FLAGS + Mostrar commits perdidos Mostrar Submódulos como Árbol Mostrar Etiquetas como Árbol OMITIR @@ -662,33 +867,38 @@ Revertir Commit Commit: Commit revertir cambios - Reescribir Mensaje de Commit Ejecutando. Por favor espera... GUARDAR Guardar Como... ¡El parche se ha guardado exitosamente! Escanear Repositorios Directorio Raíz: + Escanear otro directorio personalizado Buscar Actualizaciones... Nueva versión de este software disponible: + Versión Actual: ¡Error al buscar actualizaciones! Descargar Omitir Esta Versión + Fecha de Release de la Nueva Versión: Actualización de Software Actualmente no hay actualizaciones disponibles. + Establecer Rama del Submódulo + Submódulo: + Actual: + Cambiar A: + Opcional. Establecer por defecto cuando este vacío. Establecer Rama de Seguimiento Rama: Desestablecer upstream Upstream: Copiar SHA Ir a - Squash Commits - En: Clave Privada SSH: Ruta de almacenamiento de la clave privada SSH INICIAR Stash - Incluir archivos no rastreados + Incluir archivos sin rastrear Mensaje: Opcional. Información de este stash Modo: @@ -696,6 +906,8 @@ ¡Tanto los cambios staged como los no staged de los archivos seleccionados serán stashed! Stash Cambios Locales Aplicar + Aplicar Cambios + Checkout a Nueva Rama Copiar Mensaje Eliminar Guardar como Parche... @@ -712,31 +924,50 @@ COMMITS: SUBMÓDULOS Añadir Submódulo + RAMA + Rama Ruta Relativa Desinicializar Submódulo Fetch submódulos anidados + Historial + Mover A Abrir Repositorio del Submódulo Ruta Relativa: Carpeta relativa para almacenar este módulo. Eliminar Submódulo + Establecer Rama + Cambiar URL ESTADO modificado no inicializado revisión cambiada unmerged + Actualizar URL + Cambiar Detalles del Submódulo + ABRIR DETALLES OK - 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}$... + 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: + Actualizar submódulos anidados + Actualizar la rama de seguimiento remota del submódulo URL: Logs LIMPIAR TODO @@ -760,12 +991,15 @@ Git Ignore Ignorar todos los archivos *{0} Ignorar archivos *{0} en la misma carpeta - Ignorar archivos no rastreados en esta carpeta + Ignorar archivos sin rastrear en esta carpeta Ignorar solo este archivo + Ignorar todos los archivos sin rastrear en la misma carpeta Enmendar Puedes hacer stage a este archivo ahora. + Limpiar Historial + ¿Estás seguro de querer limpiar todo el historial de los mensajes de commit? Esta acción no se puede deshacer. COMMIT - COMMIT & PUSH + COMMIT y PUSH Plantilla/Historias Activar evento de clic Commit (Editar) @@ -773,14 +1007,16 @@ 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 - ABRIR HERRAMIENTA DE MERGE EXTERNA + MERGE + Merge con Herramienta Externa ABRIR TODOS LOS CONFLICTOS EN HERRAMIENTA DE MERGE EXTERNA LOS CONFLICTOS DE ARCHIVOS ESTÁN RESUELTOS USAR MÍOS USAR SUYOS - INCLUIR ARCHIVOS NO RASTREADOS + INCLUIR ARCHIVOS SIN RASTREAR NO HAY MENSAJES DE ENTRADA RECIENTES NO HAY PLANTILLAS DE COMMIT + Sin verificar Restablecer Autor Firmar STAGED @@ -789,13 +1025,18 @@ UNSTAGED STAGE STAGE TODO - VER ASSUME UNCHANGED + VER ASUMIR COMO SIN CAMBIOS Plantilla: ${0}$ ESPACIO DE TRABAJO: Configura Espacios de Trabajo... WORKTREE + RAMA Copiar Ruta + HEAD Bloquear + Abrir + RUTA Eliminar Desbloquear + diff --git a/src/Resources/Locales/fr_FR.axaml b/src/Resources/Locales/fr_FR.axaml index e5e443ea1..a094fb2ae 100644 --- a/src/Resources/Locales/fr_FR.axaml +++ b/src/Resources/Locales/fr_FR.axaml @@ -5,7 +5,12 @@ À propos À propos de SourceGit + Date de publication : {0} + Notes de Version Client Git Open Source et Gratuit + Ajouter le(s) Fichier(s) à Ignorer + Modèle : + Fichier de Stockage : Ajouter un Worktree Emplacement : Chemin vers ce worktree. Relatif supporté. @@ -17,11 +22,14 @@ 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 COMME MESSAGE DE COMMIT - Appliquer - 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 Appliquer le patch @@ -36,22 +44,39 @@ Révision : Archiver SourceGit Askpass + Saisir la phrase secrète : FICHIERS PRÉSUMÉS INCHANGÉS PAS DE FICHIERS PRÉSUMÉS INCHANGÉS + Charger l'Image... Rafraîchir FICHIER BINAIRE NON SUPPORTÉ !!! + Bisect + Annuler + Mauvais + Bon + Passer + Bisect en cours. Marquer le commit actuel comme bon ou mauvais et en récupérer un autre. + Bisect en cours. Le HEAD actuel est-il bon ou mauvais ? Blâme - LE BLÂME SUR CE FICHIER N'EST PAS SUPPORTÉ!!! + 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 avec ${0}$ - Comparer avec le worktree + Comparer les 2 branches sélectionnées + Comparer avec... + Comparer avec HEAD + Comparer avec ${0}$ Copier le nom de la branche + Créer une PR... + Créer une PR pour l'upstream ${0}$... Action personnalisée Supprimer ${0}$... Supprimer {0} branches sélectionnées + Modifier la description de ${0}$... Fast-Forward vers ${0}$ Fetch ${0}$ vers ${1}$... Git Flow - Terminer ${0}$ + Rebaser Interactivement ${0}$ sur ${1}$ Fusionner ${0}$ dans ${1}$... Fusionner les {0} branches sélectionnées dans celle en cours Tirer ${0}$ @@ -59,25 +84,42 @@ Pousser ${0}$ Rebaser ${0}$ sur ${1}$... Renommer ${0}$... + Réinitialiser ${0}$ sur ${1}$... + Basculer vers ${0}$ (worktree) Définir la branche de suivi... - Comparer les branches - Branche en amont invalide! + {0} commit(s) en avance + {0} commit(s) en avance, {1} commit(s) en retard + {0} commit(s) en retard + Invalide + DISTANT + STATUT + SUIVI + URL + WORKTREE ANNULER Réinitialiser à la révision parente Réinitialiser à cette révision Générer un message de commit + Fusion (intégré) + Fusion (externe) + Réinitialiser le(s) fichier(s) à ${0}$ CHANGER LE MODE D'AFFICHAGE Afficher comme liste de dossiers/fichiers Afficher comme liste de chemins Afficher comme arborescence + Changer l'URL du sous-module + Sous-module : + URL : Récupérer la branche - Récupérer ce commit - Commit : - Avertissement: une récupération vers un commit aboutiera vers un HEAD détaché Changements locaux : - Annuler - Mettre en stash et réappliquer Branche : + 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 : @@ -89,6 +131,8 @@ 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 : @@ -96,60 +140,115 @@ URL du dépôt : FERMER Éditeur + Branches + Branches et tags + Actions personnalisées du dépôt + Fichiers de révision Récupérer ce commit Cherry-Pick ce commit Cherry-Pick ... Comparer avec HEAD Comparer avec le worktree + Auteur + Message + Committer SHA + Sujet Action personnalisée - Fusionner dans ${0}$ + 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 CHANGEMENTS + fichier(s) modifié(s) Rechercher les changements... + Réduire les détails FICHIERS Fichier LFS Rechercher des fichiers... Sous-module INFORMATIONS AUTEUR - ENFANTS 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 - Description - Entrez le message du commit + COL + Saisissez le message de commit. Utilisez une ligne vide pour séparer le sujet et la description ! + SUJET + Comparer + CHANGEMENTS + COMMITS + COMMITS UNIQUEMENT À GAUCHE + COMMITS UNIQUEMENT À DROITE + Astuce : vous pouvez cherry-pick les commits sélectionnés si l'autre côté est HEAD (via le menu contextuel). Configurer le dépôt MODÈLE DE COMMIT + Paramètres intégrés : + + ${branch_name} Nom de la branche locale actuelle. + ${files_num} Nombre de fichiers modifiés + ${files} Chemins des fichiers modifiés + ${files:N} Nombre maximum N de chemins de fichiers modifiés + ${pure_files} Comme ${files}, mais uniquement les noms de fichiers purs + ${pure_files:N} Comme ${files:N}, mais sans les dossiers Contenu de modèle: Nom de modèle: ACTION PERSONNALISÉE Arguments : + Paramètres intégrés : + + ${REPO} Chemin du dépôt + ${REMOTE} Dépôt distant sélectionné ou dépôt distant de la branche sélectionnée + ${BRANCH} Branche sélectionnée, sans la partie ${REMOTE} pour les branches distantes + ${BRANCH_FRIENDLY_NAME} Nom d'affichage de la branche sélectionnée, contient la partie ${REMOTE} pour les branches distantes + ${SHA} Hash du commit sélectionné + ${TAG} Tag sélectionné + ${FILE} Fichier sélectionné, relatif à la racine du dépôt + $1, $2 ... Paramètres d'entrée utilisateur Fichier exécutable : + Paramètres d'entrée utilisateur : + Modifier Nom : Portée : Branche Commit - 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 + Mode de fusion préféré SUIVI DES PROBLÈMES Ajouter une règle d'exemple Azure DevOps + Ajouter une règle de commit Gerrit Change-Id Ajouter une règle d'exemple Gitee Ajouter une règle d'exemple pour Pull Request Gitee Ajouter une règle d'exemple GitHub @@ -159,6 +258,7 @@ 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 @@ -168,10 +268,31 @@ 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 : @@ -187,12 +308,10 @@ Basé sur : Récupérer la branche créée Changements locaux : - Rejeter - Stash & Réappliquer Nom de la nouvelle branche : Entrez le nom de la branche. - Les espaces seront remplacés par des tirets. Créer une branche locale + Écraser la branche existante Créer un tag... Nouveau tag à : Signature GPG @@ -207,12 +326,20 @@ 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: @@ -227,39 +354,55 @@ Tag : Supprimer des dépôts distants DIFF BINAIRE - 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 - Activer la navigation par blocs Ouvrir dans l'outil de fusion Voir toutes les lignes Réduit le nombre de ligne visibles Augmente le nombre de ligne visibles SÉLECTIONNEZ UN FICHIER POUR VOIR LES CHANGEMENTS + Historique du Répertoire + A des Modifications Locales + Divergence avec le Dépôt Distant + Déjà à jour Rejeter les changements Tous les changements dans la copie de travail. Changements : Inclure les fichiers ignorés + Inclure les fichiers modifiés/supprimés + Inclure les fichiers non suivis {0} changements seront rejetés Vous ne pouvez pas annuler cette action !!! + Modifier la description de la branche + Cible : Signet : Nouveau nom : Cible : Éditer le groupe sélectionné Éditer le dépôt sélectionné + Cible : + Ce dépôt Fetch Fetch toutes les branches distantes Outrepasser les vérifications de refs @@ -267,6 +410,7 @@ Remote : Récupérer les changements distants Présumer inchangé + Action personnalisée Rejeter... Rejeter {0} fichiers... Résoudre en utilisant ${0}$ @@ -290,6 +434,7 @@ FLOW - Terminer Hotfix FLOW - Terminer Release Cible: + Squash lors de la fusion Hotfix: Hotfix Prefix: Initialiser Git-Flow @@ -320,6 +465,8 @@ Afficher seulement mes verrous Verrous LFS Déverouiller + Déverrouiller tous mes verrous + Êtes-vous sûr de vouloir déverrouiller tous vos fichiers verrouillés ? Forcer le déverouillage Elaguer Lancer `git lfs prune` pour supprimer les anciens fichier LFS du stockage local @@ -332,16 +479,26 @@ Dépôt : Suivre les fichiers appelés '{0}' Suivre tous les fichiers *{0} + Sélectionner un commit Historique AUTEUR HEURE DE L'AUTEUR + HEURE DE COMMIT GRAPHE & SUJET SHA - 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 Cloner un nouveau dépôt @@ -349,29 +506,35 @@ Aller à la page suivante Aller à la page précédente Créer une nouvelle page + Ouvrir un dépôt local Ouvrir le dialogue des préférences + Afficher le menu des espaces de travail + Changer d'onglet actif + Zoom avant/arrière DÉPÔT Commit les changements de l'index Commit et pousser les changements de l'index Ajouter tous les changements et commit + Créer une nouvelle branche Fetch, démarre directement Mode tableau de bord (Défaut) + Aller au commit enfant + Aller au commit parent + Ouvrir la palette de commandes Recherche de commit Pull, démarre directement Push, démarre directement Forcer le rechargement du dépôt + Afficher/Masquer le panneau de détails du panneau`HISTORIQUE` Basculer vers 'Changements' Basculer vers 'Historique' Basculer vers 'Stashes' - ÉDITEUR DE TEXTE - Fermer le panneau de recherche - Trouver la prochaine correspondance - Trouver la correspondance précédente - Ouvrir le panneau de recherche Rejeter Indexer Retirer de l'index Initialiser le repository + Voulez-vous exécuter `git init` dans ce dossier ? + Échec de l'ouverture du dépôt. Raison : Chemin : Cherry-Pick en cours. Traitement du commit @@ -383,34 +546,69 @@ Annulation du commit Rebase interactif Stash & réappliquer changements locaux + Contourner le hook de pre-rebase Sur : + Glisser-déposer pour réorganiser les commits Branche cible : Copier le lien Ouvrir dans le navigateur + Commandes ERREUR NOTICE + Ouvrir des dépôts + Onglets + Espaces de travail Merger la branche + Personnaliser le message de fusion Dans : Option de merge: Source: + D'abord les miens, puis les leurs + D'abord les leurs, puis les miens + UTILISER LES DEUX + Tous les conflits sont résolus + {0} conflit(s) restant(s) + MIENS + Conflit suivant + Conflit précédent + RÉSULTAT + SAUVER ET INDEXER + LEURS + Conflits de fusion + Abandonner les modifications non sauvegardées ? + UTILISER LES MIENS + UTILISER LES LEURS + ANNULER Fusionner (Plusieurs) Commit tous les changement Stratégie: Cibles: + Déplacer le sous-module + Déplacer vers : + Sous-module : Déplacer le noeud du repository Sélectionnier le noeud parent pour : Nom : + NON Git n'a PAS été configuré. Veuillez d'abord le faire dans le menu Préférence. + Ouvrir + Éditeur par défaut (Système) Ouvrir le dossier AppData + Ouvrir un fichier Ouvrir dans l'outil de fusion - Ouvrir avec... + 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 Copier le chemin vers le dépôt + Éditer + Se déplacer vers l'espace de travail + Actualiser Dépôts Coller il y a {0} jours @@ -423,16 +621,15 @@ il y a {0} mois il y a {0} ans Hier - Utiliser 'Maj+Entrée' pour insérer une nouvelle ligne. 'Entrée' est la touche pour valider Préférences IA - Analyser Diff Prompt + Prompt supplémentaire (utilisez `-` pour lister vos exigences) Clé d'API - Générer le sujet de Prompt 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 - Activer le streaming APPARENCE Police par défaut Largeur de tab dans l'éditeur @@ -440,35 +637,46 @@ Défaut Éditeur Police monospace - N'utiliser que des polices monospace pour l'éditeur de texte 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 l'heure de l'auteur au lieu de l'heure de validation dans le graphique - Afficher les enfants dans les détails du commit + Afficher 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 @@ -480,6 +688,8 @@ 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 @@ -490,8 +700,6 @@ Branche distante : Dans : Changements locaux : - Rejeter - Stash & Réappliquer Dépôt distant : Pull (Fetch & Merge) Utiliser rebase au lieu de merge @@ -499,7 +707,10 @@ Assurez-vous que les submodules ont été poussés Poussage forcé Branche locale : + NOUVEAU Dépôt distant : + Révision : + Pousser la Révision vers le Dépôt Distant Pousser les changements vers le dépôt distant Branche distante : Définir comme branche de suivi @@ -508,9 +719,12 @@ Pousser tous les dépôts distants Dépôt distant : Tag : + Pousser vers une NOUVELLE branche + Saisir le nom de la nouvelle branche distante : Quitter Rebase la branche actuelle Stash & réappliquer changements locaux + Contourner le hook de pre-rebase Sur : Ajouter dépôt distant Modifier dépôt distant @@ -519,27 +733,34 @@ URL du repository : URL du dépôt distant Copier l'URL + Action personnalisée Supprimer... Editer... + Activer l'auto-fetch Fetch Ouvrir dans le navigateur Elaguer Confirmer la suppression du Worktree Activer l'option `--force` Cible : - la branche + Renommer la branche Nouveau nom : Nom unique pour cette branche Branche : ABORT Fetch automatique des changements depuis les dépôts... + Trier + Par date du Committer + Par Nom Nettoyage(GC & Elaguage) Lancer `git gc` pour ce repository. Tout effacer + Nettoyer Configurer ce repository CONTINUER Actions personnalisées Pas d'actions personnalisées + Tableau de bord Rejeter tous les changements Ouvrir dans l'explorateur de fichiers Rechercher Branches/Tags/Submodules @@ -554,19 +775,28 @@ Date du commit Topologiquement BRANCHES LOCALES + Plus d'options... Naviguer vers le HEAD Créer une branche EFFACER LES NOTIFICATIONS + Ouvrir comme dossier Ouvrir dans {0} Ouvrir dans un outil externe DEPOTS DISTANTS AJOUTER DEPOT DISTANT + RÉSOUDRE Rechercher un commit Auteur - Committer + Contenu Message + Chemin SHA Branche actuelle + Commits décorés uniquement + Premier parent uniquement + AFFICHER LES FLAGS + Afficher les commits perdus + Afficher les sous-modules en tant qu'arbre Voir les Tags en tant qu'arbre PASSER Statistiques @@ -579,6 +809,8 @@ Par nom Trier Ouvrir dans un terminal + Voir les Logs + Visiter '{0}' dans le navigateur WORKTREES AJOUTER WORKTREE ELAGUER @@ -587,32 +819,40 @@ Reset Mode: Déplacer vers : Branche actuelle : + Réinitialiser la Branche (Sans Récupération) + Déplacer Vers : + Branche : Ouvrir dans l'explorateur de fichier Annuler le Commit Commit : Commit les changements de l'annulation - Reformuler le message de commit En exécution. Veuillez patienter... SAUVEGARDER Sauvegarder en tant que... Le patch a été sauvegardé ! Analyser les repositories Dossier racine : + Scanner un autre répertoire personnalisé Rechercher des mises à jour... Une nouvelle version du logiciel est disponible : + Version actuelle : La vérification de mise à jour à échouée ! Télécharger Passer cette version + Date de la nouvelle version : Mise à jour du logiciel Il n'y a pas de mise à jour pour le moment. + Définir la Branche du Sous-module + Sous-module : + Actuel : + Changer pour : + Optionnel. Défini par défaut si vide. Définir la branche suivie Branche: Retirer la branche amont En amont: Copier le SHA Aller à - Squash les commits - Dans : Clé privée SSH : Chemin du magasin de clés privées SSH START @@ -620,10 +860,14 @@ Inclure les fichiers non-suivis Message : Optionnel. Information de ce stash + Mode : Seulement les changements indexés Les modifications indexées et non-indexées des fichiers sélectionnés seront stockées!!! Stash les changements locaux Appliquer + Appliquer les changements + Créer une branche + Copier le Message Effacer Sauver comme Patch... Effacer le Stash @@ -639,22 +883,52 @@ 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 - Copier le nom du Tag - Copier le message du tag + TAGUEUR + HEURE + Comparer 2 tags + Comparer avec... + Comparer avec HEAD + Message + Nom + Tagueur + Copier le nom du tag + Action personnalisée Supprimer ${0}$... - Fusionner ${0}$ dans ${1}$... + Supprimer les {0} tags sélectionnés... Pousser ${0}$... Actualiser les sous-modules Tous les sous-modules Initialiser au besoin - Récursivement Sous-module : + Mettre à jour vers la branche de suivi distante du sous-module URL : + Logs + TOUT EFFACER + Copier + Supprimer Avertissement Page d'accueil Créer un groupe @@ -673,21 +947,33 @@ Git Ignore Ignorer tous les *{0} fichiers Ignorer *{0} fichiers dans le même dossier + Ignorer les fichiers non suivis dans ce dossier N'ignorer que ce fichier Amender Vous pouvez indexer ce fichier. + Effacer l'historique + Êtes-vous sûr de vouloir effacer tout l'historique des messages de commit ? Cette action est irréversible. COMMIT COMMIT & POUSSER Modèles/Historiques Trigger click event Commit (Modifier) Indexer tous les changements et commit + Vous êtes en train de créer un commit sur un HEAD détaché. Voulez-vous continuer ? + Vous avez indexé {0} fichier(s) mais seulement {1} fichier(s) sont affichés ({2} fichiers sont filtrés). Voulez-vous continuer ? CONFLITS DÉTECTÉS + FUSIONNER + Fusion avec un outil externe + OUVRIR TOUS LES CONFLITS DANS L'OUTIL DE FUSION EXTERNE LES CONFLITS DE FICHIER SONT RÉSOLUS + UTILISER LES MIENS + UTILISER LES LEURS INCLURE LES FICHIERS NON-SUIVIS PAS DE MESSAGE D'ENTRÉE RÉCENT PAS DE MODÈLES DE COMMIT - SignOff + No-Verify + Réinitialiser l'Auteur + Signer INDEXÉ RETIRER DE L'INDEX RETIRER TOUT DE L'INDEX @@ -699,8 +985,13 @@ 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 index c83a8bdb9..46b2fa9a5 100644 --- a/src/Resources/Locales/it_IT.axaml +++ b/src/Resources/Locales/it_IT.axaml @@ -5,7 +5,12 @@ 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. @@ -17,11 +22,11 @@ Crea nuovo branch Branch esistente Assistente AI + Modello RIGENERA Usa AI per generare il messaggio di commit - APPLICA COME MESSAGGIO DI COMMIT - Applica - File Patch: + Nascondi SourceGit + Mostra Tutto Seleziona file .patch da applicare Ignora modifiche agli spazi Applica Patch @@ -36,29 +41,38 @@ 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 - Bisecando. La HEAD corrente è buona o cattiva? Buona Salta - Bisecando. Marca il commit corrente come buono o cattivo e fai checkout di un altro. + Bisecando. Marca il commit corrente come buono o cattivo e fai checkout di un altro. + Bisecando. La HEAD corrente è buona o cattiva? Attribuisci - L'ATTRIBUZIONE SU QUESTO FILE NON È SUPPORTATA!!! + Attribuisci sulla Revisione Precedente + Ignora modifiche agli spazi + L'ATTRIBUZIONE SU QUESTO FILE NON È SUPPORTATA!!! Checkout ${0}$... - Confronta con ${0}$ - Confronta con Worktree + 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}$ @@ -66,26 +80,38 @@ Invia ${0}$ Riallinea ${0}$ su ${1}$... Rinomina ${0}$... + Resetta ${0}$ a ${1}$... + Passa a ${0}$ (worktree) Imposta Branch di Tracciamento... - Confronto Branch - Upstream non valido + {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 - Checkout Commit - Commit: - Avviso: Effettuando un checkout del commit, la tua HEAD sarà separata Modifiche Locali: - Scarta - Stasha e Ripristina - Aggiorna tutti i sottomoduli Branch: + 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): @@ -110,17 +136,26 @@ Confronta con HEAD Confronta con Worktree Autore + Messaggio Committer SHA Oggetto Azione Personalizzata - Unisci a ${0}$ + 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 - Modifica Salva come Patch... - Compatta nel Genitore MODIFICHE + file modificati Cerca Modifiche... FILE File LFS @@ -128,41 +163,71 @@ Sottomodulo INFORMAZIONI AUTORE - FIGLI 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 - Descrizione + Inserisci il messaggio di commit. Usa una riga vuota per separare oggetto e descrizione! OGGETTO - Inserisci l'oggetto del commit + 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 @@ -172,6 +237,7 @@ 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 @@ -181,6 +247,17 @@ 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 @@ -189,6 +266,8 @@ 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: @@ -204,12 +283,10 @@ Basato Su: Checkout del Branch Creato Modifiche Locali: - Scarta - Stasha e Ripristina Nome Nuovo Branch: Inserisci il nome del branch. - Gli spazi verranno rimpiazzati con dei trattini. Crea Branch Locale + Sovrascrivi branch esistente Crea Tag... Nuovo Tag Su: Firma con GPG @@ -224,12 +301,20 @@ 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 anche il branch remoto ${0}$ 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: @@ -244,39 +329,53 @@ Tag: Elimina dai repository remoti DIFF BINARIO - Modalità File Modificata 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 - Abilita la navigazione a blocchi Apri nello Strumento di Merge Mostra Tutte le Righe Diminuisci Numero di Righe Visibili Aumenta Numero di Righe Visibili SELEZIONA UN FILE PER VISUALIZZARE LE MODIFICHE + 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 @@ -284,6 +383,7 @@ Remoto: Recupera Modifiche Remote Presumi invariato + Azione Personalizzata Scarta... Scarta {0} file... Risolvi Usando ${0}$ @@ -307,7 +407,6 @@ FLOW - Completa Hotfix FLOW - Completa Rilascio Target: - Invia al remote dopo aver finito Esegui squash durante il merge Hotfix: Prefisso Hotfix: @@ -339,6 +438,8 @@ 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 @@ -354,9 +455,9 @@ STORICO AUTORE ORA AUTORE + ORA COMMIT GRAFICO E OGGETTO SHA - ORA COMMIT {0} COMMIT SELEZIONATI Tieni premuto 'Ctrl' o 'Shift' per selezionare più commit. Tieni premuto ⌘ o ⇧ per selezionare più commit. @@ -369,12 +470,16 @@ 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 @@ -382,12 +487,6 @@ Passa a 'Modifiche' Passa a 'Storico' Passa a 'Stashes' - EDITOR TESTO - Chiudi il pannello di ricerca - Trova il prossimo risultato - Trova il risultato precedente - Apri con uno strumento di diff/merge esterno - Apri il pannello di ricerca Scarta Aggiungi in stage Rimuovi @@ -404,33 +503,63 @@ 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 - Apri con... Opzionale. Crea Nuova Pagina - Segnalibro Chiudi Tab Chiudi Altri Tab Chiudi i Tab a Destra Copia Percorso Repository + Modifica + Sposta nel Workspace + Aggiorna Repository Incolla {0} giorni fa @@ -443,16 +572,12 @@ {0} mesi fa {0} anni fa Ieri - Usa 'Shift+Enter' per inserire una nuova riga. 'Enter' è il tasto rapido per il pulsante OK Preferenze AI - Analizza il Prompt Differenza Chiave API - Genera Prompt Oggetto - Modello Nome + Il valore inserito è il nome per caricare la chiave API da ENV Server - Abilita streaming ASPETTO Font Predefinito Larghezza della Tab Editor @@ -460,24 +585,30 @@ Dimensione Font Predefinita Dimensione Font Editor Font Monospaziato - Usa solo font monospaziato nell'editor 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 nel grafico l'orario dell'autore anziché quello del commit - Mostra i figli nei dettagli del commit + 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 @@ -488,6 +619,7 @@ 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 @@ -501,6 +633,8 @@ Chiave GPG dell'utente per la firma INTEGRAZIONE SHELL/TERMINALE + Argomenti + Usa '.' per indicare la cartella di lavoro Percorso Shell/Terminale Potatura Remota @@ -511,8 +645,6 @@ Branch Remoto: In: Modifiche Locali: - Scarta - Stasha e Riapplica Remoto: Scarica (Recupera e Unisci) Riallineare anziché unire @@ -520,15 +652,20 @@ 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 tracking + 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 @@ -540,6 +677,7 @@ URL del Repository: URL del repository Git remoto Copia URL + Azione Personalizzata Elimina... Modifica... Recupera @@ -560,10 +698,12 @@ 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 @@ -578,20 +718,27 @@ 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 - Committer 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 @@ -615,17 +762,20 @@ 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 - Modifica Messaggio di Commit 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! @@ -633,14 +783,17 @@ 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 - Compatta Commit - In: Chiave Privata SSH: Percorso per la chiave SSH privata AVVIA @@ -648,10 +801,12 @@ 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 @@ -667,29 +822,45 @@ 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 - Copia Nome Tag - Copia Messaggio Tag + AUTORE TAG + DATA + Confronta 2 tag + Confronta con... + Confronta con HEAD + Messaggio + Nome + Autore + Copia Nome Tag + Azione Personalizzata Elimina ${0}$... - Unisci ${0}$ in ${1}$... + Elimina i {0} tag selezionati... Invia ${0}$... Aggiorna Sottomoduli Tutti i sottomoduli Inizializza se necessario - Ricorsivamente Sottomodulo: + Aggiorna al branch di tracciamento remoto del sottomodulo URL: Log CANCELLA TUTTO @@ -713,18 +884,23 @@ 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 - APRI STRUMENTO DI MERGE ESTERNO + MERGE + APRI STRUMENTO DI MERGE ESTERNO APRI TUTTI I CONFLITTI NELLO STRUMENTO DI MERGE ESTERNO CONFLITTI NEI FILE RISOLTI USO IL MIO @@ -732,6 +908,8 @@ INCLUDI FILE NON TRACCIATI NESSUN MESSAGGIO RECENTE INSERITO NESSUN TEMPLATE DI COMMIT + No-Verify + Reimposta Autore SignOff IN STAGE RIMUOVI DA STAGE @@ -746,6 +924,8 @@ WORKTREE Copia Percorso Blocca + Apri Rimuovi Sblocca + diff --git a/src/Resources/Locales/ja_JP.axaml b/src/Resources/Locales/ja_JP.axaml index 63e4736d1..844e70874 100644 --- a/src/Resources/Locales/ja_JP.axaml +++ b/src/Resources/Locales/ja_JP.axaml @@ -3,413 +3,644 @@ - 概要 - SourceGitについて - オープンソース & フリーなGit GUIクライアント - ワークツリーを追加 + 情報 + SourceGit について + リリース日: {0} + リリースノート + オープンソース & フリーな Git GUI クライアント + ファイルの変更を無視 + パターン: + 登録先のファイル: + 作業ツリーを追加 場所: - ワークツリーのパスを入力してください。相対パスも使用することができます。 + この作業ツリーへのパス (相対パスも使用できます) ブランチの名前: - 任意。デフォルトでは宛先フォルダ名が使用されます。 + 省略可能 - 既定では宛先のフォルダーと同じ名前が使用されます 追跡するブランチ: - 追跡中のリモートブランチ + 追跡するリモートブランチを設定 チェックアウトする内容: 新しいブランチを作成 既存のブランチ - OpenAI アシスタント + AI アシスタント + モデル 再生成 - OpenAIを使用してコミットメッセージを生成 - コミットメッセージとして適用 - 適用 - パッチファイル: - 適用する .patchファイルを選択 + AI を使用してコミットメッセージを生成 + 使用 + SourceGit を隠す + 他を隠す + すべて表示 + 3 分割マージ + 適用する .patch ファイルを選択 空白文字の変更を無視 + 適用元: + ファイル + クリップボード パッチを適用 空白文字: スタッシュを適用 適用後に削除 インデックスの変更を復元 スタッシュ: - アーカイブ... + アーカイブ化... アーカイブの保存先: - アーカイブファイルのパスを選択 + アーカイブファイルへのパスを選択 リビジョン: - アーカイブ + アーカイブ化 SourceGit Askpass - 変更されていないとみなされるファイル - 変更されていないとみなされるファイルはありません - 更新 - バイナリファイルはサポートされていません!!! - Blame - BLAMEではこのファイルはサポートされていません!!! - ${0}$ をチェックアウトする... - ワークツリーと比較 + パスフレーズを入力: + 未変更と見なしたファイル + 未変更と見なしたファイルはありません + 画像を選択... + 再読み込み + バイナリファイルはサポートされていません!!! + 問題の発生源を特定 + 中止 + 問題あり + 問題なし + スキップ + 問題の発生源となったコミットを特定しています。別のコミットをチェックアウトし、そのコミットに問題があるかどうかを調査してください。 + 問題の発生源となったコミットを特定しています。問題があるコミットをチェックアウト・選択してください。 + 問題の発生源となったコミットを特定しています。現在のコミットに問題があるかどうかを調査したあと、別のコミットをチェックアウトしてみてください。 + 問題の発生源となったコミットを特定しています。現在の HEAD に問題はありませんか? + 著者の履歴 + 前のリビジョンの著者の履歴 + 空白文字の変更を無視 + このファイルに著者の履歴は表示できません!!! + ${0}$ をチェックアウト... + 選択した 2 つのブランチを比較 + 比較対象を選択... + HEAD と比較 + ${0}$ と比較 ブランチ名をコピー + プルリクエストを作成... + 上流の ${0}$ にプルリクエストを作成... カスタムアクション - ${0}$を削除... - 選択中の{0}個のブランチを削除 - ${0}$ へ早送りする - ${0}$ から ${1}$ へフェッチする - Git Flow - Finish ${0}$ - ${0}$ を ${1}$ にマージする... - 選択中の{0}個のブランチを現在のブランチにマージする - ${0}$ をプルする - ${0}$ を ${1}$ にプルする... - ${0}$ をプッシュする - ${0}$ を ${1}$ でリベースする... - ${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が切断されます ローカルの変更: - 破棄 - スタッシュして再適用 ブランチ: - チェリーピック - ソースをコミットメッセージに追加 - コミット(複数可): + 現在の HEAD には、どのブランチやタグにも繋がっていないコミットが含まれています!それでも続行しますか? + これらのサブモジュールを更新する必要があります:{0}更新しますか? + チェックアウト & 早送り + 早送り先: + スタッシュからブランチをチェックアウト + 新しいブランチ: + スタッシュ: + コミットまたはタグをチェックアウト + 対象: + 警告: これを行うと、HEAD が切断されます + コミットを取り込む + 取り込み元をコミットメッセージに明記 + コミット: すべての変更をコミット メインライン: - 通常、マージをチェリーピックすることはできません。どちらのマージ元をメインラインとして扱うべきかが分からないためです。このオプションを使用すると、指定した親に対して変更を再適用する形でチェリーピックを実行できます。 - スタッシュをクリア - すべてのスタッシュをクリアします。続行しますか? + 通常、マージコミットを取り込むことはできません。どちらのマージ元をメインラインとして扱うべきかが分からないためです。このオプションを使用すると、指定した親コミットに対して、変更を再適用する形で取り込みを実行できます。 + スタッシュを消去 + すべてのスタッシュを消去します。よろしいですか? リモートリポジトリをクローン 追加の引数: - リポジトリをクローンする際の追加パラメータ(任意)。 + リポジトリをクローンする際の追加の引数 (省略可能) + ブックマーク: + グループ: ローカル名: - リポジトリの名前(任意)。 - 親フォルダ: + リポジトリの名前 (省略可能) + 親フォルダー: サブモジュールを初期化して更新 - リポジトリのURL: + リポジトリの URL: 閉じる - エディタ - コミットをチェックアウト - このコミットをチェリーピック - チェリーピック... - HEADと比較 - ワークツリーと比較 + エディター + ブランチ + ブランチ & タグ + リポジトリのカスタムアクション + リビジョンのファイル + コミットをチェックアウト... + コミットを取り込む... + 取り込む... + HEAD と比較 + 作業ツリーと比較 + 著者 + 著者の日時 + メッセージ + コミッター + コミット日時 SHA + タイトル カスタムアクション - ${0}$ にマージ + 対話式リベース + 削除... + 編集... + 親コミットに統合... + ${0}$ を ${1}$ で対話式リベース + 書き直す... + 親コミットに記録付きで統合... マージ... - コミットを戻す - 書き直す + ${0}$ を ${1}$ にプッシュ... + ${0}$ を ${1}$ でリベース... + ${0}$ を ${1}$ にリセット... + コミットを取り消す... パッチとして保存... - 親にスカッシュ 変更 + 個の変更されたファイル 変更を検索... + 詳細を折りたたむ ファイル - LFSファイル + LFS ファイル ファイルを検索... サブモジュール コミットの情報 著者 - コミッター - このコミットを含む参照を確認 - コミットが含まれるか確認 - 最初の100件の変更のみが表示されています。すべての変更は'変更'タブで確認できます。 + このコミットが含まれる参照を確認 + これらの参照にコミットが含まれています + メールアドレスをコピー + 名前をコピー + 名前 & メールアドレスをコピー + 最初の 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メールアドレス - Eメールアドレス - GIT - 自動的にリモートからフェッチ 間隔: - 分(s) - リモートの初期値 - ISSUEトラッカー - サンプルのAzure DevOpsルールを追加 - サンプルのGitee Issueルールを追加 - サンプルのGiteeプルリクエストルールを追加 - サンプルのGitHubルールを追加 - サンプルのGitLab Issueルールを追加 - サンプルのGitLabマージリクエストルールを追加 - サンプルのJiraルールを追加 + メールアドレス + E メールアドレス + Git + サブモジュールを自動更新する前に尋ねる + リモートから + 分ごとに自動フェッチ + コンベンショナルコミットの種類定義 + 既定のリモート + サブモジュールを自動更新する際は再帰的に実行 + 優先するマージ方式 + イシュートラッカー + Azure DevOps のルールを追加 + Gerrit Change-Id コミットのルールを追加 + Gitee イシューのルールを追加 + Gitee プルリクエストのルールを追加 + GitHub のルールを追加 + GitLab イシューのルールを追加 + GitLab マージリクエストのルールを追加 + Jira のルールを追加 新しいルール - Issueの正規表現: + イシューの正規表現: ルール名: - リザルトURL: - 正規表現のグループ値に$1, $2を使用してください。 + このルールを .issuetracker ファイルで共有 + 最終的な URL: + 正規表現のグループ値は $1, $2 で取得してください。 AI 優先するサービス: - 優先するサービスが設定されている場合、SourceGitはこのリポジトリでのみそれを使用します。そうでない場合で複数サービスが利用できる場合は、そのうちの1つを選択するためのコンテキストメニューが表示されます。 + '優先するサービス' を設定すると、このリポジトリではそのサービスのみを使用するようになります。そうでなければ、複数のサービスが存在する場合に限り、その中からひとつを選択できるコンテキストメニューが表示されます。 HTTP プロキシ - このリポジトリで使用するHTTPプロキシ + このリポジトリで使用する HTTP プロキシ ユーザー名 このリポジトリにおけるユーザー名 + カスタムアクションのプロンプトを編集 + チェック時の値: + チェックされたときにのみ、この値がコマンドライン引数として渡されます + 説明: + 既定値: + フォルダー選択: + ラベル: + 選択肢: + 選択肢は '|' で区切って記述します + 文字列の整形ツール: + 省略可能 - 最終的な出力文字列を整形するために使用されます (入力がなければ何もしません)。入力された値は `${VALUE}` で参照してください。 + ここでも組み込みの変数 ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, ${TAG} がそのまま利用できます + 種類: + リモート名付きの名前を使用: ワークスペース 名前 起動時にタブを復元 - Conventional Commitヘルパー + 続行 + コミットの内容が何もありません!それでも続行しますか (--allow-empty)? + すべてステージに上げてからコミット + 選択した変更をステージに上げてからコミット + コミットの内容が何もありません!コミットする前に、変更をステージに上げますか?それともこのまま続行しますか (--allow-empty)? + 再起動が必要です + この変更は、アプリを再起動したあとに反映されます。 + コンベンショナルコミットの生成補助ツール 破壊的変更: - 閉じたIssue: + 閉じるイシュー: 詳細な変更: スコープ: - 短い説明: + 簡単な説明: 変更の種類: コピー すべてのテキストをコピー + パッチとしてコピー 絶対パスをコピー パスをコピー ブランチを作成... 派生元: 作成したブランチにチェックアウト ローカルの変更: - 破棄 - スタッシュして再適用 - 新しいブランチの名前: + 新しいブランチ名: ブランチの名前を入力 - スペースはダッシュに置き換えられます。 ローカルブランチを作成 + 既存のブランチを上書き タグを作成... - 付与されるコミット: - GPG署名を使用 + 付与するコミット: + GPG で署名 タグメッセージ: - 任意。 - タグの名前: - 推奨フォーマット: v1.0.0-alpha - 作成後にすべてのリモートにプッシュ - 新しいタグを作成 + 省略可能 + タグ名: + 推奨される形式: v1.0.0-alpha + タグの作成後、すべてのリモートにプッシュ + タグを作成 種類: 注釈付き 軽量 - Ctrlキーを押しながらクリックで実行 + Ctrl キーを押しながらで直接実行できます 切り取り + 破棄 + 何もしない + スタッシュして再適用 + サブモジュールの初期化を解除 + ローカルの変更の有無に関わらず、強制的に解除 + サブモジュール: ブランチを削除 + 次のリモートブランチも削除しますか? ブランチ: - リモートブランチを削除しようとしています!!! - もしリモートブランチを削除する場合、${0}$も削除します。 + 未マージのコミットがあっても強制的に削除 + リモートブランチを削除しようとしています!!! 複数のブランチを削除 - 一度に複数のブランチを削除しようとしています! 操作を行う前に再度確認してください! + 複数のブランチをまとめて削除しようとしています。操作を行う前によく確認してください! + 複数のタグを削除 + リモートからも削除 + 複数のタグをまとめて削除しようとしています。操作を行う前によく確認してください! リモートを削除 リモート: パス: 対象: - すべての子ノードがリストから削除されます。 - これはリストからのみ削除され、ディスクには保存されません! - グループを削除 - リポジトリを削除 + このグループ配下の項目も含め、すべて一覧から削除されます。 + 一覧から削除されるのみで、ディスクから削除されるわけではありません! + グループの削除を確認 + リポジトリの削除を確認 サブモジュールを削除 - サブモジュールのパス: + サブモジュールへのパス: タグを削除 タグ: - リモートリポジトリから削除 + リモートリポジトリからも削除 バイナリの差分 - ファイルモードが変更されました - 先頭の差分 - 空白の変更を無視 + 空のファイル + 最初の差分 + 空白文字の変更を無視 + ブレンド + 色差 + 左右に並べる + スライド 最後の差分 - LFSオブジェクトの変更 + LFS オブジェクトの変更 + 新版 次の差分 - 変更がない、もしくはEOLの変更のみ + 変更なし、または行末コードの変更のみ + 旧版 前の差分 パッチとして保存 - 隠されたシンボルを表示 - 差分の分割表示 + 空白文字を可視化 + 左右に並べて差分を表示 サブモジュール + 削除 新規 - スワップ - シンタックスハイライト - 行の折り返し - ブロックナビゲーションを有効化 + さらに {0} 件のコミットされていない変更があります + 入れ替え + 構文を強調表示 + 行を折り返す マージツールで開く すべての行を表示 表示する行数を減らす 表示する行数を増やす ファイルを選択すると、変更内容が表示されます + ディレクトリの履歴 + ローカルの変更あり + 上流のブランチとの相違あり + すでに最新です 変更を破棄 - ワーキングディレクトリのすべての変更を破棄 + 作業コピーに対するローカルのすべての変更 変更: - 無視したファイルを含める - {0}個の変更を破棄します。 - この操作を元に戻すことはできません!!! + 無視されたファイルを含める + 変更/削除されたファイルを含める + 未追跡のファイルを含める + {0} 件の変更が破棄されます + この操作は元に戻せません!!! + ブランチの説明を編集 + 対象: ブックマーク: 新しい名前: 対象: - 選択中のグループを編集 - 選択中のリポジトリを編集 + 選択したグループを編集 + 選択したリポジトリを編集 + 対象: + このリポジトリ フェッチ すべてのリモートをフェッチ - ローカル参照を強制的に上書き + ローカルの参照を強制的に上書き タグなしでフェッチ リモート: リモートの変更をフェッチ - 変更されていないとみなされる + 未変更と見なす + カスタムアクション 破棄... - {0}個のファイルを破棄... + {0} 個のファイルを破棄... ${0}$ を使用して解決 パッチとして保存... - ステージ - {0}個のファイルをステージ... + ステージに上げる + {0} 個のファイルをステージに上げる スタッシュ... - {0}個のファイルをスタッシュ... - アンステージ - {0}個のファイルをアンステージ... + {0} 個のファイルをスタッシュ... + ステージから降ろす + {0} 個のファイルをステージから降ろす 自分の変更を使用 (checkout --ours) 相手の変更を使用 (checkout --theirs) ファイルの履歴 変更 - コンテンツ - Git-Flow + ファイルの内容 + ファイルモードの変更: + 削除されたファイルのモード: + ディレクトリ + 実行可能 + 新しいファイルのモード: + 通常 + サブモジュール + シンボリックリンク + 不明 + Git フロー 開発ブランチ: - Feature: - Feature プレフィックス: - FLOW - Finish Feature - FLOW - Finish Hotfix - FLOW - Finish Release + 新機能の実装: + 新機能の実装用のプレフィックス: + ${0}$ を完了 + 新機能の実装を完了 + 緊急のバグ修正を完了 + リリース作業を完了 対象: - Hotfix: - Hotfix プレフィックス: - Git-Flowを初期化 - ブランチを保持 - プロダクション ブランチ: - Release: - Release プレフィックス: - Start Feature... - FLOW - Start Feature - Start Hotfix... - FLOW - Start Hotfix + マージする前にリベース + スカッシュしてマージ + 緊急のバグ修正: + 緊急のバグ修正用のプレフィックス: + Git フローを初期化 + ブランチを維持 + 本番ブランチ: + リリース作業: + リリース作業用のプレフィックス: + 開始地点: + 新機能の実装を開始... + 新機能の実装を開始 + 緊急のバグ修正を開始... + 緊急のバグ修正を開始 + 名前: 名前を入力 - Start Release... - FLOW - Start Release - Versionタグ プレフィックス: + リリース作業を開始... + リリース作業を開始 + バージョンタグのプレフィックス: Git LFS - トラックパターンを追加... + 追跡パターンを追加... パターンをファイル名として扱う - カスタム パターン: - Git LFSにトラックパターンを追加 + カスタムパターン: + Git LFS に追跡パターンを追加 フェッチ - `git lfs fetch`を実行して、Git LFSオブジェクトをダウンロードします。ワーキングコピーは更新されません。 - LFSオブジェクトをフェッチ - Git LFSフックをインストール + `git lfs fetch` を実行し、Git LFS オブジェクトをダウンロードします。作業コピーは更新されません。 + LFS オブジェクトをフェッチ + Git LFS フックをインストール ロックを表示 - ロックされているファイルはありません + ロックされたファイルはありません ロック - 私のロックのみ表示 - LFSロック - ロック解除 - 強制的にロック解除 - 削除 - `git lfs prune`を実行して、ローカルの保存領域から古いLFSファイルを削除します。 + 自分のロックのみを表示 + LFS ロック + ロックを解除 + 自分のロックをすべて解除 + 自分でロックしたファイルをすべて解除しますか? + 強制的にロックを解除 + 掃除 + `git lfs prune` を実行し、ローカルの保存領域から古い LFS ファイルを削除します プル - `git lfs pull`を実行して、現在の参照とチェックアウトのすべてのGit LFSファイルをダウンロードします。 - LFSオブジェクトをプル + `git lfs pull` を実行し、現在の参照の Git LFS ファイルをすべてダウンロード & チェックアウトします + LFS オブジェクトをプル プッシュ - キュー内の大容量ファイルをGit LFSエンドポイントにプッシュします。 - LFSオブジェクトをプッシュ + キューにある大容量ファイルを Git LFS エンドポイントにプッシュします + LFS オブジェクトをプッシュ リモート: - {0}という名前のファイルをトラック - すべての*{0}ファイルをトラック + '{0}' という名前のファイルを追跡 + *{0} ファイルをすべて追跡 + コミットを選択 履歴 著者 - 著者時間 + 著者の日時 + コミット日時 グラフ & コミットのタイトル SHA - 日時 + グラフでの強調表示 + 全範囲 + 現在のブランチのみ + 現在のブランチ & 選択したコミット + 選択したコミットのみ {0} コミットを選択しました - 'Ctrl'キーまたは'Shift'キーを押すと、複数のコミットを選択できます。 - ⌘ または ⇧ キーを押して複数のコミットを選択します。 - TIPS: + 表示する列 + 'Ctrl' または 'Shift' キーを押しながらで、複数のコミットを選択できます。 + ⌘ または ⇧ キーを押しながらで、複数のコミットを選択できます。 + ヒント: + 別のウィンドウで開く + コミットの詳細 + リビジョンを比較 キーボードショートカットを確認 総合 - 新しくリポジトリをクローン - 現在のページを閉じる - 次のページに移動 - 前のページに移動 - 新しいページを作成 + 新しいリポジトリをクローン + 現在のタブを閉じる + 次のタブに移動 + 前のタブに移動 + 新しいタブを作成 + ローカルリポジトリを開く 設定ダイアログを開く + ワークスペースのドロップダウンメニューを表示 + アクティブなタブを切り替え + 拡大/縮小 リポジトリ - ステージ済みの変更をコミット - ステージ済みの変更をコミットしてプッシュ - 全ての変更をステージしてコミット - 直接フェッチを実行 - ダッシュボードモード (初期値) + ステージに上げた変更をコミット + ステージに上げた変更をコミットしてプッシュ + すべての変更をステージに上げてコミット + 新しいブランチを作成 + フェッチ (直接実行) + ダッシュボードモード (既定) + 選択したコミットの子コミットに移動 + 選択したコミットの親コミットに移動 + コマンドパレットを開く コミット検索モード - 直接プルを実行 - 直接プッシュを実行 + プル (直接実行) + プッシュ (直接実行) 現在のリポジトリを強制的に再読み込み - '変更'に切り替える - '履歴'に切り替える - 'スタッシュ'に切り替える - テキストエディタ - 検索パネルを閉じる - 次のマッチを検索 - 前のマッチを検索 - 検索パネルを開く + `履歴` の詳細パネルを広げる/折りたたむ + 'ローカルの変更' に切り替え + '履歴' に切り替え + 'スタッシュ' に切り替え 破棄 - ステージ - アンステージ - リポジトリの初期化 + ステージに上げる + ステージから降ろす + リポジトリを初期化 + このパスで `git init` コマンドを実行しますか? + リポジトリを開けませんでした。理由: パス: - チェリーピックが進行中です。'中止'を押すと元のHEADが復元されます。 - コミットを処理中 - マージリクエストが進行中です。'中止'を押すと元のHEADが復元されます。 - マージ中 - リベースが進行中です。'中止'を押すと元のHEADが復元されます。 - 停止しました - 元に戻す処理が進行中です。'中止'を押すと元のHEADが復元されます。 - コミットを元に戻しています - インタラクティブ リベース + 取り込み作業が進行中です。 + 取り込み対象のコミット: + マージ作業が進行中です。 + マージ対象: + リベース作業が進行中です。 + 停止地点: + 取り消し作業が進行中です。 + 取り消し対象のコミット: + 対話式リベース ローカルの変更をスタッシュして再適用 - On: + リベース前のフックを実行しない + リベース地点: + ドラッグ & ドロップでコミットを並べ替えられます 対象のブランチ: リンクをコピー - ブラウザで開く + ブラウザーで開く + コマンド エラー 通知 - ブランチのマージ - 宛先: + リポジトリを開く + タブ + ワークスペース + ブランチをマージ + マージメッセージを編集 + マージ先: マージオプション: - ソースブランチ: + マージ元: + マージができるかを確認しています... + マージは衝突せずに行えます + マージ時に何らかのエラーが発生します + マージに衝突が発生します + 自分の変更 → 相手の変更の順で適用 + 相手の変更 → 自分の変更の順で適用 + 両方を使用 + すべての衝突が解決されました + {0} 件の衝突が残っています + 自分 + 次の衝突 + 前の衝突 + 結果 + 保存 & ステージに上げる + 相手 + マージ衝突 + 保存されていない変更を破棄しますか? + 自分の変更を使用 + 相手の変更を使用 + 元に戻す マージ (複数) すべての変更をコミット - マージ戦略: + マージ方式: 対象: - リポジトリノードの移動 - 親ノードを選択: + サブモジュールを移動 + 移動先: + サブモジュール: + リポジトリを別のグループに移動 + このリポジトリの新しい所属先を選択: 名前: - Gitが設定されていません。まず[設定]に移動して設定を行ってください。 - アプリケーションデータのディレクトリを開く - マージツールで開く - 外部ツールで開く... - 任意。 - 新しいページを開く - ブックマーク + いいえ + Git の設定が行われていません。[設定] を開いて初期設定をしてください。 + 開く + 既定のエディター (システム) + データの保管ディレクトリを開く + ファイルを開く + 外部のマージツールで開く + ローカルリポジトリを開く + ブックマーク: + グループ: + フォルダー: + 省略可能 + 新しいタブを作成 タブを閉じる 他のタブを閉じる - 右のタブを閉じる - リポジトリパスをコピー + 右側のタブを閉じる + リポジトリへのパスをコピー + 編集 + ワークスペースに移動 + 再読み込み リポジトリ 貼り付け {0} 日前 @@ -422,210 +653,262 @@ {0} ヶ月前 {0} 年前 昨日 - 改行には'Shift+Enter'キーを使用します。 'Enter"はOKボタンのホットキーとして機能します。 設定 AI - 差分分析プロンプト - APIキー - タイトル生成プロンプト + 追加のプロンプト (`-` で必要事項を羅列してください) + API キー モデル + 利用できるモデル ID を自動的に取得 名前 + この値を環境変数の名前とし、そこから API キーを読み込む サーバー - ストリーミングを有効化 外観 - デフォルトのフォント - エディタのタブ幅 - フォントサイズ - デフォルト - エディタ + 既定のフォント + エディターのタブ幅 + フォントの大きさ + 既定 + エディター 等幅フォント - テキストエディタでは等幅フォントのみを使用する テーマ テーマの上書き - タイトルバーの固定タブ幅を使用 - ネイティブウィンドウフレームを使用 - 差分/マージ ツール - インストール パス - 差分/マージ ツールのパスを入力 + 自動的にスクロールバーを隠す + タイトルバーに固定タブ幅を使用 + ネイティブなウィンドウフレームを使用 + 差分/マージツール + 差分時の引数 + 利用できる変数: $LOCAL, $REMOTE + マージ時の引数 + 利用できる変数: $BASE, $LOCAL, $REMOTE, $MERGED + インストールパス + 差分/マージツールへのパスを入力 ツール 総合 - 起動時にアップデートを確認 - 日時のフォーマット + 起動時に更新を確認 + 日付の書式 + 変更ツリーのフォルダー階層をまとめる 言語 コミット履歴 - グラフにコミット時間の代わりに著者の時間を表示する - コミット詳細に子コミットを表示 + 初めから `ローカルの変更` ページを表示 + 初めからコミットの詳細の `変更` タブを表示 + コミットグラフを相対時間で表記 コミットグラフにタグを表示 - コミットタイトル枠の大きさ - GIT - 自動CRLFを有効化 - デフォルトのクローンディレクトリ - ユーザー Eメールアドレス - グローバルgitのEメールアドレス - フェッチ時に--pruneを有効化 - Git (>= 2.25.1) はこのアプリで必要です - インストール パス - HTTP SSL 検証を有効にする + 適切とするコミットタイトルの長さ + 24 時間 + コミットグラフのブランチ名をまとめる + GitHub のような既定のアバターを生成 + Git + 自動 CRLF 変換を有効化 + 既定のクローンディレクトリ + ユーザーのメールアドレス + Git ユーザーのメールアドレス (グローバル設定) + フェッチ時に不要なブランチを掃除 + テキストの差分で行末の CR コードを無視 + このアプリには Git (>= 2.25.1) が必須です + インストールパス + HTTP の SSL 検証を有効化 + git-credential-manager ではなく git-credential-libsecret を使用 ユーザー名 - グローバルのgitユーザー名 - Gitバージョン + Git ユーザーの名前 (グローバル設定) + ブランチのチェックアウトやマージ時に、既定で変更をスタッシュして再適用 + Git バージョン GPG 署名 - コミットにGPG署名を行う - GPGフォーマット + コミットを GPG で署名 + GPG 形式 プログラムのインストールパス - インストールされたgpgプログラムのパスを入力 - タグにGPG署名を行う - ユーザー署名キー - ユーザーのGPG署名キー + インストールされている gpg プログラムへのパスを入力 + タグを GPG で署名 + ユーザーの署名鍵 + ユーザーの GPG 署名鍵 統合 - シェル/ターミナル + シェル/端末 + 引数 + 作業ディレクトリの明示には '.' を使用してください パス - シェル/ターミナル - リモートを削除 + シェル/端末 + リモートを掃除 対象: - 作業ツリーを削除 - `$GIT_DIR/worktrees` の作業ツリー情報を削除 + 作業ツリーを掃除 + `$GIT_COMMON_DIR/worktrees` 内の作業ツリー情報を掃除 プル - ブランチ: - 宛先: + リモートブランチ: + プル先: ローカルの変更: - 破棄 - スタッシュして再適用 リモート: プル (フェッチ & マージ) - マージの代わりにリベースを使用 + マージではなくリベースを使用 プッシュ サブモジュールがプッシュされていることを確認 強制的にプッシュ - ローカル ブランチ: + ローカルブランチ: + 新規 リモート: + リビジョン: + リビジョンをリモートにプッシュ 変更をリモートにプッシュ - リモート ブランチ: - 追跡ブランチとして設定 + リモートブランチ: + 追跡するブランチとして設定 すべてのタグをプッシュ - リモートにタグをプッシュ + タグをリモートにプッシュ すべてのリモートにプッシュ リモート: タグ: + 新しいブランチにプッシュ + 新しいリモートブランチ名を入力: 終了 現在のブランチをリベース ローカルの変更をスタッシュして再適用 - On: + リベース前のフックを実行しない + リベース地点: + リベースができるかを確認しています... + リベースは衝突せずに行えます + リベース時に何らかのエラーが発生します + リベースに衝突が発生します リモートを追加 リモートを編集 名前: - リモートの名前 - リポジトリのURL: - リモートのgitリポジトリのURL - URLをコピー + リモート名 + リポジトリの URL: + リモートの Git リポジトリの URL + URL をコピー + カスタムアクション 削除... 編集... + 自動フェッチを有効化 フェッチ - ブラウザで開く - 削除 - ワークツリーの削除を確認 + ブラウザーで開く + 掃除 + 作業ツリーの削除を確認 `--force` オプションを有効化 対象: - ブランチの名前を編集 + ブランチ名を変更 新しい名前: - このブランチにつける一意な名前 + このブランチに付ける一意な名前 ブランチ: 中止 - リモートから変更を自動取得中... - クリーンアップ(GC & Prune) - このリポジトリに対して`git gc`コマンドを実行します。 - すべてのフィルターをクリア - リポジトリの設定 - 続ける + リモートから変更を自動取得しています... + 並べ替え + コミット日時順 + 名前順 + クリーンアップ (GC & Prune) + このリポジトリに `git gc` コマンドを実行します。 + すべて消去 + 消去 + このリポジトリの設定 + 続行 カスタムアクション カスタムアクションがありません + ダッシュボード すべての変更を破棄 ファイルブラウザーで開く ブランチ/タグ/サブモジュールを検索 + グラフでの可視性 解除 - コミットグラフで非表示 - コミットグラフでフィルター + コミットグラフから隠す + コミットグラフで絞り込む レイアウト 水平 垂直 コミットの並び順 - 日時 - トポロジカルソート - ローカル ブランチ - HEADに移動 + コミット日時 + トポロジカル + ローカルブランチ + その他のオプション... + HEAD に移動 ブランチを作成 - 通知をクリア + 通知を消去 + フォルダーとして開く {0} で開く 外部ツールで開く リモート リモートを追加 + 解決 コミットを検索 著者 - コミッター + ファイルの内容 メッセージ + パス SHA 現在のブランチ - タグをツリーとして表示 + 参照付きのコミットのみ + 最初の親コミットのみ + 表示フラグ + 消失したコミットを表示 + サブモジュールをツリー形式で表示 + タグをツリー形式で表示 スキップ 統計 サブモジュール サブモジュールを追加 サブモジュールを更新 タグ - 新しいタグを作成 - 作成者日時 - 名前 - ソート - ターミナルで開く - ワークツリー - ワークツリーを追加 - 削除 - GitリポジトリのURL + タグを作成 + 作成者の日時順 + 名前順 + 並べ替え + 端末で開く + ログを表示 + ブラウザーで '{0}' を訪問 + 作業ツリー + 作業ツリーを追加 + 掃除 + Git リポジトリの URL 現在のブランチをリビジョンにリセット - リセットモード: + リセット方式: 移動先: 現在のブランチ: + ブランチをリセット (チェックアウトなし) + 移動先: + ブランチ: ファイルエクスプローラーで表示 - コミットを戻す + コミットを取り消す コミット: - コミットの変更を戻す - コミットメッセージを書き直す - 実行中です。しばらくお待ちください... + 取り消しの変更をコミット + 実行しています。お待ちください... 保存 名前を付けて保存... パッチが正常に保存されました! リポジトリをスキャン ルートディレクトリ: - 更新を確認 - 新しいバージョンのソフトウェアが利用可能です: + 任意の別ディレクトリをスキャン + 更新を確認... + このソフトウェアの新しいバージョンが利用できます: + 現在のバージョン: 更新の確認に失敗しました! ダウンロード このバージョンをスキップ + 新しいバージョンのリリース日: ソフトウェアの更新 - 利用可能なアップデートはありません - トラッキングブランチを設定 + 利用できる更新はありません。 + サブモジュールのブランチを設定 + サブモジュール: + 現在: + 変更先: + 省略可能 - 空欄で既定値を使用します + 追跡するブランチを設定 ブランチ: - 上流ブランチを解除 - 上流ブランチ: - SHAをコピー - Go to - スカッシュコミット - 宛先: - SSH プライベートキー: - プライベートSSHキーストアのパス - スタート + 上流のブランチを解除 + 上流のブランチ: + SHA をコピー + このコミットに移動 + SSH の秘密鍵: + SSH の秘密鍵が保管されているパス + 開始 スタッシュ - 追跡されていないファイルを含める + 未追跡のファイルを含める メッセージ: - オプション. このスタッシュの情報 - ステージされた変更のみ - 選択したファイルの、ステージされた変更とステージされていない変更の両方がスタッシュされます!!! + 省略可能 - このスタッシュのメッセージ + 方式: + ステージに上げたファイルのみ + 選択したファイルの、ステージに上がっている変更とそうではない変更の両方がスタッシュされます!!! ローカルの変更をスタッシュ 適用 - 破棄 - パッチとして保存 - スタッシュを破棄 - 破棄: + 変更を適用 + 新しいブランチをチェックアウト + メッセージをコピー + 削除 + パッチとして保存... + スタッシュを削除 + 削除: スタッシュ 変更 スタッシュ @@ -637,70 +920,119 @@ コミット: サブモジュール サブモジュールを追加 + ブランチ + ブランチ 相対パス - ネストされたサブモジュールを取得する - サブモジュールのリポジトリを開く + 初期化を解除 + 入れ子になったサブモジュールも取得 + 履歴 + 移動 + リポジトリを開く 相対パス: - このモジュールを保存するフォルダの相対パス - サブモジュールを削除 + このモジュールを保存するフォルダーへの相対パス + 削除 + ブランチを設定 + URL を変更 + 状態 + 変更あり + 未初期化 + リビジョンに更新あり + 未マージ + 更新 + URL + サブモジュールの変更の詳細 + 詳細を開く OK - タグ名をコピー - タグメッセージをコピー + タグの作成者 + 日時 + タグをチェックアウト... + 2 つのタグを比較 + 比較対象を選択... + HEAD と比較 + メッセージ + 名前 + タグの作成者 + タグ名をコピー + カスタムアクション ${0}$ を削除... + 選択した {0} 個のタグを削除... ${0}$ を ${1}$ にマージ... ${0}$ をプッシュ... サブモジュールを更新 すべてのサブモジュール 必要に応じて初期化 - 再帰的に更新 サブモジュール: + 入れ子になったサブモジュールも更新 + サブモジュールのリモート追跡ブランチに更新 URL: + ログ + すべて消去 + コピー + 削除 警告 - ようこそ + ようこそページ グループを作成 サブグループを作成 - リポジトリをクローンする + リポジトリをクローン 削除 - ドラッグ & ドロップでフォルダを追加できます. グループを作成したり、変更したりできます。 + ドラッグ & ドロップでフォルダーを追加できます。また、グループの作成・編集もできます。 編集 別のグループに移動 すべてのリポジトリを開く リポジトリを開く - ターミナルを開く - デフォルトのクローンディレクトリ内のリポジトリを再スキャン + 端末を開く + 既定のクローンディレクトリ内のリポジトリを再スキャン リポジトリを検索... - 変更 - Git Ignore - すべての*{0}ファイルを無視 - 同じフォルダ内の*{0}ファイルを無視 + ローカルの変更 + 無視 + *{0} ファイルをすべて無視 + 同じフォルダーの *{0} ファイルを無視 + このフォルダーで未追跡のファイルを無視 このファイルのみを無視 - Amend - このファイルを今すぐステージできます。 + 同じフォルダーで未追跡のファイルをすべて無視 + 最後のコミットをやり直す + このファイルをステージに上げられるようになりました。 + 履歴を消去 + コミットメッセージの履歴をすべて消去しますか?この操作は元に戻せません。 コミット コミットしてプッシュ - メッセージのテンプレート/履歴 - クリックイベントをトリガー - コミット (Edit) - すべての変更をステージしてコミット - 競合が検出されました - ファイルの競合は解決されました - 追跡されていないファイルを含める + テンプレート/履歴 + クリックイベントを発動 + コミット (編集) + すべての変更をステージに上げてコミット + 切断された 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 87a14a3af..8155dc647 100644 --- a/src/Resources/Locales/pt_BR.axaml +++ b/src/Resources/Locales/pt_BR.axaml @@ -5,7 +5,12 @@ Sobre Sobre o SourceGit + Data de Lançamento + Notas de Lançamento Cliente Git GUI Livre e de Código Aberto + Adicionar Arquivo(s) ao Ignore + Padrão: + Salvar arquivo: Adicionar Worktree Localização: Caminho para este worktree. Caminho relativo é suportado. @@ -17,42 +22,69 @@ Criar Novo Branch Branch Existente Assietente IA + Modelo + Gerar novamente Utilizar IA para gerar mensagem de commit - Patch - Arquivo de Patch: + Esconder SourceGit + Mostrar Todos Selecione o arquivo .patch para aplicar Ignorar mudanças de espaço em branco Aplicar Patch Espaço em Branco: + Aplicar Stash + Apagar depois de aplicar + Restabelecer as alterações do índice + Stash: Arquivar... Salvar Arquivo Como: Selecione o caminho do arquivo de arquivo Revisão: Arquivar SourceGit Askpass + Entre com a senha ARQUIVOS CONSIDERADOS SEM ALTERAÇÕES NENHUM ARQUIVO CONSIDERADO SEM ALTERAÇÕES + Carregar imagem... Atualizar ARQUIVO BINÁRIO NÃO SUPORTADO!!! + Bisect + Abortar + Inválido + Válido + Pular + Bisect. Marcar o commit atual como válido ou inválido e checar outro commit. + Bisect. O HEAD atual é válido ou inválido? Blame - BLAME NESTE ARQUIVO NÃO É SUPORTADO!!! + Ignorar espaços em branco + BLAME NESSE ARQUIVO NÃO É SUPORTADO!!! Checkout ${0}$... - Comparar com ${0}$ - Comparar com Worktree + Comparar ambos os branches selecionados + Comparar com... + Comparar com HEAD Copiar Nome do Branch + Criar PR... + Criar PR para o upstream ${0}$... + Ação personalizada Excluir ${0}$... Excluir {0} branches selecionados + Editar descri ção para %{0}%... Fast-Forward para ${0}$ Buscar ${0}$ em ${1}$... Git Flow - Finalizar ${0}$ Mesclar ${0}$ em ${1}$... + Mesclar os {0} branches no branch atual Puxar ${0}$ Puxar ${0}$ para ${1}$... Subir ${0}$ Rebase ${0}$ em ${1}$... Renomear ${0}$... + Restaurar ${0}$ para ${1}$... + Mudar para ${0}$ (worktree) Definir Branch de Rastreamento... - Comparação de Branches + {0} commit(s) à frente + Inválido + REMOTO + ESTADO CANCELAR Resetar para Revisão Pai Resetar para Esta Revisão @@ -62,12 +94,7 @@ Exibir como Lista de Caminhos Exibir como Árvore de Sistema de Arquivos Checkout Branch - Checkout Commit - Commit: - Aviso: Ao fazer o checkout de um commit, seu Head ficará desanexado Alterações Locais: - Descartar - Stash & Reaplicar Branch: Cherry-Pick Adicionar origem à mensagem de commit @@ -94,9 +121,7 @@ SHA Ação customizada Reverter Commit - Modificar Mensagem Salvar como Patch... - Mesclar ao Commit Pai ALTERAÇÕES Buscar Alterações... ARQUIVOS @@ -113,8 +138,7 @@ REFERÊNCIAS SHA Abrir no navegador - Descrição - Insira o assunto do commit + Comparação Configurar Repositório TEMPLATE DE COMMIT Conteúdo do Template: @@ -168,8 +192,6 @@ Baseado Em: Checar o branch criado Alterações Locais: - Descartar - Guardar & Reaplicar Nome do Novo Branch: Insira o nome do branch. Criar Branch Local @@ -187,10 +209,12 @@ leve 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 Múltiplos Branches Você está tentando excluir vários branches de uma vez. Certifique-se de verificar antes de agir! Excluir Remoto @@ -204,7 +228,6 @@ Tag: Excluir dos repositórios remotos DIFERENÇA BINÁRIA - Modo de Arquivo Alterado Ignorar mudanças de espaço em branco MUDANÇA DE OBJETO LFS Próxima Diferença @@ -307,9 +330,9 @@ 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. @@ -334,11 +357,6 @@ Alternar para 'Mudanças' Alternar para 'Históricos' Alternar para 'Stashes' - EDITOR DE TEXTO - Fechar painel de busca - Encontrar próxima correspondência - Encontrar correspondência anterior - Abrir painel de busca Descartar Preparar Despreparar @@ -365,14 +383,13 @@ O Git NÃO foi configurado. Por favor, vá para [Preferências] e configure primeiro. Abrir Pasta de Dados do Aplicativo Abrir na Ferramenta de Mesclagem - Abrir Com... 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 @@ -385,13 +402,9 @@ {0} meses atrás {0} anos atrás Ontem - Use 'Shift+Enter' para inserir uma nova linha. 'Enter' é a tecla de atalho do botão OK Preferências INTELIGÊNCIA ARTIFICIAL - Prompt para Analisar Diff Chave da API - Prompt para Gerar Título - Modelo Nome Servidor APARÊNCIA @@ -400,7 +413,6 @@ Padrão Editor Fonte Monoespaçada - Usar fonte monoespaçada apenas no editor de texto Tema Substituições de Tema Usar largura fixa de aba na barra de título @@ -413,7 +425,6 @@ Verificar atualizações na inicialização Idioma Commits do Histórico - Exibir data do autor em vez da data do commit no gráfico Comprimento do Guia de Assunto GIT Habilitar Auto CRLF @@ -446,8 +457,6 @@ Branch Remoto: Para: Alterações Locais: - Descartar - Guardar & Reaplicar Remoto: Puxar (Buscar & Mesclar) Usar rebase em vez de merge @@ -513,7 +522,6 @@ ADICIONAR REMOTO Pesquisar Commit Autor - Committer Mensagem SHA Branch Atual @@ -537,7 +545,6 @@ Reverter Commit Commit: Commitar alterações de reversão - Reescrever Mensagem do Commit Executando. Por favor, aguarde... SALVAR Salvar Como... @@ -552,8 +559,6 @@ Atualização de Software Não há atualizações disponíveis no momento. Copiar SHA - Squash Commits - Squash commits em: Chave SSH Privada: Caminho para a chave SSH privada INICIAR @@ -586,15 +591,11 @@ Pasta relativa para armazenar este módulo. Excluir Submódulo OK - Copiar Nome da Tag - Copiar mensage da Tag Excluir ${0}$... - Mesclar ${0}$ em ${1}$... Enviar ${0}$... Atualizar Submódulos Todos os submódulos Inicializar conforme necessário - Recursivamente Submódulo: URL: Aviso diff --git a/src/Resources/Locales/ru_RU.axaml b/src/Resources/Locales/ru_RU.axaml index 568c07ef1..525796412 100644 --- a/src/Resources/Locales/ru_RU.axaml +++ b/src/Resources/Locales/ru_RU.axaml @@ -5,6 +5,8 @@ О программе О SourceGit + Дата выпуска: {0} + Примечания выпуска Бесплатный графический клиент Git с исходным кодом Добавить файл(ы) к игнорируемым Шаблон: @@ -13,20 +15,26 @@ Расположение: Путь к рабочему каталогу (поддерживается относительный путь) Имя ветки: - Имя целевого каталога по умолчанию. (необязательно) + Имя целевого каталога по умолчанию (необязательно) Отслеживание ветки: Отслеживание внешней ветки Переключиться на: Создать новую ветку Ветку из списка Помощник OpenAI + Модель ПЕРЕСОЗДАТЬ Использовать OpenAI для создания сообщения о ревизии - ПРИМЕНИТЬ КАК СООБЩЕНИЕ РЕВИЗИИ - Исправить - Файл заплатки: + Использовать + Скрыть SourceGit + Скрыть остальные + Показать все + Трехстороннее слияние Выберите файл .patch для применения Игнорировать изменения пробелов + Источник: + Файл + Буфер обмена Применить заплатку Пробел: Отложить @@ -48,22 +56,32 @@ Раздвоить О Плохая - Раздвоение. Текущая ГОЛОВА (HEAD) хорошая или плохая? Хорошая Пропустить - Раздвоение. Сделать текущую ревизию хорошей или плохой и переключиться на другой. + Раздвоение. Пожалуйста, переключитесь на другой, затем отметьте его как хороший или плохой. + Раздвоение. Отметить текущую ревизию как плохую или переключиться на другую, а затем отметить её как плохую. + Раздвоение. Отметить текущую ревизию хорошей или плохой и переключиться на другой. + Раздвоение. Текущая ГОЛОВА (HEAD) хорошая или плохая? Расследование - РАССЛЕДОВАНИЕ В ЭТОМ ФАЙЛЕ НЕ ПОДДЕРЖИВАЕТСЯ!!! + Расследование на предыдущей редакции + Игнорировать изменения пробелов + РАССЛЕДОВАНИЕ В ЭТОМ ФАЙЛЕ НЕ ПОДДЕРЖИВАЕТСЯ!!! Переключиться на ${0}$... - Сравнить с ${0}$ - Сравнить с рабочим каталогом + Сравнить две выбранные ветки + Сравнить с ... + Сравнить с ГОЛОВОЙ + Сравнить с ${0}$ Копировать имя ветки + Создать PR... + Создать PR для основной ветки ${0}$... Изменить действие Удалить ${0}$... Удалить выбранные {0} ветки + Править описание для ${0}$... Перемотать вперёд к ${0}$ Извлечь ${0}$ в ${1}$... Git-процесс - Завершение ${0}$ + Интерактивное перемещение ${0}$ в ${1}$ Влить ${0}$ в ${1}$... Влить {0} выделенных веток в текущую Загрузить ${0}$ @@ -72,13 +90,24 @@ Переместить ${0}$ на ${1}$... Переименовать ${0}$... Сбросить ${0}$ к ${1}$... + Переключить на ${0}$ (рабочий каталог) Отслеживать ветку... - Сравнение веток - Недопустимая основная ветка! + {0} ревизий вперёд + {0} ревизий вперёд, {1} ревизий назад + {0} ревизий назад + Неверно + УДАЛЁННЫЙ + СОСТОЯНИЕ + ОТСЛЕЖИВАНИЕ + URL-АДРЕС + РАБОЧИЙ КАТАЛОГ ОТМЕНА Сбросить родительскую ревизию Сбросить эту ревизию Произвести сообщение о ревизии + Слить (встроенный) + Слить (внешний) + Сбросить файл(ы) в ${0}$ ИЗМЕНИТЬ РЕЖИМ ОТОБРАЖЕНИЯ Показывать в виде списка файлов и каталогов Показывать в виде списка путей @@ -87,17 +116,18 @@ Подмодуль: URL-адрес: Переключить ветку - Переключение ревизии - Ревизия: - Предупреждение: После переключения ревизии ваша Голова (HEAD) будет отсоединена Локальные изменения: - Отклонить - Отложить и примненить повторно - Обновить все подкаталоги Ветка: Ваша текущая ГОЛОВА содержит ревизию(и), не связанные ни с к какими ветками или метками! Вы хотите продолжить? + Подмодулям требуется обновление:{0}Обновить их? Переключиться и перемотать Перемотать к: + Переключить ветку из отложенного + Новая ветка: + Отложенный: + Переключить ревизию или метку + Цель: + Предупреждение: После этого ГОЛОВА будет отсоединена Частичный выбор Добавить источник для ревизии сообщения Ревизия(и): @@ -107,46 +137,63 @@ Очистить отложенные Вы пытаетесь очистить все отложенные. Вы уверены, что хотите продолжить? Клонировать внешний репозиторий - Расширенные параметры: - Дополнительные аргументы для клонирования репозитория. (необязательно). + Параметры: + Аргументы git clone (необязательно) + Закладка: + Группа: Локальное имя: - Имя репозитория. (необязательно). + Имя репозитория (необязательно) Родительский каталог: - Создать и обновить подмодуль + Создать и обновить подмодули Адрес репозитория: ЗАКРЫТЬ Редактор + Ветки + Ветки и метки + Пользовательские действия репозитория + Ревизия файлов Переключиться на эту ревизию Применить эту ревизию (cherry-pick) Применить несколько ревизий ... Сравнить c ГОЛОВОЙ (HEAD) Сравнить с рабочим каталогом Автор + Автор (время) Сообщение Ревизор + Ревизор (время) SHA Субъект Пользовательское действие - Влить в ${0}$ + Интерактивное перемещение + Бросить... + Редактировать... + Исправить в родительском... + Интерактивное перемещение ${0}$ в ${1}$ + Изменить комментарий... + Втиснуть в родительский... Влить ... Выложить ${0}$ в ${1}$ + Переместить ${0}$ в ${1}$ + Сбросить ${0}$ к ${1}$ Отменить ревизию - Изменить комментарий Сохранить как заплатки... - Объединить с предыдущей ревизией ИЗМЕНЕНИЯ изменённый(х) файл(ов) Найти изменения.... + Свернуть подробности ФАЙЛЫ Файл LFS Поиск файлов... Подмодуль ИНФОРМАЦИЯ АВТОР - ДОЧЕРНИЙ РЕВИЗОР (ИСПОЛНИТЕЛЬ) Найти все ветки с этой ревизией ВЕТКИ С ЭТОЙ РЕВИЗИЕЙ + Копировать адрес почты + Копировать имя + Копировать имя и адрес почты Отображаются только первые 100 изменений. Смотрите все изменения на вкладке ИЗМЕНЕНИЯ. Ключ: СООБЩЕНИЕ @@ -155,37 +202,64 @@ SHA Подписант: Открыть в браузере - Описание + СТБ + Введите сообщение ревизии. Пожалуйста, используйте пустые строки для разделения субъекта и описания! СУБЪЕКТ - Введите тему ревизии + Сравнить + ИЗМЕНЕНИЯ + РЕВИЗИИ + РЕВИЗИИ ТОЛЬКО СЛЕВА + РЕВИЗИИ ТОЛЬКО СПРАВА + Подсказка: Если другая сторона является ГОЛОВОЙ (используя контекстное меню), вы можете использовать частично выбранные ревизии (cherry-pick). Настройка репозитория ШАБЛОН РЕВИЗИИ - Вы можете использовать ${files_num}, ${branch_name}, ${files} и ${files:N}, где N — максимальное количество путей к файлам для вывода. + Встроенные параметры: + + ${branch_name} Имя текущей локальной ветки + ${files_num} Количество изменённых файлов + ${files} Пути изменённых файлов + ${files:N} Пути изменённых файлов, не более N + ${pure_files} То же, что и ${files}, но только имена файлов + ${pure_files:N} То же, что и ${files:N}, но только имена файлов Cодержание: Название: ПОЛЬЗОВАТЕЛЬСКОЕ ДЕЙСТВИЕ Аргументы: - Встроенные параметры: ${REPO} — путь к репозиторию; ${BRANCH} — выбранная ветка; ${SHA} — хеш выбранной ревизии; ${TAG} — выбранная метка + Встроенные параметры: + + ${REPO} Путь репозитория + ${REMOTE} Выбранная удаённая ветка + ${BRANCH} Выбранная ветка, без ${REMOTE} удалённых веток + ${BRANCH_FRIENDLY_NAME} Понятное имя выбранной ветки, содержащую ${REMOTE} удалённые ветки + ${SHA} Хеш выбранной ревизии + ${TAG} Выбранная метка + ${FILE} Выбранный файл, относительно корня репозитория + $1, $2 ... Ввод управляющих значений Исполняемый файл: Элементы управления вводом: Редактор - Вы можете использовать $1, $2 ... в аргументах для значений элемента управления вводом Имя: Диапазон: Ветка Ревизия + Файл + Удалённый Репозиторий Метка Ждать для выполения выхода Адрес электронной почты Адрес электронной почты GIT + Спрашивать перед автоматическим обновлением подмодулей. Автозагрузка изменений Минут(а/ы) + Общепринятые типы ревизии Внешний репозиторий по умолчанию + Включить (--recursive) при автоматическом обновлении подмодулей Предпочтительный режим слияния ОТСЛЕЖИВАНИЕ ПРОБЛЕМ Добавить пример правила Azure DevOps + Добавить правило Gerrit ревизии идентификатора изменения Добавить пример правила для тем в Gitea Добавить пример правила запроса скачивания из Gitea Добавить пример правила для Git @@ -195,6 +269,7 @@ Новое правило Проблема с регулярным выражением: Имя правила: + Опубликовать это правило в файле .issuetracker Адрес результата: Пожалуйста, используйте $1, $2 для доступа к значениям групп регулярных выражений. ОТКРЫТЬ ИИ @@ -213,7 +288,11 @@ Метка: Опции: Используйте разделитель «|» для опций + Строка форматирования: + Не обязательно. Использовать для форматирования выходной строки. Игнорировать пустые входные данные. Пожалуйста, используйте «${VALUE}» для представления входной строки. + Встроенные переменные ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, и ${TAG} останутся здесь доступными Тип: + Используйте понятное имя: Рабочие пространства Цвет Имя @@ -221,7 +300,8 @@ ПРОДОЛЖИТЬ Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty)? Сформировать всё и зафиксировать ревизию - Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty) или отложить всё, затем зафиксировать ревизию? + Сформировать выбранные и зафиксировать + Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty) или отложить всё, а затем зафиксировать ревизию? Требуется перезапуск Вы должны перезапустить приложение после применения изменений. Общепринятый помощник по ревизии @@ -233,17 +313,15 @@ Тип изменения: Копировать Копировать весь текст + Копировать как исправление Копировать полный путь Копировать путь Создать ветку... Основан на: Переключиться на созданную ветку Локальные изменения: - Отклонить - Отложить и применить повторно Имя новой ветки: Введите имя ветки. - Пробелы будут заменены на тире. Создать локальную ветку Перезаписать существующую ветку Создать метку... @@ -260,15 +338,22 @@ Простой Удерживайте Ctrl, чтобы сразу начать Вырезать + Отклонить + Ничего не делать + Отложить и примненить повторно Удалить подмодуль Принудительно удалить даже если содержит локальные изменения. Подмодуль: Удалить ветку + Вы хотите также удалить следующую удалённую ветку? Ветка: + Принудительно удалить, даже если содержит неслитые ревизии Вы собираетесь удалить внешнюю ветку!!! - Также удалите внешнюю ветку ${0}$ Удаление нескольких веток Вы пытаетесь удалить несколько веток одновременно. Обязательно перепроверьте, прежде чем предпринимать какие-либо действия! + Удалить несколько меток + Удалить их с удалённого + Вы пытаетесь удалить сразу несколько меток. Перепроверьте обязательно перед выполнением! Удалить внешний репозиторий Внешний репозиторий: Путь: @@ -283,10 +368,11 @@ Метка: Удалить из внешнего репозитория СРАВНЕНИЕ БИНАРНИКОВ - Режим файла изменён + ФАЙЛ ПУСТ Первое сравнение Игнорировать изменения пробелов СМЕСЬ + СРАВНЕНИЕ РЯДОМ СМАХИВАНИЕ Последнее сравнение @@ -302,22 +388,29 @@ ПОДМОДУЛЬ УДАЛЁН НОВЫЙ + + {0} Незафиксированных изменний Обмен Подсветка синтаксиса Перенос слов в строке - Разрешить навигацию по блокам Открыть в инструменте слияния Показывать все строки Меньше видимых строк Больше видимых строк ВЫБЕРИТЕ ФАЙЛ ДЛЯ ПРОСМОТРА ИЗМЕНЕНИЙ Каталог историй + Есть локальные изменения + Не соответствует с исходящим потоком + В актуальном состоянии Отклонить изменения Все локальные изменения в рабочей копии. Изменения: Включить игнорируемые файлы + Включить изменённые/удалённые файлы + Включить неотслеживаемые файлы {0} изменений будут отменены Вы не можете отменить это действие!!! + Править описание ветки + Цель: Закладка: Новое имя: Цель: @@ -332,30 +425,41 @@ Внешний репозиторий: Извлечь внешние изменения Не отслеживать + Пользовательское действие Отклонить... - Отклонить {0} файлов... + Отклонить файлы ({0})... Взять версию ${0}$ Сохранить как файл заплатки... Сформировать - Сформированные {0} файлы + Сформировать файлы ({0}) Отложить... - Отложить {0} файлов... + Отложить файлы ({0})... Расформировать - Несформированные {0} файлы + Расформировать файлы ({0}) Использовать мой (checkout --ours) Использовать их (checkout --theirs) История файлов ИЗМЕНИТЬ СОДЕРЖИМОЕ + Изменение режима файла: + Режим удалённого файла: + Каталог + Исполняемый + Новый режим файла: + Обычный + Подмодуль + Символическая сскла + Неизвестно Git-процесс Ветка разработчика: Свойство: Свойство префикса: + Завершение ${0}$ ПРОЦЕСС - Свойства завершения ПРОЦЕСС - Закончить исправление ПРОЦЕСС - Завершить выпуск Цель: - Выложить на удалённый(ые) после завершения + Перенос перед слиянием Втиснуть при слиянии Исправление: Префикс исправлений: @@ -364,10 +468,12 @@ Производственная ветка: Выпуск: Префикс выпуска: + Начать с: Свойство запуска... ПРОЦЕСС - Свойство запуска Запуск исправлений... ПРОЦЕСС - Запуск исправлений + Имя: Ввести имя Запуск выпуска... ПРОЦЕСС - Запуск выпуска @@ -387,6 +493,8 @@ Показывать только мои блокировки Блокировки LFS Разблокировать + Снять все мои блокировки + Вы уверены, что хотите снять все свои блокировки? Принудительно разблокировать Обрезать Запустить (git lfs prune), чтобы удалить старые файлы LFS из локального хранилища @@ -398,50 +506,63 @@ Выложить объекты LFS Внешнее хранилище: Отслеживать файлы с именем «{0}» - Отслеживать все *{0} файлов + Отслеживать все файлы (*{0}) + Выбрать ревизию Истории АВТОР ВРЕМЯ АВТОРА + ВРЕМЯ РЕВИЗИИ ГРАФ И СУБЪЕКТ SHA - ВРЕМЯ РЕВИЗИИ - ВЫБРАННЫЕ {0} РЕВИЗИИ + ПОДСВЕТКА В ГРАФЕ + ВСЕ + Только текущая ветка + Текущая ветка и выбранные ревизии + Только выбранные ревизии + ВЫБРАНО РЕВИЗИЙ: {0} + ПОКАЗЫВАТЬ КОЛОНКИ Удерживайте Ctrl или Shift, чтобы выбрать несколько ревизий. Удерживайте ⌘ или ⇧, чтобы выбрать несколько ревизий. ПОДСКАЗКИ: - Ссылка на сочетания клавиш - ОБЩЕЕ + Открыть в отдельном окне + Подробности ревизии + Сравнить ревизию + Справка по сочетаниям клавиш + ГЛОБАЛЬНЫЕ Клонировать репозиторий - Закрыть вкладку + Закрыть текущую вкладку Перейти на следующую вкладку Перейти на предыдущую вкладку Создать новую вкладку + Открыть локальный репозиторий Открыть диалоговое окно настроек - Переключить активное рабочее место - Переключить активную страницу + Показать рабочее пространство в выпадющем меню + Переключиться на вкладку + Масштабирование РЕПОЗИТОРИЙ Зафиксировать сформированные изменения Зафиксировать и выложить сформированные изменения Сформировать все изменения и зафиксировать - Извлечение, запускается сразу + Создать новую ветку + Извлечь (fetch), запускается сразу Режим доски (по умолчанию) + К дочернему выбранной ревизии + К родительскому выбранной ревизии + Открыть палитру команд Режим поиска ревизий - Загрузить, запускается сразу - Выложить, запускается сразу - Принудительно перезагрузить репозиторий + Загрузить (pull), запускается сразу + Выложить (push), запускается сразу + Принудительно перечитать репозиторий + Развернуть/Свернуть панель подробностей в «ИСТОРИИ» Переключить на «Изменения» - Переключить на «Истории» + Переключить на «Историю» Переключить на «Отложенные» - ТЕКСТОВЫЙ РЕДАКТОР - Закрыть панель поиска - Найти следующее совпадение - Найти предыдущее совпадение - Открыть с внешним инструментом сравнения/слияния - Открыть панель поиска Отклонить Сформировать Расформировать Создать репозиторий + Вы действительно хотите запуститб команду «git init» по этому пути? + Не удалось открыть репозиторий. Причина: Путь: Выполняется частичный перенос ревизий (cherry-pick). Обрабтка ревизии. @@ -453,20 +574,44 @@ Выполняется отмена Интерактивное перемещение Отложить и применить повторно локальные изменения + Обходной зацеп предварительного перемещения На: Перетаскивайте для переупорядочивания ревизий Целевая ветка: Копировать ссылку Открыть в браузере + Команды ОШИБКА УВЕДОМЛЕНИЕ + Доступна новая версия + Открыть репозитории + Вкладки Рабочие места - Страницы Влить ветку Изменить сообщение слияния В: Опции слияния: Источник: + Тестировать на слияние... + Слияние может быть выполнено без конфликтов + Неизвестная ошибка при тестировании слияния + Слияние вызовет конфликты + Сначала мои, а потом их + Сначала их, а потом мои + ИСПОЛЬЗОВАТЬ ОБА + Все конфликты разрешены + Осталось конфликтов: {0} + MINE + Следующий конфликт + Предыдущий конфликт + РЕЗУЛЬТАТ + СОХРАНИТЬ И СФОРМИРОВАТЬ + ИХ + Конфликты при слиянии + Отменить несохранённые изменения? + ИСПОЛЬЗОВАТЬ МОИ + ИСПОЛЬЗОВАТЬ ИХ + Отменить Влить несколько веток Зафиксировать все изменения Стратегия: @@ -477,17 +622,26 @@ Переместить репозиторий в другую группу Выбрать группу для: Имя: + Нет Git НЕ был настроен. Пожалуйста, перейдите в [Настройки] и сначала настройте его. + Открыть + Редактор по умолчанию (Системный) Открыть каталог данных программы + Открыть файл Открыть в инструменте слияния - Окрыть с... + Открыть локальный репозиторий + Закладка: + Группа: + Каталог: Необязательно. - Создать новую страницу - Закладка + Создать новую вкладку Закрыть вкладку Закрыть другие вкладки Закрыть вкладки справа Копировать путь репозитория + Редактировать... + Переместить в рабочее пространство + Обновить Репозитории Вставить {0} дней назад @@ -500,16 +654,15 @@ {0} месяцев назад {0} лет назад Вчера - Используйте «Shift+Enter» для ввода новой строки. «Enter» - это горячая клавиша кнопки «OK» Параметры ОТКРЫТЬ ИИ - Запрос на анализ сравнения + Дополнительная подсказка (Для перечисления ваших требований используйте `-`) Ключ API - Создать запрос на тему Модель + Автоматическое получение доступных идентификаторов моделей Имя: + Введённое значение — это имя для загрузки API-ключа из ENV Сервер - Разрешить потоковую передачу ВИД Шрифт по умолчанию Редактировать ширину вкладки @@ -517,24 +670,33 @@ По умолчанию Редактор Моноширный шрифт - В текстовом редакторе используется только моноширный шрифт Тема Переопределение темы + Автоматически скрывать прокрутку Использовать фиксированную ширину табуляции в строке заголовка. Использовать системное окно ИНСТРУМЕНТ СРАВНЕНИЙ/СЛИЯНИЯ + Аргументы сравнения + Доступны переменные: $LOCAL, $REMOTE + Слить аргументы + Доступны переменные: $BASE, $LOCAL, $REMOTE, $MERGED Путь установки Введите путь для инструмента сравнения/слияния Инструмент ОСНОВНЫЕ Проверить обновления при старте Формат даты + Включить компактные каталоги в дереве изменений Язык Максимальная длина истории - Показывать время автора вместо времени ревизии на графике - Показать наследника в деталях комментария + Показывать вкладку «ЛОКАЛЬНЫЕ ИЗМЕНЕНИЯ» по умолчанию + Показывать вкладку «Изменения» в сведении ревизии по умолчанию + Отображение относительного времени на графе ревизии Показывать метки на графике Длина темы ревизии + 24-часовой + Использовать компактные имена веток в графе ревизии + Создать Github-подобный аватар по умолчанию GIT Включить автозавершение CRLF Каталог клонирования по умолчанию @@ -545,8 +707,10 @@ Для работы программы требуется версия Git (>= 2.25.1) Путь установки Разрешить верификацию HTTP SSL + Использовать git-credential-libsecret вместо git-credential-manager Имя пользователя Общее имя пользователя git + Отложить и повторно применить изменения по умолчанию при извлечении или объединении веток Версия Git GPG ПОДПИСЬ GPG подпись ревизии @@ -558,6 +722,8 @@ Ключ GPG подписи пользователя ВНЕДРЕНИЕ ОБОЛОЧКА/ТЕРМИНАЛ + Аргументы + Используйте, пожалуйста, точку «.» для индикации рабочего каталога Путь Оболочка/Терминал Удалить внешний репозиторий @@ -568,9 +734,6 @@ Ветка внешнего репозитория: В: Локальные изменения: - Отклонить - Отложить и применить повторно - Обновить все подмодули Внешний репозиторий: Загрузить (Получить и слить) Использовать перемещение вместо слияния @@ -590,10 +753,17 @@ Выложить на все внешние репозитории Внешний репозиторий: Метка: + Отправить к НОВОЙ ветке + Введитте имя для новой удалённой ветки: Выйти Перемещение текущей ветки Отложить и применить повторно локальные изменения + Обходной зацеп предварительного перемещения На: + Тестировать на перемещение... + Перемещение может быть выполнено без конфликтов + Неизвестная ошибка при тестировании перемещения + Перемещение вызовет конфликты Добавить внешний репозиторий Редактировать внешний репозиторий Имя: @@ -601,8 +771,10 @@ Адрес: Адрес внешнего репозитория git Копировать адрес + Пользовательские действия Удалить... Редактировать... + Включить автоизвлечение Извлечь Открыть в браузере Удалить @@ -626,13 +798,17 @@ ПРОДОЛЖИТЬ Изменить действия Не изменять действия + Панель Отклонить все изменения. Открыть в файловом менеджере Поиск веток, меток и подмодулей Видимость на графике + Свернуть фильтры Не установлен (по умолчанию) Скрыть в графе ревизии + Развернуть фильтры Фильтр в графе ревизии + фильтры включены РАСПОЛОЖЕНИЕ Горизонтально Вертикально @@ -640,23 +816,26 @@ Дата ревизии Топологически ЛОКАЛЬНЫЕ ВЕТКИ + Больше опций... Навигация по ГОЛОВЕ (HEAD) Создать ветку ОЧИСТКА УВЕДОМЛЕНИЙ - Подсвечивать только текущую ветку + Открыть как каталог Открыть в {0} Открыть в расширенном инструменте ВНЕШНИЕ РЕПОЗИТОРИИ ДОБАВИТЬ ВНЕШНИЙ РЕПОЗИТОРИЙ + РАЗРЕШИТЬ Поиск ревизии Автор - Ревизор Содержимое Сообщение Путь SHA Текущая ветка + Только оформленные ревизии Показывать только первый родительский + ПОКАЗЫВАТЬ ФЛАГИ Показывать потерянные ревизии Показывать подмодули как дерево Показывать метки как катлог @@ -671,7 +850,6 @@ По имени Сортировать Открыть в терминале - Использовать относительное время Просмотр журналов Посетить '{0}' в браузере РАБОЧИЕ КАТАЛОГИ @@ -689,20 +867,22 @@ Отменить ревизию Ревизия: Отмена ревизии - Изменить комментарий ревизии Запуск. Подождите пожалуйста... СОХРАНИТЬ Сохранить как... Заплатка успешно сохранена! - Сканирование репозиторий + Обнаружение репозиториев Корневой каталог: - Проверка для обновления... + Сканировать другой пользовательский каталог + Проверить обновления... Доступна новая версия программного обеспечения: + Текущая версия: Не удалось проверить наличие обновлений! Загрузка Пропустить эту версию + Дата выпуска новой версии: Обновление ПО - В настоящее время обновления недоступны. + Сейчас нет обновлений. Установить ветку подмодуля Подмодуль: Текущий: @@ -714,8 +894,6 @@ Основная ветка: Копировать SHA Перейти - Втиснуть ревизии - В: Приватный ключ SSH: Путь хранения приватного ключа SSH ЗАПУСК @@ -728,6 +906,8 @@ Сформированные так и несформированные изменения выбранных файлов будут сохранены!!! Отложить локальные изменения Принять + Применить изменения + Переключить на новую ветку Копировать сообщение Отбросить Сохранить как заплатку... @@ -744,7 +924,8 @@ РЕВИЗИИ: ПОДМОДУЛИ Добавить подмодули - Ветка + ВЕТКА + Ветка Каталог Удалить подмодуль Извлечение вложенных подмодулей @@ -763,18 +944,29 @@ не слита Обновить URL-адрес + Подробности изменения подмодуля + ОТКРЫТЬ ПОДРОБНОСТИ ОК - Копировать имя метки - Копировать сообщение метки + РАЗМЕТЧИК + ВРЕМЯ + Переключить метку... + Сравнить две ветки + Сравнить с ... + Сравнить с ГОЛОВОЙ + Сообщение + Имя + Разметчик + Копировать имя метки Пользовательское действие Удалить ${0}$... + Удалить выбранные метки ({0})... Влить ${0}$ в ${1}$... Выложить ${0}$... Обновление подмодулей Все подмодули Создавать по необходимости - Рекурсивно Подмодуль: + Обновить вложенные подмодули Обновить до отслеживаемой ветки удалённого подмодуля Сетевой адрес: Журналы @@ -784,25 +976,28 @@ Предупреждение Приветствие Создать группу - Создать подгруппу + Создать подгруппу... Клонировать репозиторий - Удалить + Удалить... ПОДДЕРЖИВАЕТСЯ: ПЕРЕТАСКИВАНИЕ КАТАЛОГОВ, ПОЛЬЗОВАТЕЛЬСКАЯ ГРУППИРОВКА. - Редактировать - Перейти в другую группу + Редактировать... + Переместить в другую группу... Открыть все репозитории Открыть репозиторий Открыть терминал - Повторное сканирование репозиториев в каталоге клонирования по умолчанию - Поиск репозиториев... + Обнаружить репозитории в каталоге клонирования по умолчанию + Найти репозиторий... Изменения Игнорировать Git Игнорировать все *{0} файлы Игнорировать *{0} файлы в том же каталоге Игнорировать неотслеживаемые файлы в этом каталоге Игнорировать только эти файлы + Игнорировать все неотслеживаемые файлы в одном и том же каталоге Изменить Теперь вы можете сформировать этот файл. + Очистить историю + Вы действительно хотите очистить всю историю сообщений ревизии? Данное действие нельзя отменить. ЗАФИКСИРОВАТЬ ЗАФИКСИРОВАТЬ и ОТПРАВИТЬ Шаблон/Истории @@ -812,14 +1007,17 @@ Вы создаёте ревизию на отсоединённой ГОЛОВЕ. Вы хотите продолжить? Вы сформировали {0} файл(ов), но отображается только {1} файл(ов) ({2} файл(ов) отфильтровано). Вы хотите продолжить? ОБНАРУЖЕНЫ КОНФЛИКТЫ - ОТКРЫТЬ ВНЕШНИЙ ИНСТРУМЕНТ СЛИЯНИЯ + СЛИТЬ + Слить с помощью внешнего инструмента ОТКРЫТЬ ВСЕ КОНФЛИКТЫ ВО ВНЕШНЕМ ИНСТРУМЕНТЕ СЛИЯНИЯ КОНФЛИКТЫ ФАЙЛОВ РАЗРЕШЕНЫ ИСПОЛЬЗОВАТЬ МОИ ИСПОЛЬЗОВАТЬ ИХ + Фильтр изменений... ВКЛЮЧИТЬ НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ НЕТ ПОСЛЕДНИХ ВХОДНЫХ СООБЩЕНИЙ НЕТ ШАБЛОНОВ РЕВИЗИИ + Не проверять Сбросить автора Завершение работы СФОРМИРОВАННЫЕ @@ -833,8 +1031,13 @@ РАБОЧЕЕ ПРОСТРАНСТВО: Настройка рабочего пространства... РАБОЧИЙ КАТАЛОГ + ВЕТКА Копировать путь + ГОЛОВА Заблокировать + Открыть + ПУТЬ Удалить Разблокировать + Да diff --git a/src/Resources/Locales/ta_IN.axaml b/src/Resources/Locales/ta_IN.axaml index 7604b3bd2..5cbea045b 100644 --- a/src/Resources/Locales/ta_IN.axaml +++ b/src/Resources/Locales/ta_IN.axaml @@ -17,11 +17,9 @@ புதிய கிளையை உருவாக்கு ஏற்கனவே உள்ள கிளை செநு உதவியாளர் + மாதிரி மறு-உருவாக்கு உறுதிமொழி செய்தியை உருவாக்க செநுவைப் பயன்படுத்து - உறுதிமொழி செய்தி என இடு - ஒட்டு - ஒட்டு கோப்பு: .ஒட்டு இடுவதற்கு கோப்பைத் தேர்ந்தெடு வெள்ளைவெளி மாற்றங்களைப் புறக்கணி ஒட்டு இடு @@ -41,9 +39,8 @@ புதுப்பி இருமம் கோப்பு ஆதரிக்கப்படவில்லை!!! குற்றச்சாட்டு - இந்த கோப்பில் குற்றம் சாட்ட ஆதரிக்கப்படவில்லை!!! + இந்த கோப்பில் குற்றம் சாட்ட ஆதரிக்கப்படவில்லை!!! ${0}$ சரிபார்... - பணிமரத்துடன் ஒப்பிடுக கிளை பெயரை நகலெடு தனிப்பயன் செயல் ${0}$ ஐ நீக்கு... @@ -59,8 +56,6 @@ மறுதளம் ${0}$ இதன்மேல் ${1}$... மறுபெயரிடு ${0}$... கண்காணிப்பு கிளையை அமை... - கிளை ஒப்பிடு - தவறான மேல்ஓடை! விடு பெற்றோர் திருத்தத்திற்கு மீட்டமை இந்த திருத்தத்திற்கு மீட்டமை @@ -70,12 +65,7 @@ பாதை பட்டியலாகக் காட்டு கோப்பு முறைமை மரமாகக் காட்டு கிளை சரிபார் - உறுதிமொழி சரிபார் - உறுதிமொழி: - முன்னறிவிப்பு: ஒரு உறுதிமொழி சரிபார்பதன் மூலம், உங்கள் தலை பிரிக்கப்படும் உள்ளக மாற்றங்கள்: - நிராகரி - பதுக்கிவை & மீண்டும் இடு கிளை: கனி பறி உறுதிமொழி செய்திக்கு மூலத்தைச் சேர் @@ -102,12 +92,9 @@ பணிமரத்துடன் ஒப்பிடுக பாகொவ-வை தனிப்பயன் செயல் - ${0}$ இதற்கு ஒன்றிணை ஒன்றிணை ... உறுதிமொழி திரும்பபெறு - வேறுமொழி ஒட்டாக சேமி... - பெற்றோர்களில் நொறுக்கு மாற்றங்கள் மாற்றங்களைத் தேடு... கோப்புகள் @@ -116,7 +103,6 @@ துணைத்தொகுதி தகவல் ஆசிரியர் - குழந்தைகள் உறுதிமொழியாளர் இந்த உறுதிமொழிடைக் கொண்ட குறிப்புகளைச் சரிபார் உறுதிமொழி இதில் உள்ளது @@ -126,8 +112,7 @@ குறிகள் பாகொவ உலாவியில் திற - விளக்கம் - உறுதிமொழி பொருளை உள்ளிடவும் + ஒப்பிடு களஞ்சியம் உள்ளமை உறுதிமொழி வளர்புரு வார்ப்புரு உள்ளடக்கம்: @@ -186,11 +171,8 @@ இதன் அடிப்படையில்: உருவாக்கப்பட்ட கிளையைப் சரிபார் உள்ளக மாற்றங்கள்: - நிராகரி - பதுக்கிவை & மீண்டும் இடு புதிய கிளை பெயர்: கிளை பெயரை உள்ளிடவும். - இடைவெளிகள் கோடுகளால் மாற்றப்படும். உள்ளக கிளையை உருவாக்கு குறிச்சொல்லை உருவாக்கு... இங்கு புதிய குறிச்சொல்: @@ -206,10 +188,11 @@ குறைந்தஎடை நேரடியாகத் தொடங்க கட்டுப்பாட்டை அழுத்திப் பிடி வெட்டு + நிராகரி + பதுக்கிவை & மீண்டும் இடு கிளையை நீக்கு கிளை: நீங்கள் ஒரு தொலை கிளையை நீக்கப் போகிறீர்கள்!!! - தொலை ${0}$ கிளையையும் நீக்கு பல கிளைகளை நீக்கு நீங்கள் ஒரே நேரத்தில் பல கிளைகளை நீக்க முயற்சிக்கிறீர்கள் நடவடிக்கை எடுப்பதற்கு முன் மீண்டும் சரிபார்! தொலையை நீக்கு @@ -226,7 +209,6 @@ குறிசொல்: தொலை களஞ்சியங்களிலிருந்து நீக்கு இருமம் வேறுபாடு - கோப்பு முறை மாற்றப்பட்டது முதல் வேறுபாடு வெள்ளைவெளி மாற்றத்தை புறக்கணி கடைசி வேறுபாடு @@ -242,7 +224,6 @@ இடமாற்று தொடரியல் சிறப்பம்சமாக்கல் வரி சொல் மடக்கு - தடுப்பு-வழிசெலுத்தலை இயக்கு ஒன்றிணை கருவியில் திற அனைத்து வரிகளையும் காட்டு தெரியும் வரிகளின் எண்ணிக்கையைக் குறை @@ -334,9 +315,9 @@ வரலாறு ஆசிரியர் ஆசிரியர் நேரம் + உறுதிமொழி நேரம் வரைபடம் & பொருள் பாகொவ - உறுதிமொழி நேரம் தேர்ந்தெடுக்கப்பட்ட {0} உறுதிமொழிகள் பல உறுதிமொழிகளைத் தேர்ந்தெடுக்க 'கட்டுப்பாடு' அல்லது 'உயர்த்து'ஐ அழுத்திப் பிடி. பல உறுதிமொழிகளைத் தேர்ந்தெடுக்க ⌘ அல்லது ⇧ ஐ அழுத்திப் பிடி. @@ -362,11 +343,6 @@ 'மாற்றங்கள்' என்பதற்கு மாறு 'வரலாறுகள்' என்பதற்கு மாறு 'பதுகிவைத்தவை' என்பதற்கு மாறு - உரை திருத்தி - தேடல் பலகத்தை மூடு - அடுத்த பொருத்தத்தைக் கண்டறி - முந்தைய பொருத்தத்தைக் கண்டறி - தேடல் பலகத்தைத் திற நிராகரி நிலைபடுத்து நிலைநீக்கு @@ -402,14 +378,13 @@ அறிவிலி உள்ளமைக்கப்படவில்லை. [விருப்பத்தேர்வுகள்]க்குச் சென்று முதலில் அதை உள்ளமை. தரவு சேமிப்பக கோப்பகத்தைத் திற ஒன்றிணை கருவியில் திற - இதனுடன் திற... விருப்பத்தேர்வு. புதிய பக்கத்தை உருவாக்கு - புத்தகக்குறி மூடு தாவல் பிற தாவல்களை மூடு வலதுபுறத்தில் உள்ள தாவல்களை மூடு களஞ்சிய பாதை நகலெடு + திருத்து களஞ்சியங்கள் ஒட்டு {0} நாட்களுக்கு முன்பு @@ -422,16 +397,11 @@ {0} திங்களுக்கு முன்பு {0} ஆண்டுகளுக்கு முன்பு நேற்று - புதிய வரியை உள்ளிட 'உயர்த்து+நுழை' ஐப் பயன்படுத்தவும். 'நுழை' என்பது சரி பொத்தானின் சூடானவிசை ஆகும் விருப்பத்தேர்வுகள் செநு - வேறுபாடு உடனடியாக பகுப்பாய்வு செய் பநிஇ திறவுகோல் - பொருள் உடனடியாக உருவாக்கு - மாதிரி பெயர் சேவையகம் - ஓடையை இயக்கு தோற்றம் இயல்புநிலை எழுத்துரு திருத்தி தாவல் அகலம் @@ -439,7 +409,6 @@ இயல்புநிலை திருத்தி ஒற்றைவெளி எழுத்துரு - ஒற்றைவெளி எழுத்துருவை உரை திருத்தியில் மட்டும் பயன்படுத்து கருப்பொருள் கருப்பொருள் மேலெழுதப்படுகிறது தலைப்புப்பட்டியில் நிலையான தாவல் அகலத்தைப் பயன்படுத்து @@ -453,8 +422,6 @@ தேதி வடிவம் மொழி வரலாற்று உறுதிமொழிகள் - வரைபடத்தில் உறுதிமொழி நேரத்திற்குப் பதிலாக ஆசிரியர் நேரத்தைக் காட்டு - உறுதிமொழி விவரங்களில் குழந்தைகளைக் காட்டு உறுதிமொழி வரைபடத்தில் குறிச்சொற்களைக் காட்டு பொருள் வழிகாட்டி நீளம் அறிவிலி @@ -489,8 +456,6 @@ தொலை கிளை: இதனுள்: உள்ளக மாற்றங்கள்: - நிராகரி - பதுக்கிவை & மீண்டும் இடு தொலை: இழு (எடுத்து ஒன்றிணை) ஒன்றிணை என்பதற்குப் பதிலாக மறுதளத்தைப் பயன்படுத்து @@ -562,7 +527,6 @@ தொலையைச் சேர் உறுதிமொழி தேடு ஆசிரியர் - உறுதிமொழியாளர் செய்தி பாகொவ தற்போதைய கிளை @@ -590,7 +554,6 @@ பின்வாங்கு உறுதிமொழி உறுதிமொழி: பின்வாங்கு மாற்றங்களை உறுதிமொழி - மாறுசொல் உறுதிமொழி செய்தி இயங்குகிறது. காத்திருக்கவும்... சேமி எனச் சேமி... @@ -610,8 +573,6 @@ மேல்ஓடை: SHA ஐ நகலெடு இதற்கு செல் - நொறுக்கு உறுதிமொழிகள் - இதில்: பாஓடு தனியார் திறவுகோல்: தனியார் பாஓடு திறவுகோல் கடை பாதை தொடங்கு @@ -645,15 +606,11 @@ இந்த தொகுதியை சேமிப்பதற்கான தொடர்புடைய கோப்புறை. துணை தொகுதியை நீக்கு சரி - குறிச்சொல் பெயரை நகலெடு - குறிச்சொல் செய்தியை நகலெடு நீக்கு ${0}$... - ${0}$ இதை ${1}$ இல் இணை... தள்ளு ${0}$... துணைத்தொகுதிகளைப் புதுப்பி அனைத்து துணைத்தொகுதிகள் தேவைக்கேற்றப துவக்கு - சுழற்சி முறையில் முகவரி: முன்னறிவிப்பு வரவேற்பு பக்கம் diff --git a/src/Resources/Locales/uk_UA.axaml b/src/Resources/Locales/uk_UA.axaml index b4b4ccbfd..9cad040c7 100644 --- a/src/Resources/Locales/uk_UA.axaml +++ b/src/Resources/Locales/uk_UA.axaml @@ -17,11 +17,9 @@ Створити нову гілку Наявна гілка AI Асистент + Модель ПЕРЕГЕНЕРУВАТИ Використати AI для генерації повідомлення коміту - ЗАСТОСУВАТИ ЯК ПОВІДОМЛЕННЯ КОМІТУ - Застосувати - Файл патчу: Виберіть файл .patch для застосування Ігнорувати зміни пробілів Застосувати Патч @@ -41,10 +39,8 @@ Оновити БІНАРНИЙ ФАЙЛ НЕ ПІДТРИМУЄТЬСЯ!!! Автор рядка - ПОШУК АВТОРА РЯДКА ДЛЯ ЦЬОГО ФАЙЛУ НЕ ПІДТРИМУЄТЬСЯ!!! + ПОШУК АВТОРА РЯДКА ДЛЯ ЦЬОГО ФАЙЛУ НЕ ПІДТРИМУЄТЬСЯ!!! Перейти на ${0}$... - Порівняти з ${0}$ - Порівняти з робочим деревом Копіювати назву гілки Спеціальна дія Видалити ${0}$... @@ -60,8 +56,6 @@ Перебазувати ${0}$ на ${1}$... Перейменувати ${0}$... Встановити відстежувану гілку... - Порівняти гілки - Недійсний upstream! СКАСУВАТИ Скинути до батьківської ревізії Скинути до цієї ревізії @@ -71,12 +65,7 @@ Показати як список шляхів Показати як дерево файлової системи Перейти на гілку - Перейти на коміт - Коміт: - Попередження: Перехід на коміт призведе до стану "від'єднаний HEAD" Локальні зміни: - Скасувати - Сховати та Застосувати Гілка: Cherry-pick Додати джерело до повідомлення коміту @@ -103,12 +92,9 @@ Порівняти з робочим деревом SHA Спеціальна дія - Злиття в ${0}$ Злити ... Скасувати коміт - Змінити повідомлення Зберегти як патч... - Склеїти з батьківським комітом ЗМІНИ Пошук змін... ФАЙЛИ @@ -117,7 +103,6 @@ Підмодуль ІНФОРМАЦІЯ АВТОР - ДОЧІРНІ КОМІТЕР Перевірити посилання, що містять цей коміт КОМІТ МІСТИТЬСЯ В @@ -127,8 +112,7 @@ ПОСИЛАННЯ (Refs) SHA Відкрити в браузері - Опис - Введіть тему коміту + Порівняти Налаштування сховища ШАБЛОН КОМІТУ Зміст шаблону: @@ -191,11 +175,8 @@ На основі: Перейти на створену гілку Локальні зміни: - Скасувати - Сховати та Застосувати Назва нової гілки: Введіть назву гілки. - Пробіли будуть замінені на тире. Створити локальну гілку Створити тег... Новий тег для: @@ -211,10 +192,11 @@ легкий Утримуйте Ctrl для запуску без діалогу Вирізати + Скасувати + Сховати та Застосувати Видалити гілку Гілка: Ви збираєтеся видалити віддалену гілку!!! - Також видалити віддалену гілку ${0}$ Видалити кілька гілок Ви намагаєтеся видалити кілька гілок одночасно. Перевірте ще раз перед виконанням! Видалити віддалене сховище @@ -231,7 +213,6 @@ Тег: Видалити з віддалених сховищ РІЗНИЦЯ ДЛЯ БІНАРНИХ ФАЙЛІВ - Змінено режим файлу Перша відмінність Ігнорувати зміни пробілів Остання відмінність @@ -247,7 +228,6 @@ Поміняти місцями Підсвітка синтаксису Перенос слів - Увімкнути навігацію блоками Відкрити в інструменті злиття Показати всі рядки Зменшити кількість видимих рядків @@ -339,9 +319,9 @@ ІСТОРІЯ АВТОР ЧАС АВТОРА + ЧАС КОМІТУ ГРАФ ТА ТЕМА SHA - ЧАС КОМІТУ ВИБРАНО {0} КОМІТІВ Утримуйте 'Ctrl' або 'Shift' для вибору кількох комітів. Утримуйте ⌘ або ⇧ для вибору кількох комітів. @@ -367,11 +347,6 @@ Перейти до 'Зміни' Перейти до 'Історія' Перейти до 'Схованки' - ТЕКСТОВИЙ РЕДАКТОР - Закрити панель пошуку - Знайти наступний збіг - Знайти попередній збіг - Відкрити панель пошуку Скасувати Індексувати Видалити з індексу @@ -407,14 +382,13 @@ Git не налаштовано. Будь ласка, перейдіть до [Налаштування] та налаштуйте його. Відкрити теку зберігання даних Відкрити в інструменті злиття - Відкрити за допомогою... Необов'язково. Створити нову вкладку - Закладка Закрити вкладку Закрити інші вкладки Закрити вкладки праворуч Копіювати шлях до сховища + Редагувати Сховища Вставити {0} днів тому @@ -427,16 +401,11 @@ {0} місяців тому {0} років тому Вчора - Використовуйте 'Shift+Enter' для введення нового рядка. 'Enter' - гаряча клавіша кнопки OK Налаштування AI - Промпт для аналізу різниці Ключ API - Промпт для генерації теми - Модель Назва Сервер - Увімкнути потокове відтворення ВИГЛЯД Шрифт за замовчуванням Ширина табуляції в редакторі @@ -444,7 +413,6 @@ За замовчуванням Редактор Моноширинний шрифт - Використовувати моноширинний шрифт лише в текстовому редакторі Тема Перевизначення теми Використовувати фіксовану ширину вкладки в заголовку @@ -458,8 +426,6 @@ Формат дати Мова Кількість комітів в історії - Показувати час автора замість часу коміту в графі - Показувати дочірні коміти в деталях Показувати теги в графі комітів Довжина лінії-орієнтира для теми GIT @@ -494,8 +460,6 @@ Віддалена гілка: В: Локальні зміни: - Скасувати - Сховати та Застосувати Віддалене сховище: Pull (Fetch & Merge) Використовувати rebase замість merge @@ -567,7 +531,6 @@ ДОДАТИ ВІДДАЛЕНЕ СХОВИЩЕ Пошук коміту Автор - Комітер Повідомлення SHA Поточна гілка @@ -595,7 +558,6 @@ Revert (Скасувати коміт) Коміт: Закомітити зміни скасування - Змінити повідомлення коміту Виконується. Будь ласка, зачекайте... ЗБЕРЕГТИ Зберегти як... @@ -615,8 +577,6 @@ Upstream: Копіювати SHA Перейти до - Squash (Склеїти коміти) - В: Приватний ключ SSH: Шлях до сховища приватного ключа SSH ПОЧАТИ @@ -650,15 +610,11 @@ Відносна тека для зберігання цього модуля. Видалити підмодуль OK - Копіювати назву тегу - Копіювати повідомлення тегу Видалити ${0}$... - Злиття ${0}$ в ${1}$... Надіслати ${0}$... Оновити підмодулі Усі підмодулі Ініціалізувати за потреби - Рекурсивно Підмодуль: URL: Попередження @@ -690,7 +646,6 @@ Індексувати всі зміни та закомітити Ви проіндексували {0} файл(ів), але відображено лише {1} ({2} файлів відфільтровано). Продовжити? ВИЯВЛЕНО КОНФЛІКТИ - ВІДКРИТИ ЗОВНІШНІЙ ІНСТРУМЕНТ ЗЛИТТЯ ВІДКРИТИ ВСІ КОНФЛІКТИ В ЗОВНІШНЬОМУ ІНСТРУМЕНТІ ЗЛИТТЯ КОНФЛІКТИ ФАЙЛІВ ВИРІШЕНО ВИКОРИСТАТИ МОЮ ВЕРСІЮ diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 7f176709b..d77cd5418 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -5,6 +5,8 @@ 关于软件 关于本软件 + 发布日期:{0} + 浏览版本更新说明 开源免费的Git客户端 新增忽略文件 匹配模式 : @@ -20,20 +22,26 @@ 创建新分支 已有分支 AI助手 + 模型 重新生成 使用AI助手生成提交信息 - 应用本次生成 - 应用补丁(apply) - 补丁文件 : + 应用 + 隐藏 SourceGit + 隐藏其他 + 显示全部 + 尝试三路合并 选择补丁文件 忽略空白符号 + 补丁来源 : + 文件 + 剪贴板 应用补丁 空白符号处理 : 应用贮藏 在成功应用后丢弃该贮藏 恢复索引中已暂存的变化 已选贮藏 : - 存档(archive) ... + 存档(archive)... 存档文件路径: 选择存档文件的存放路径 指定的提交: @@ -48,37 +56,58 @@ 二分定位(bisect) 终止 标记错误 - 二分定位进行中。当前提交是 '正确' 还是 '错误' ? 标记正确 无法判定 - 二分定位进行中。请标记当前的提交是 '正确' 还是 '错误',然后检出另一个提交。 + 二分定位进行中。请检出另一个提交并标记其是【正确】还是【错误】 + 二分定位进行中。请标记当前提交为【错误】,或检出另一个提交并标记为【错误】。 + 二分定位进行中。请标记当前的提交是【正确】还是【错误】,然后检出另一个提交。 + 二分定位进行中。当前提交是【正确】还是【错误】? 逐行追溯(blame) - 选中文件不支持该操作!!! + 对当前版本的前一版本执行逐行追溯操作 + 忽略空白符变化 + 选中文件不支持该操作!!! 检出(checkout) ${0}$... - 与当前 ${0}$ 比较 - 与本地工作树比较 + 比较选中的 2 个分支 + 与其他分支或标签比较... + 与当前 HEAD 比较 + 与 ${0}$ 比较 复制分支名 + 创建合并请求... + 为上游分支 ${0}$ 创建合并请求... 自定义操作 删除 ${0}$... - 删除选中的 {0} 个分支 - 快进(fast-forward)到 ${0}$ + 删除选中的 {0} 个分支... + 编辑 ${0}$ 的描述... + 快进(fast-forward) 到 ${0}$ 拉取(fetch) ${0}$ 至 ${1}$... - GIT工作流 - 完成 ${0}$ - 合并 ${0}$ 到 ${1}$... - 合并 {0} 个分支到当前分支 - 拉回(pull) ${0}$ + GIT工作流 - 完成 ${0}$... + 交互式变基 ${0}$ 到 ${1}$ + 合并(merge) ${0}$ 到 ${1}$... + 合并(merge) {0} 个分支到当前分支... + 拉回(pull) ${0}$... 拉回(pull) ${0}$ 内容至 ${1}$... - 推送(push)${0}$ + 推送(push) ${0}$... 变基(rebase) ${0}$ 至 ${1}$... 重命名 ${0}$... - 重置 ${0}$ 到 ${1}$... - 切换上游分支 ... - 分支比较 - 跟踪的上游分支不存在或已删除! + 重置(reset) ${0}$ 到 ${1}$... + 切换到 ${0}$ (工作树) + 切换上游分支... + 领先 {0} 个提交 + 领先 {0} 个提交,落后 {1} 个提交 + 落后 {0} 个提交 + 不存在 + 远程 + 状态 + 上游分支 + 远程地址 + 工作树 取 消 重置文件到上一版本 重置文件到该版本 生成提交信息 + 解决冲突(内部工具) + 解决冲突(外部工具) + 重置文件到 ${0}$ 切换变更显示模式 文件名+路径列表模式 全路径列表模式 @@ -87,17 +116,18 @@ 子模块 : 远程地址 : 检出(checkout)分支 - 检出(checkout)提交 - 提交 : - 注意:执行该操作后,当前HEAD会变为游离(detached)状态! 未提交更改 : - 丢弃更改 - 贮藏并自动恢复 - 同时更新所有子模块 目标分支 : 您当前游离的HEAD包含未被任何分支及标签引用的提交!是否继续? + 以下子模块需要更新:{0}是否立即更新? 检出分支并快进 上游分支 : + 从所选贮藏检出分支 + 新分支 : + 所选贮藏 : + 检出提交或标签 + 目标 : + 注意:执行该操作后,当前HEAD会变为游离(detached)状态! 挑选提交 提交信息中追加来源信息 提交列表 : @@ -109,6 +139,8 @@ 克隆远程仓库 额外参数 : 其他克隆参数,选填。 + 书签 : + 自定义分组 : 本地仓库名 : 本地仓库目录的名字,选填。 父级目录 : @@ -116,41 +148,52 @@ 远程仓库 : 关闭 提交信息编辑器 - 检出此提交 - 挑选(cherry-pick)此提交 + 分支列表 + 分支 & 标签 + 自定义操作列表 + 文件列表 + 检出此提交... + 挑选(cherry-pick)此提交... 挑选(cherry-pick)... 与当前HEAD比较 与本地工作树比较 作者 + 修改时间 提交信息 提交者 + 提交时间 提交指纹 主题 自定义操作 - 交互式变基(rebase -i) ${0}$ 到 ${1}$ - 合并(merge)此提交至 ${0}$ + 交互式变基(rebase -i) + 丢弃... + 编辑... + 修复至父提交... + 交互式变基 ${0}$ 到 ${1}$ + 修改提交信息... + 合并至父提交... 合并(merge)... - 推送(push) ${0}$ 到 ${1}$ - 变基(rebase) ${0}$ 到 ${1} - 重置(reset) ${0}$ 到 ${1} - 回滚此提交 - 编辑提交信息 - 另存为补丁 ... - 合并此提交到上一个提交 - 合并之后的提交到 ${0}$ + 推送(push) ${0}$ 到 ${1}$... + 变基(rebase) ${0}$ 到 ${1}$... + 重置(reset) ${0}$ 到 ${1}$... + 回滚此提交... + 另存为补丁... 变更对比 个文件发生变更 查找变更... + 折叠详细面板 文件列表 LFS文件 查找文件... 子模块 基本信息 修改者 - 子提交 提交者 查看包含此提交的分支/标签 本提交已被以下分支/标签包含 + 复制电子邮箱 + 复制用户名 + 复制用户名及邮箱 仅显示前100项变更。请前往【变更对比】页面查看全部。 签名密钥 : 提交信息 @@ -159,37 +202,64 @@ 提交指纹 签名者 : 浏览器中查看 - 详细描述 + + 请输入提交的信息。注意:主题与具体描述中间需要空白行分隔! 主题 - 填写提交信息主题 + 比较 + 差异文件 + 差异提交 + 左侧独有提交 + 右侧独有提交 + 提示:如果比较的另一方是 HEAD,您可以挑选已选中的提交(使用右键菜单) 仓库配置 提交信息模板 - 您可使用 ${files_num}, ${branch_name}, ${files} 或 ${files:N}(N表示最大显示的文件数) + 内置变量: + + ${branch_name} 当前分支名 + ${files_num} 变更文件数量 + ${files} 变更文件路径列表 + ${files:N} 变更文件路径列表(仅输出指定 N 条) + ${pure_files} 与 ${files} 类似,但仅输出文件名 + ${pure_files:N} 与 ${files:N} 类似,但仅输出文件名 模板内容 : 模板名 : 自定义操作 命令行参数 : - 内置变量:${REPO} 仓库路径、${BRANCH} 选中的分支、${SHA} 选中的提交哈希,${TAG} 选中的标签 + 内置变量: + + ${REPO} 仓库路径 + ${REMOTE} 选中的远程仓库或选中分支所属的远程仓库 + ${BRANCH} 选中的分支,对于远程分支不包含远程名 + ${BRANCH_FRIENDLY_NAME} 选中的分支,对于远程分支包含远程名 + ${SHA} 选中的提交哈希 + ${TAG} 选中的标签 + ${FILE} 选中的文件 + $1, $2 ... 输入控件中填写的值 可执行文件路径 : 输入控件 : 编辑 - 请在命令行参数中使用 $1, $2 等占位符表示输入控件的值 名称 : 作用目标 : 选中的分支 选中的提交 + 选中的文件 + 远程仓库 仓库 选中的标签 等待操作执行完成 电子邮箱 邮箱地址 GIT配置 + 在自动更新子模块前询问用户 启用定时自动拉取远程更新 分钟 + 自定义规范化提交类型 默认远程 + 自动更新子模块时启用 '--recursive' 选项 默认合并方式 ISSUE追踪 新增匹配Azure DevOps规则 + 新增匹配Gerrit Change-Id规则 新增匹配Gitee议题规则 新增匹配Gitee合并请求规则 新增匹配GitHub Issue规则 @@ -199,6 +269,7 @@ 新增自定义规则 匹配ISSUE的正则表达式 : 规则名 : + 写入 .issuetracker 文件共享此规则 为ISSUE生成的URL链接 : 可在URL中使用$1,$2等变量填入正则表达式匹配的内容 AI @@ -217,15 +288,20 @@ 名称 : 选项列表 : 选项之间请使用英文 '|' 作为分隔符 + 输出内容格式化字串: + 可选。用于格式化输出结果。当用户输入为空时忽略该操作。请使用`${VALUE}`代替用户输入。 + 内置变量 ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE} 与 ${TAG} 在这里仍然可用 类型 : + 输出结果带有远程名: 工作区 颜色 名称 启动时恢复打开的仓库 确认继续 提交未包含变更文件!是否继续(--allow-empty)? - 自动暂存并提交 - 提交未包含变更文件!是否继续(--allow-empty)或是自动暂存所有变更并提交? + 暂存所有变更并提交 + 仅暂存所选变更并提交 + 提交未包含变更文件!是否继续(--allow-empty)或是自动暂存变更并提交? 系统提示 程序需要重新启动,以便修改生效! 规范化提交信息生成 @@ -237,20 +313,18 @@ 类型: 复制 复制全部文本 + 复制为补丁 复制完整路径 复制路径 - 新建分支 ... + 新建分支... 新分支基于 : 完成后切换到新分支 未提交更改 : - 丢弃更改 - 贮藏并自动恢复 新分支名 : 填写分支名称。 - 空格将被替换为'-'符号 创建本地分支 允许重置已存在的分支 - 新建标签 ... + 新建标签... 标签位于 : 使用GPG签名 标签描述 : @@ -264,15 +338,22 @@ 轻量标签 按住Ctrl键点击将以默认参数运行 剪切 + 丢弃更改 + 不做处理 + 贮藏并自动恢复 取消初始化子模块 强制取消,即使包含本地变更 子模块 : 删除分支确认 + 是否同时删除以下远程分支? 分支名 : + 强制删除,即使包含未合并的提交 您正在删除远程上的分支,请务必小心!!! - 同时删除远程分支 ${0}$ 删除多个分支 您正在尝试一次性删除多个分支,请务必仔细检查后再执行操作! + 删除多个标签 + 同时在远程仓库中删除 + 您正在尝试一次性删除多个标签,请务必仔细检查后再执行操作! 删除远程确认 远程名 : 路径 : @@ -287,10 +368,11 @@ 标签名 : 同时删除远程仓库中的此标签 二进制文件 - 文件权限已变化 + 空文件 首个差异 忽略空白符号变化 混合对比 + 差异比较 分列对比 填充对比 最后一个差异 @@ -306,22 +388,29 @@ 子模块 删除 新增 + + {0} 项未提交变更 交换比对双方 语法高亮 自动换行 - 启用基于变更块的跳转 使用外部合并工具查看 显示完整文件 减少可见的行数 增加可见的行数 请选择需要对比的文件 目录内容变更历史 + 未提交的本地变更 + 当前分支HEAD与远端不一致 + 已是最新 放弃更改确认 - 所有本地址未提交的修改。 + 所有本仓库未提交的修改。 变更 : 包括所有已忽略的文件 + 包括已修改或删除的文件 + 包括未跟踪的文件 总计{0}项选中更改 本操作不支持回退,请确认后继续!!! + 编辑分支描述 + 目标 : 书签 : 名称 : 目标 : @@ -336,6 +425,7 @@ 远程仓库 : 拉取远程仓库内容 不跟踪此文件的更改 + 自定义操作 放弃更改... 放弃 {0} 个文件的更改... 应用 ${0}$ @@ -351,15 +441,25 @@ 文件历史 文件变更 文件内容 + 文件类型变更: + 删除: + 目录 + 可执行文件 + 新增文件类型: + 普通文件 + 子模块 + 符号链接 + 未知 GIT工作流 开发分支 : 特性分支 : 特性分支名前缀 : + 完成 ${0}$ 结束特性分支 结束修复分支 结束版本分支 目标分支 : - 完成后自动推送 + 合并前执行变基操作 压缩变更为单一提交后合并分支 修复分支 : 修复分支名前缀 : @@ -368,10 +468,12 @@ 发布分支 : 版本分支 : 版本分支名前缀 : + 开始于 : 开始特性分支... 开始特性分支 开始修复分支... 开始修复分支 + 分支名 : 输入分支名 开始版本分支... 开始版本分支 @@ -382,7 +484,7 @@ 规则 : 添加LFS追踪文件规则 拉取LFS对象 (fetch) - 执行`git lfs prune`命令,下载远程LFS对象,但不会更新工作副本。 + 执行`git lfs fetch`命令,下载远程LFS对象,但不会更新工作副本。 拉取LFS对象 启用Git LFS支持 显示LFS对象锁 @@ -391,6 +493,8 @@ 仅显示被我锁定的文件 LFS对象锁状态 解锁 + 解锁所有被我锁定的文件 + 确定要解锁所有被您锁定的文件吗? 强制解锁 精简本地LFS对象存储 运行`git lfs prune`命令,从本地存储中精简当前版本不需要的LFS对象 @@ -403,16 +507,26 @@ 远程 : 跟踪名为'{0}'的文件 跟踪所有 *{0} 文件 + 选择前往的提交 历史记录 作者 修改时间 + 提交时间 路线图与主题 提交指纹 - 提交时间 + 高亮分支 + 全部 + 仅当前分支 + 当前分支和选中的提交 + 仅选中的提交 已选中 {0} 项提交 + 显示列 可以按住 Ctrl 或 Shift 键选择多个提交 可以按住 ⌘ 或 ⇧ 键选择多个提交 小提示: + 以独立窗口中打开 + 提交详情 + 比较 快捷键参考 全局快捷键 克隆远程仓库 @@ -420,32 +534,35 @@ 切换到下一个页面 切换到上一个页面 新建页面 + 打开本地仓库 打开偏好设置面板 - 切换工作区 + 显示工作区下拉菜单 切换显示页面 + 缩放内容 仓库页面快捷键 提交暂存区更改 提交暂存区更改并推送 自动暂存全部变更并提交 + 新建分支 拉取 (fetch) 远程变更 切换左边栏为分支/标签等显示模式(默认) + 前往选中提交的子提交 + 前往选中提交的父提交 + 打开快捷命令面板 切换左边栏为提交搜索模式 拉回 (pull) 远程变更 推送本地变更到远程 重新加载仓库状态 + 展开/折叠历史提交中的详情面板 显示本地更改 显示历史记录 显示贮藏列表 - 文本编辑器 - 关闭搜索 - 定位到下一个匹配搜索的位置 - 定位到上一个匹配搜索的位置 - 使用外部比对工具查看 - 打开搜索 丢弃 暂存 移出暂存区 初始化新仓库 + 是否在该路径下执行 `git init` 命令(初始化仓库)? + 打开本地仓库失败,原因: 路径 : 挑选(Cherry-Pick)操作进行中。 正在处理提交 @@ -457,20 +574,44 @@ 正在回滚提交 交互式变基 自动贮藏并恢复本地变更 + 绕过 pre-rebase 钩子 起始提交 : 拖拽以便对提交重新排序 目标分支 : 复制链接地址 在浏览器中访问 + 命令列表 出错了 系统提示 - 工作区列表 + 检测到新版本 + 打开其他仓库 页面列表 + 工作区列表 合并分支 编辑合并信息 目标分支 : 合并方式 : 合并目标 : + 检测合并冲突中... + 合并操作不会产生冲突 + 检测合并冲突时出现未知错误 + 合并操作将存在冲突文件 + 先应用 MINE 后 THEIRS + 先应用 THEIRS 后 MINE + 应用全部 + 所有冲突已解决 + {0} 个冲突未解决 + MINE + 下一个冲突 + 上一个冲突 + 合并结果 + 保存并暂存 + THEIRS + 合并冲突 + 放弃所有更改? + 仅应用 MINE + 仅应用 THEIRS + 撤销更改 合并(多目标) 提交变化 合并策略 : @@ -481,17 +622,26 @@ 调整仓库分组 请选择目标分组: 名称 : + 不用了 GIT尚未配置。请打开【偏好设置】配置GIT路径。 + 打开 + 系统默认编辑器 浏览应用数据目录 + 打开文件 使用外部对比工具查看 - 打开文件... + 打开本地仓库 + 书签 : + 分组 : + 仓库位置 : 选填。 新建空白页 - 设置书签 关闭标签页 关闭其他标签页 关闭右侧标签页 复制仓库路径 + 编辑 + 移至工作区 + 刷新 新标签页 粘贴 {0}天前 @@ -504,16 +654,15 @@ {0}个月前 {0}年前 昨天 - 请使用Shift+Enter换行。Enter键已被【确 定】按钮占用。 偏好设置 AI - Analyze Diff Prompt + 附加提示词 (请使用 `-` 列出您的要求) API密钥 - Generate Subject Prompt 模型 + 自动拉取可用模型列表 配置名称 + 从环境变量(填写环境变量名)中读取API密钥 服务地址 - 启用流式输出 外观配置 缺省字体 编辑器制表符宽度 @@ -521,36 +670,47 @@ 默认 代码编辑器 等宽字体 - 仅在文本编辑器中使用等宽字体 主题 主题自定义 + 允许滚动条自动隐藏 主标签使用固定宽度 使用系统默认窗体样式 对比/合并工具 + 对比命令参数 + 可用参数:$LOCAL, $REMOTE + 合并命令参数 + 可用参数:$BASE, $LOCAL, $REMOTE, $MERGED 安装路径 填写工具可执行文件所在位置 工具 通用配置 启动时检测软件更新 日期时间格式 + 在变更列表树中启用紧凑文件夹模式 显示语言 最大历史提交数 - 在提交路线图中显示修改时间而非提交时间 - 在提交详情页中显示子提交列表 + 默认显示【本地更改】页 + 在提交详情页默认打开【变更对比】标签页 + 在提交路线图中显示相对时间 在提交路线图中显示标签 SUBJECT字数检测 + 24小时制 + 在提交路线图中使用精简分支名 + 生成GitHub风格的默认头像 GIT配置 自动换行转换 默认克隆路径 邮箱 默认GIT用户邮箱 - 拉取更新时启用修剪(--prune) - 对比文件时,默认忽略换行符变更 (--ignore-cr-at-eol) + 拉取更新时启用修剪 + 对比文件时,默认忽略换行符变更 本软件要求GIT最低版本为2.25.1 安装路径 启用HTTP SSL验证 + 使用 git-credential-libsecret 替代 git-credential-manager 用户名 默认GIT用户名 + 迁出或合并分支时,默认贮藏并自动恢复本地更改 Git 版本 GPG签名 启用提交签名 @@ -562,6 +722,8 @@ 输入签名提交所使用的KEY 第三方工具集成 终端/SHELL + 启动参数 + 请使用 '.' 来指定工作目录 安装路径 终端/SHELL 清理远程已删除分支 @@ -572,9 +734,6 @@ 拉取分支 : 本地分支 : 未提交更改 : - 丢弃更改 - 贮藏并自动恢复 - 同时更新所有子模块 远程 : 拉回(拉取并合并) 使用变基方式合并分支 @@ -594,10 +753,17 @@ 推送到所有远程仓库 远程仓库 : 标签 : + 推送到新的分支 + 输入新的远端分支名 退出 变基(rebase)操作 自动贮藏并恢复本地变更 + 绕过 pre-rebase 钩子 目标提交 : + 检测变基冲突中... + 未检测到文件冲突 + 检测变基冲突时出现未知错误 + 检测到冲突文件 添加远程仓库 编辑远程仓库 远程名 : @@ -605,8 +771,10 @@ 仓库地址 : 远程仓库的地址 复制远程地址 - 删除 ... - 编辑 ... + 自定义操作 + 删除... + 编辑... + 启用自动拉取 拉取(fetch)更新 在浏览器中打开 清理远程已删除分支 @@ -630,13 +798,17 @@ 下一步 自定义操作 自定义操作未设置 + 主页 放弃所有更改 在文件浏览器中打开 快速查找分支/标签/子模块 设置在列表中的可见性 + 折叠过滤项列表 不指定 在提交列表中隐藏 + 展开过滤项列表 使用其对提交列表过滤 + 个过滤项已被启用 布局方式 水平排布 竖直排布 @@ -644,23 +816,26 @@ 按提交时间 按拓扑排序 本地分支 + 更多选项... 定位HEAD 新建分支 清空通知列表 - 仅高亮显示当前分支 + 本仓库(文件夹) 在 {0} 中打开 使用外部工具打开 远程列表 添加远程 + 解决冲突 查找提交 作者 - 提交者 变更内容 提交信息 路径 提交指纹 仅在当前分支中查找 + 仅显示分支、标签所引用的提交 仅显示合并提交中的第一个提交 + 显示内容 显示丢失引用的提交 以树型结构展示 以树型结构展示 @@ -675,7 +850,6 @@ 按名称 排序 在终端中打开 - 在提交列表中使用相对时间 查看命令日志 访问远程仓库 '{0}' 工作树列表 @@ -693,18 +867,20 @@ 回滚操作确认 目标提交 : 回滚后提交更改 - 编辑提交信息 执行操作中,请耐心等待... 保 存 另存为... 补丁已成功保存! 扫描仓库 根路径 : + 扫描其他自定义路径 检测更新... 检测到软件有版本更新: + 当前版本 : 获取最新版本信息失败! 下 载 忽略此版本 + 新版发布时间 : 软件更新 当前已是最新版本。 修改子模块追踪分支 @@ -718,8 +894,6 @@ 上游分支 : 复制提交指纹 跳转到提交 - 压缩为单个提交 - 合并入: SSH密钥 : SSH密钥文件 开 始 @@ -732,6 +906,8 @@ 选中文件的所有变更均会被贮藏! 贮藏本地变更 应用(apply) + 应用(apply)选中变更 + 检出新分支 复制描述信息 删除(drop) 另存为补丁... @@ -749,6 +925,7 @@ 子模块 添加子模块 跟踪分支 + 跟踪分支 相对路径 取消初始化 拉取子孙模块 @@ -767,18 +944,29 @@ 未解决冲突 更新 仓库 + 子模块变更详情 + 查看变更详情 确 定 - 复制标签名 - 复制标签信息 + 创建者 + 创建时间 + 检出标签... + 比较 2 个标签 + 与其他分支或标签比较... + 与当前 HEAD 比较 + 标签信息 + 标签名 + 创建者 + 复制标签名 自定义操作 删除 ${0}$... + 删除选中 {0} 个标签... 合并 ${0}$ 到 ${1}$... 推送 ${0}$... 更新子模块 更新所有子模块 如未初始化子模块,先初始化 - 递归更新子孙模块 子模块 : + 递归更新子孙模块 更新到子模块自己的远程踪分支 仓库地址 : 日志列表 @@ -805,8 +993,11 @@ 忽略同目录下所有 *{0} 文件 忽略该目录下的新文件 忽略本文件 + 忽略同目录下所有新文件 修补 现在您已可将其加入暂存区中 + 清空历史提交信息 + 您确定要清空所有的历史提交信息记录吗(执行操作后无法撤回)? 提交 提交并推送 历史输入/模板 @@ -816,14 +1007,17 @@ 您正在向一个游离的 HEAD 提交变更,是否继续提交? 当前有 {0} 个文件在暂存区中,但仅显示了 {1} 个文件({2} 个文件被过滤掉了),是否继续提交? 检测到冲突 - 打开合并工具 + 解决冲突 + 使用外部工具解决冲突 打开合并工具解决冲突 文件冲突已解决 使用 MINE 使用 THEIRS + 查找变更... 显示未跟踪文件 没有提交信息记录 没有可应用的提交信息模板 + 跳过GIT钩子 重置提交者 署名 已暂存 @@ -837,8 +1031,13 @@ 工作区: 配置工作区... 本地工作树 + 連結分支 复制工作树路径 + 最新提交 锁定工作树 + 打开工作树 + 位置 移除工作树 解除工作树锁定 + 好的 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 4eecc7148..b07c82996 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -5,13 +5,15 @@ 關於 關於 SourceGit + 發行日期: {0} + 版本說明 開源免費的 Git 客戶端 新增忽略檔案 - 匹配模式 : - 儲存路徑 : - 新增工作區 - 工作區路徑: - 填寫該工作區的路徑。支援相對路徑。 + 比對模式: + 儲存路徑: + 新增工作樹 + 工作樹路徑: + 填寫該工作樹的路徑。支援相對路徑。 分支名稱: 選填。預設使用目標資料夾名稱。 追蹤分支 @@ -20,13 +22,19 @@ 建立新分支 已有分支 AI 助理 + 模型 重新產生 使用 AI 產生提交訊息 - 套用為提交訊息 - 套用修補檔 (apply patch) - 修補檔: + 套用 + 隱藏 SourceGit + 隱藏其他 + 顯示全部 + 嘗試三向合併 選擇修補檔 忽略空白符號 + 修補檔來源: + 檔案 + 剪貼簿 套用修補檔 空白字元處理: 套用擱置變更 @@ -48,37 +56,58 @@ 二分搜尋 (bisect) 中止 標記為錯誤 - 二分搜尋進行中。目前的提交是「良好」是「錯誤」? 標記為良好 無法確認 - 二分搜尋進行中。請標記目前的提交為「良好」或「錯誤」,然後簽出另一個提交。 + 二分搜尋進行中。請簽出另一個提交,然後標記該提交為「良好」或「錯誤」。 + 二分搜尋進行中。請標記目前的提交為「錯誤」,或簽出另一個提交並標記為「錯誤」。 + 二分搜尋進行中。請標記目前的提交為「良好」或「錯誤」,然後簽出另一個提交。 + 二分搜尋進行中。目前的提交是「良好」還是「錯誤」? 逐行溯源 (blame) - 所選擇的檔案不支援該操作! + 對上一個版本執行逐行溯源 + 忽略空白符號變化 + 所選擇的檔案不支援該操作! 簽出 (checkout) ${0}$... - 與目前 ${0}$ 比較 - 與本機工作區比較 + 比較所選的 2 個分支 + 與其他分支或標籤比較... + 與目前 HEAD 比較 + 與 ${0}$ 比較 複製分支名稱 + 建立拉取請求... + 為上游分支 ${0}$ 建立拉取請求... 自訂動作 刪除 ${0}$... - 刪除所選的 {0} 個分支 + 刪除所選的 {0} 個分支... + 編輯 ${0}$ 的描述... 快轉 (fast-forward) 到 ${0}$ 提取 (fetch) ${0}$ 到 ${1}$... - Git 工作流 - 完成 ${0}$ + Git 工作流 - 完成 ${0}$... + 互動式重定基底 ${0}$ 至 ${1}$ 合併 ${0}$ 到 ${1}$... - 合併 {0} 個分支到目前分支 - 拉取 (pull) ${0}$ + 合併 {0} 個分支到目前分支... + 拉取 (pull) ${0}$... 拉取 (pull) ${0}$ 內容至 ${1}$... - 推送 (push) ${0}$ + 推送 (push) ${0}$... 重定基底 (rebase) ${0}$ 分支至 ${1}$... 重新命名 ${0}$... 重設 ${0}$ 至 ${1}$... + 切換到 ${0}$ (工作樹) 切換上游分支... - 分支比較 - 追蹤上游分支不存在或已刪除! + 領先 {0} 次提交 + 領先 {0} 次提交,落後 {1} 次提交 + 落後 {0} 次提交 + 無效 + 遠端 + 狀態 + 上游分支 + 遠端網址 + 工作樹 取 消 重設檔案到上一版本 重設檔案為此版本 產生提交訊息 + 解決衝突 (內建工具) + 解決衝突 (外部工具) + 重設檔案到 ${0}$ 切換變更顯示模式 檔案名稱 + 路徑列表模式 全路徑列表模式 @@ -87,109 +116,150 @@ 子模組: 遠端網址: 簽出 (checkout) 分支 - 簽出 (checkout) 提交 - 提交: - 注意: 執行該操作後,目前 HEAD 會變為分離 (detached) 狀態! 未提交變更: - 捨棄變更 - 擱置變更並自動復原 - 同時更新所有子模組 目標分支: - 您目前的分離的 HEAD 包含與任何分支/標籤無關的提交!您要繼續嗎? + 目前的 HEAD 包含未連結至任何分支或標籤的提交! 您是否要繼續? + 以下子模組需要更新: {0},您要立即更新嗎? 簽出分支並快轉 - 上游分支 : + 上游分支: + 從所選擱置簽出分支 + 新分支: + 所選擱置: + 簽出提交或標籤 + 目標: + 注意: 執行該操作後,目前 HEAD 會變為分離 (detached) 狀態! 揀選提交 提交資訊中追加來源資訊 提交列表: 提交變更 對比的父提交: - 通常您不能對一個合併進行揀選 (cherry-pick),因為您不知道合併的哪一邊應該被視為主線。這個選項指定了作為主線的父提交,允許揀選相對於該提交的修改。 + 通常您無法揀選 (cherry-pick) 合併提交,因為無法判斷哪個父提交應視為主線。此選項可指定作為主線的父提交。 捨棄擱置變更確認 您正在捨棄所有的擱置變更,一經操作便無法復原,是否繼續? 複製 (clone) 遠端存放庫 額外參數: 其他複製參數,選填。 + 書籤: + 群組: 本機存放庫名稱: 本機存放庫目錄的名稱,選填。 - 父級目錄: + 上層目錄: 初始化並更新子模組 遠端存放庫: 關閉 提交訊息編輯器 - 簽出 (checkout) 此提交 - 揀選 (cherry-pick) 此提交 + 分支列表 + 分支 & 標籤 + 自訂動作 + 檔案列表 + 簽出 (checkout) 此提交... + 揀選 (cherry-pick) 此提交... 揀選 (cherry-pick)... 與目前 HEAD 比較 - 與本機工作區比較 + 與本機工作樹比較 作者 + 修改時間 提交訊息 提交者 + 提交時間 提交編號 標題 自訂動作 - 互動式重定基底 (rebase -i) ${0}$ 至 ${1}$ - 合併 (merge) 此提交到 ${0}$ + 互動式重定基底 (rebase -i) + 捨棄... + 編輯... + 修正至父提交... + 互動式重定基底 ${0}$ 至 ${1}$ + 編輯提交訊息... + 合併至父提交... 合併 (merge)... - 推送(push) ${0}$ 至 ${1}$ - 重定基底 (rebase) ${0}$ 至 ${1}$ - 重設 (reset) ${0}$ 至 ${1}$ - 復原此提交 - 編輯提交訊息 + 推送 (push) ${0}$ 至 ${1}$... + 重定基底 (rebase) ${0}$ 至 ${1}$... + 重設 (reset) ${0}$ 至 ${1}$... + 復原此提交... 另存為修補檔 (patch)... - 合併此提交到上一個提交 - 合併之後的提交至 ${0}$ 變更對比 個檔案已變更 搜尋變更... + 收合詳細面板 檔案列表 LFS 檔案 搜尋檔案... 子模組 基本資訊 作者 - 後續提交 提交者 檢視包含此提交的分支或標籤 本提交包含於以下分支或標籤 + 複製電子郵件 + 複製名稱 + 複製名稱及電子郵件 僅顯示前 100 項變更。請前往 [變更對比] 頁面以瀏覽所有變更。 - 簽名鑰匙: + 簽章金鑰: 提交訊息 前次提交 相關參照 提交編號 簽署人: 在瀏覽器中檢視 - 詳細描述 + + 請輸入提交訊息,標題與詳細描述之間請使用單行空白區隔。 標題 - 填寫提交訊息標題 + 比較 + 差異檔案 + 差異提交 + 左側獨有提交 + 右側獨有提交 + 提示: 如果比較的另一方是 HEAD,您可以揀選 (cherry-pick) 已選擇的提交 (使用滑鼠右鍵) 存放庫設定 提交訊息範本 - 您可以使用 ${files_num}、${branch_name}、${files} 或 ${files:N} 其中 N 是要輸出的檔案路徑的最大數目。 + 內建參數: + + ${branch_name} 目前分支名稱 + ${files_num} 已變更檔案數 + ${files} 已變更檔案路徑清單 + ${files:N} 已變更檔案路徑清單 (僅列出前 N 個) + ${pure_files} 類似 ${files},不含資料夾的純檔案名稱 + ${pure_files:N} 類似 ${files:N},不含資料夾的純檔案名稱 範本內容: 範本名稱: 自訂動作 指令參數: - 內建參數: ${REPO} 存放庫路徑、${BRANCH} 所選的分支、${SHA} 所選的提交編號、${TAG} 所選的標籤 + 內建參數: + + ${REPO} 存放庫路徑 + ${REMOTE} 所選的遠端存放庫或所選分支的遠端 + ${BRANCH} 所選的分支。遠端分支不包含遠端名稱 + ${BRANCH_FRIENDLY_NAME} 所選的分支。遠端分支會包含遠端名稱 + ${SHA} 所選的提交編號 + ${TAG} 所選的標籤 + ${FILE} 所選的檔案,相對於存放庫根目錄的路徑 + $1, $2 ... 輸入控制項中的值 可執行檔案路徑: - 輸入控件: + 輸入控制項: 編輯 - 請使用占位符如 $1, $2 來代表輸入控制項的值 名稱: 執行範圍: 選取的分支 選取的提交 + 選取的檔案 + 遠端存放庫 存放庫 選取的標籤 等待自訂動作執行結束 電子郵件 電子郵件地址 Git 設定 + 在自動更新子模組之前先詢問 啟用定時自動提取 (fetch) 遠端更新 分鐘 + 自訂約定式提交類型 預設遠端存放庫 + 自動更新子模組時啟用「--recursive」選項 預設合併模式 Issue 追蹤 新增符合 Azure DevOps 規則 + 新增符合 Gerrit Change-Id 規則 新增符合 Gitee 議題規則 新增符合 Gitee 合併請求規則 新增符合 GitHub Issue 規則 @@ -199,6 +269,7 @@ 新增自訂規則 符合 Issue 的正規表達式: 規則名稱: + 寫入 .issuetracker 檔案以共用此規則 為 Issue 產生的網址連結: 可在網址中使用 $1、$2 等變數填入正規表達式相符的內容 AI @@ -208,26 +279,31 @@ HTTP 網路代理 使用者名稱 用於本存放庫的使用者名稱 - 編輯自訂動作輸入控件 - 啟用時的指令行參數: - 勾選 CheckBox 後,此值將用於命令列參數中 + 編輯自訂動作輸入控制項 + 啟用時的指令參數: + 勾選核取方塊後,此值將用於指令參數中 描述: 預設值: 目標路徑是否為資料夾: 名稱: 選項列表: 請使用英文「|」符號分隔選項 + 字串格式化: + 選填。用於格式化輸出字串。若輸入為空則會忽略此設定。請使用 `${VALUE}` 來表示輸入字串。 + 內建變數 ${REPO}、${REMOTE}、${BRANCH}、${BRANCH_FRIENDLY_NAME}、${SHA}、${FILE} 及 ${TAG} 在此處仍可使用 類型: + 輸出包含遠端的名稱: 工作區 顏色 名稱 啟動時還原上次開啟的存放庫 確認繼續 未包含任何檔案變更! 您是否仍要提交 (--allow-empty)? - 自動暫存並提交 - 未包含任何檔案變更! 您是否仍要提交 (--allow-empty) 或者自動暫存全部變更並提交? + 暫存全部變更並提交 + 僅暫存所選變更並提交 + 未包含任何檔案變更! 您是否仍要提交 (--allow-empty) 或自動暫存變更並提交? 系統提示 - 您需要重新啟動此應用程式才能套用變更! + 您需要重新啟動此應用程式才能套用變更! 產生約定式提交訊息 破壞性變更: 關閉的 Issue: @@ -237,17 +313,15 @@ 類型: 複製 複製全部內容 + 複製為修補檔 複製完整路徑 複製路徑 新增分支... 新分支基於: 完成後切換到新分支 未提交變更: - 捨棄變更 - 擱置變更並自動復原 新分支名稱: 輸入分支名稱。 - 空格將以英文破折號取代 建立本機分支 允許覆寫現有分支 新增標籤... @@ -264,15 +338,22 @@ 輕量標籤 按住 Ctrl 鍵將直接以預設參數執行 剪下 + 捨棄變更 + 不做處理 + 擱置變更並自動復原 取消初始化子模組 - 強制取消,即使它包含本地變更 - 子模組 : + 強制取消,即使包含本機變更 + 子模組: 刪除分支確認 + 您是否也想刪除以下遠端分支? 分支名稱: + 強制刪除,即使包含未合併的提交 您正在刪除遠端上的分支,請務必小心! - 同時刪除遠端分支 ${0}$ 刪除多個分支 您正在嘗試一次性刪除多個分支,請務必仔細檢查後再刪除! + 刪除多個標籤 + 同時刪除遠端存放庫中的這些標籤 + 您正在嘗試一次性刪除多個標籤,請務必仔細檢查後再刪除! 刪除遠端確認 遠端名稱: 路徑: @@ -287,12 +368,13 @@ 標籤名稱: 同時刪除遠端存放庫中的此標籤 二進位檔案 - 檔案權限已變更 + 空檔案 第一個差異 忽略空白符號變化 混合對比 + 差異對比 並排對比 - 填充對比 + 滑桿對比 最後一個差異 LFS 物件變更 變更後 @@ -306,22 +388,29 @@ 子模組 已刪除 新增 + + {0} 項未提交變更 交換比對雙方 語法上色 自動換行 - 區塊切換上/下一個差異 使用外部合併工具檢視 顯示檔案的全部內容 減少可見的行數 增加可見的行數 請選擇需要對比的檔案 - 目錄内容變更歷史 + 目錄內容變更歷史 + 未提交的本機變更 + 目前分支 HEAD 與上游不相符 + 已更新至最新 捨棄變更 所有本機未提交的變更。 變更: 包括所有已忽略的檔案 + 包括已變更或刪除的檔案 + 包含未追蹤檔案 將捨棄總計 {0} 項已選取的變更 您無法復原此操作,請確認後再繼續! + 編輯分支的描述 + 目標: 書籤: 名稱: 目標: @@ -336,6 +425,7 @@ 遠端存放庫: 提取遠端存放庫內容 不追蹤此檔案的變更 + 自訂動作 捨棄變更... 捨棄已選的 {0} 個檔案變更... 使用 ${0}$ @@ -350,16 +440,26 @@ 使用對方版本 (theirs) 檔案歷史 檔案變更 - 檔案内容 + 檔案內容 + 檔案類型變更: + 刪除檔案類型: + 目錄 + 可執行檔案 + 新增檔案類型: + 一般檔案 + 子模組 + 符號連結 + 未知 Git 工作流 開發分支: 功能分支: 功能分支前置詞: + 完成 ${0}$ 完成功能分支 完成修復分支 完成發行分支 目標分支: - 完成後自動推送 + 合併前重定基底 壓縮為單一提交後合併 修復分支: 修復分支前置詞: @@ -368,10 +468,12 @@ 發行分支: 版本分支: 發行分支前置詞: + 開始於: 開始功能分支... 開始功能分支 開始修復分支... 開始修復分支 + 分支名稱: 輸入分支名稱 開始發行分支... 開始發行分支 @@ -391,6 +493,8 @@ 僅顯示被我鎖定的檔案 LFS 物件鎖 解鎖 + 解鎖所有由我鎖定的檔案 + 您確定要解鎖所有由您自己鎖定的檔案嗎? 強制解鎖 清理 (prune) 執行 `git lfs prune` 以從本機中清理目前版本不需要的 LFS 物件 @@ -403,16 +507,26 @@ 遠端存放庫: 追蹤名稱為「{0}」的檔案 追蹤所有 *{0} 檔案 + 選取要前往的提交 歷史記錄 作者 修改時間 + 提交時間 路線圖與訊息標題 提交編號 - 提交時間 + 上色分支 + 全部 + 僅目前分支 + 目前分支和選取的提交 + 僅選取的提交 已選取 {0} 項提交 + 顯示欄位 可以按住 Ctrl 或 Shift 鍵選擇多個提交 可以按住 ⌘ 或 ⇧ 鍵選擇多個提交 小提示: + 在新視窗中開啟 + 提交詳細資訊 + 比較 快速鍵參考 全域快速鍵 複製 (clone) 遠端存放庫 @@ -420,32 +534,35 @@ 切換到下一個頁面 切換到上一個頁面 新增頁面 + 開啟本機存放庫 開啟偏好設定面板 - 切換工作區 + 顯示工作區的下拉式選單 切換目前頁面 + 放大/縮小內容 存放庫頁面快速鍵 提交暫存區變更 提交暫存區變更並推送 自動暫存全部變更並提交 + 新增分支 提取 (fetch) 遠端的變更 切換左邊欄為分支/標籤等顯示模式 (預設) + 前往所選提交的子提交 + 前往所選提交的父提交 + 開啟命令面板 切換左邊欄為歷史搜尋模式 拉取 (pull) 遠端的變更 推送 (push) 本機變更到遠端存放庫 強制重新載入存放庫 + 在歷史頁面中展開/收合詳細面板 顯示本機變更 顯示歷史記錄 顯示擱置變更列表 - 文字編輯器快速鍵 - 關閉搜尋面板 - 前往下一個搜尋相符的位置 - 前往上一個搜尋相符的位置 - 使用外部比對工具檢視 - 開啟搜尋面板 捨棄 暫存 取消暫存 初始化存放庫 + 您是否要在該路徑執行 `git init` 以初始化存放庫? + 無法在指定路徑開啟本機存放庫。原因: 路徑: 揀選 (cherry-pick) 操作進行中。 正在處理提交 @@ -457,20 +574,44 @@ 正在復原提交 互動式重定基底 自動擱置變更並復原本機變更 + 繞過 pre-rebase hook 起始提交: 拖曳以重新排序提交 目標分支: 複製連結 在瀏覽器中開啟連結 + 命令列表 發生錯誤 系統提示 - 工作區列表 + 有新版本可用 + 開啟存放庫 頁面列表 + 工作區列表 合併分支 編輯合併訊息 目標分支: 合併方式: 合併來源: + 正在偵測合併衝突... + 合併操作不會產生衝突 + 偵測合併衝突時發生未知錯誤 + 合併操作將產生檔案衝突 + 先套用我方版本 (ours),再套用對方版本 (theirs) + 先套用對方版本 (theirs),再套用我方版本 (ours) + 套用兩者 + 所有衝突已經解決 + {0} 個衝突尚未解決 + 我方版本 (ours) + 下一個衝突 + 上一個衝突 + 合併結果 + 儲存並暫存 + 對方版本 (theirs) + 解決衝突 + 捨棄未儲存的變更? + 僅套用我方版本 (ours) + 僅套用對方版本 (theirs) + 復原變更 合併 (多個來源) 提交變更 合併策略: @@ -481,17 +622,26 @@ 調整存放庫分組 請選擇目標分組: 名稱: + 尚未設定 Git。請開啟 [偏好設定] 以設定 Git 路徑。 + 開啟 + 系統預設編輯器 瀏覽程式資料目錄 + 開啟檔案 使用外部比對工具檢視 - 開啟檔案... + 開啟本機存放庫 + 書籤: + 群組: + 存放庫位置: 選填。 新增分頁 - 設定書籤 關閉分頁 關閉其他分頁 關閉右側分頁 複製存放庫路徑 + 編輯 + 移至工作區 + 重新整理 新分頁 貼上 {0} 天前 @@ -504,16 +654,15 @@ {0} 個月前 {0} 年前 昨天 - 請使用 Shift + Enter 換行。Enter 鍵已被 [確定] 按鈕佔用。 偏好設定 AI - 分析變更差異提示詞 + 附加提示詞 (請使用 '-' 列出您的要求) API 金鑰 - 產生提交訊息提示詞 模型 + 自動取得可用的模型列表 名稱 + 從環境變數中 (輸入環境變數名稱) 讀取 API 金鑰 伺服器 - 啟用串流輸出 外觀設定 預設字型 編輯器 Tab 寬度 @@ -521,36 +670,47 @@ 預設 程式碼 等寬字型 - 僅在文字編輯器中使用等寬字型 佈景主題 自訂主題 + 允許自動隱藏捲軸 使用固定寬度的分頁標籤 使用系統原生預設視窗樣式 對比/合併工具 + 對比命令參數 + 可用參數: $LOCAL, $REMOTE + 合併命令參數 + 可用參數: $BASE, $LOCAL, $REMOTE, $MERGED 安裝路徑 填寫可執行檔案所在路徑 工具 一般設定 啟動時檢查軟體更新 日期時間格式 + 在樹狀變更目錄中啟用密集資料夾模式 顯示語言 最大歷史提交數 - 在提交路線圖中顯示修改時間而非提交時間 - 在提交詳細資訊中顯示後續提交 + 預設顯示 [本機變更] 頁面 + 在提交詳細資訊頁面預設顯示 [變更對比] + 在提交路線圖中顯示相對時間 在路線圖中顯示標籤 提交標題字數偵測 + 24 小時制 + 在提交路線圖中使用簡潔的分支標籤 + 產生 GitHub 風格的預設頭貼 Git 設定 自動換行轉換 預設複製 (clone) 路徑 電子郵件 預設 Git 使用者電子郵件 - 拉取變更時進行清理 (--prune) - 對比檔案時,預設忽略行尾的 CR 變更 (--ignore-cr-at-eol) + 提取後清理遠端已刪除分支 + 對比檔案時,預設忽略行末的 CR 變更 本軟體要求 Git 最低版本為 2.25.1 安裝路徑 啟用 HTTP SSL 驗證 + 使用 git-credential-libsecret 取代 git-credential-manager 使用者名稱 預設 Git 使用者名稱 + 在簽出或合併分支時,預設擱置變更並自動復原 Git 版本 GPG 簽章 啟用提交簽章 @@ -562,19 +722,18 @@ 填寫簽章提交所使用的金鑰 第三方工具整合 終端機/Shell + 啟動參數 + 請使用「.」表示目前工作目錄 安裝路徑 終端機/Shell 清理遠端已刪除分支 目標: - 清理工作區 - 清理在 `$GIT_COMMON_DIR/worktrees` 中的無效工作區資訊 + 清理工作樹 + 清理在 `$GIT_COMMON_DIR/worktrees` 中的無效工作樹資訊 拉取 (pull) 拉取分支: 本機分支: 未提交變更: - 捨棄變更 - 擱置變更並自動復原 - 同時更新所有子模組 遠端: 拉取 (提取並合併) 使用重定基底 (rebase) 合併分支 @@ -594,10 +753,17 @@ 推送到所有遠端存放庫 遠端存放庫: 標籤: + 推送到新的分支 + 輸入新的遠端分支名稱: 結束 重定基底 (rebase) 操作 自動擱置變更並復原本機變更 + 繞過 pre-rebase hook 目標提交: + 正在偵測重定基底衝突... + 重定基底操作不會產生衝突 + 偵測重定基底衝突時發生未知錯誤 + 重定基底操作將產生檔案衝突 新增遠端存放庫 編輯遠端存放庫 遠端名稱: @@ -605,38 +771,44 @@ 存放庫網址: 遠端存放庫的網址 複製遠端網址 + 自訂動作 刪除... 編輯... + 啟用定時自動提取 提取 (fetch) 更新 - 在瀏覽器中存取網址 + 在瀏覽器中開啟網址 清理遠端已刪除分支 - 刪除工作區操作確認 + 刪除工作樹操作確認 啟用 [--force] 選項 - 目標工作區: + 目標工作樹: 分支重新命名 新名稱: 新的分支名稱不能與現有分支名稱相同 分支: 中止 - 自動提取遠端變更中... + 正在自動提取遠端變更... 排序 依建立時間 - 依名稱升序 + 依名稱遞增排序 清理本存放庫 (GC) - 本操作將執行 `git gc` 命令。 + 本操作將執行 `git gc` 指令。 清空篩選規則 清空 設定本存放庫 下一步 自訂動作 沒有自訂的動作 + 首頁 捨棄所有變更 在檔案瀏覽器中開啟 快速搜尋分支/標籤/子模組 篩選以顯示或隱藏 + 摺疊篩選器 取消指定 在提交列表中隱藏 + 展開篩選器 以其篩選提交列表 + 個篩選規則已啟用 版面配置 橫向顯示 縱向顯示 @@ -644,26 +816,29 @@ 依時間排序 依拓撲排序 本機分支 + 更多選項... 回到 HEAD 新增分支 清除所有通知 - 路線圖中僅對目前分支上色 + 此存放庫 (資料夾) 在 {0} 中開啟 使用外部工具開啟 遠端列表 新增遠端 + 解決衝突 搜尋提交 作者 - 提交者 變更內容 提交訊息 路徑 提交編號 僅搜尋目前分支 + 只顯示分支和標籤引用的提交 只顯示合併提交的第一父提交 - 顯示遺失參照的提交 - 以樹型結構展示 - 以樹型結構展示 + 顯示內容 + 顯示失去參照的提交 + 以樹狀結構顯示 + 以樹狀結構顯示 跳過此提交 提交統計 子模組列表 @@ -675,51 +850,50 @@ 依名稱 排序 在終端機中開啟 - 在提交列表中使用相對時間 檢視 Git 指令記錄 檢視遠端存放庫 '{0}' - 工作區列表 - 新增工作區 + 工作樹列表 + 新增工作樹 清理 遠端存放庫網址 重設目前分支到指定版本 重設模式: 移至提交: 目前分支: - 重設選取的分支(非目前分支) - 重設位置 : - 選取分支 : + 重設選取的分支 (非目前分支) + 重設位置: + 選取分支: 在檔案瀏覽器中檢視 復原操作確認 目標提交: 復原後提交變更 - 編輯提交訊息 - 執行操作中,請耐心等待... + 正在執行操作,請耐心等待... 儲存 另存新檔... 修補檔已成功儲存! 掃描存放庫 頂層目錄: + 掃描其他自訂目錄 檢查更新... 軟體有版本更新: + 目前版本: 取得最新版本資訊失敗! 下載 忽略此版本 + 新版本發行日期: 軟體更新 目前已是最新版本。 設定子模組的追蹤分支 子模組: 目前追蹤分支: 變更為: - 選購。為空時設定為預設值。 + 選填。留空時設定為預設值。 切換上游分支 本機分支: 取消設定上游分支 上游分支: 複製提交編號 前往此提交 - 壓縮為單個提交 - 合併入: SSH 金鑰: SSH 金鑰檔案 開 始 @@ -732,6 +906,8 @@ 已選取的檔案中的變更均會被擱置! 擱置本機變更 套用 (apply) + 套用 (apply) 所選變更 + 簽出分支 複製描述訊息 刪除 (drop) 另存為修補檔 (patch)... @@ -749,6 +925,7 @@ 子模組 新增子模組 追蹤分支 + 追蹤分支 相對路徑 取消初始化 提取子模組 @@ -758,8 +935,8 @@ 相對存放庫路徑: 本機存放的相對路徑。 刪除 - 更改追蹤分支 - 更改遠端網址 + 變更追蹤分支 + 變更遠端網址 狀態 未提交變更 未初始化 @@ -767,18 +944,29 @@ 未解決的衝突 更新 存放庫 + 子模組變更詳細資訊 + 開啟變更詳細資訊 確 定 - 複製標籤名稱 - 複製標籤訊息 + 建立者 + 建立時間 + 簽出標籤... + 比較 2 個標籤 + 與其他分支或標籤比較... + 與目前 HEAD 比較 + 標籤訊息 + 標籤名稱 + 建立者 + 複製標籤名稱 自訂動作 刪除 ${0}$... + 刪除所選的 {0} 個標籤... 合併 ${0}$ 到 ${1}$... 推送 ${0}$... 更新子模組 更新所有子模組 - 如果子模組尚未初始化,則初始化它 - 遞迴更新子孫模組 + 如果子模組尚未初始化,則將其初始化 子模組: + 遞迴更新子模組 更新至子模組的遠端追蹤分支 存放庫網址: 記錄 @@ -794,7 +982,7 @@ 支援拖放以新增目錄與自訂群組。 編輯 調整存放庫分組 - 開啟所有包含存放庫 + 開啟所有存放庫 開啟本機存放庫 開啟終端機 重新掃描預設複製 (clone) 目錄下的存放庫 @@ -805,25 +993,31 @@ 忽略同路徑下所有 *{0} 檔案 忽略本路徑下的新增檔案 忽略本檔案 + 忽略同路徑下所有新增檔案 修補 現在您已可將其加入暫存區中 + 清除提交訊息歷史 + 您確定要清除所有提交訊息記錄嗎 (執行後無法復原)? 提 交 提交並推送 歷史輸入/範本 觸發點擊事件 提交 (修改原始提交) 自動暫存全部變更並提交 - 您正在向一个分離狀態的 HEAD 提交變更,您確定要繼續提交嗎? + HEAD 目前處於分離狀態。您確定要繼續提交變更嗎? 您已暫存 {0} 個檔案,但只顯示 {1} 個檔案 ({2} 個檔案被篩選器隱藏)。您確定要繼續提交嗎? 偵測到衝突 - 使用外部合併工具開啟 + 解決衝突 + 使用外部工具解決衝突 使用外部合併工具開啟 檔案衝突已解決 使用我方版本 (ours) 使用對方版本 (theirs) + 搜尋變更... 顯示未追蹤檔案 沒有提交訊息記錄 沒有可套用的提交訊息範本 + 繞過 Hooks 檢查 重設作者 署名 已暫存 @@ -834,11 +1028,16 @@ 暫存所有檔案 檢視不追蹤變更的檔案 範本: ${0}$ - 工作區: + 工作區: 設定工作區... - 本機工作區 - 複製工作區路徑 - 鎖定工作區 - 移除工作區 - 解除鎖定工作區 + 本機工作樹 + 分支 + 複製工作樹路徑 + 最新提交 + 鎖定工作樹 + 開啟工作樹 + 位置 + 移除工作樹 + 解除鎖定工作樹 + diff --git a/src/Resources/Styles.axaml b/src/Resources/Styles.axaml index e1ebf0061..27878b5cc 100644 --- a/src/Resources/Styles.axaml +++ b/src/Resources/Styles.axaml @@ -1,6 +1,5 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - @@ -519,175 +697,19 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - + + + + + + - - - - - - + + + + + + diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml index 3b4637331..414316380 100644 --- a/src/Resources/Themes.axaml +++ b/src/Resources/Themes.axaml @@ -3,20 +3,24 @@ #FFF0F5F9 - #00000000 + #FF999999 #FFCFDEEA #FFF0F5F9 - #FFF8F8F8 + #FFF4F8FB + White #FFFAFAFA #FFB0CEE8 #FF1F1F1F #FF836C2E - #FFFFFFFF + #FFFFFFFF + #400078D7 + #40FF8C00 #FFCFCFCF #FF898989 #FFCFCFCF #FFF8F8F8 White + #FF898989 #FF1F1F1F #FF6F6F6F #10000000 @@ -24,35 +28,45 @@ #80FF9797 #A7E1A7 #F19B9D + DarkCyan #0000EE #FFE4E4E4 + Black + #FFF0F5F9 #FF252525 - #FF444444 + #FF606060 #FF1F1F1F - #FF2C2C2C + #FF2F2F2F #FF2B2B2B + #FF393939 #FF1C1C1C #FF8F8F8F - #FFDDDDDD + #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 @@ -61,16 +75,20 @@ + - + + + + @@ -80,10 +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 8d76934c7..fc8fe02d1 100644 --- a/src/SourceGit.csproj +++ b/src/SourceGit.csproj @@ -1,12 +1,13 @@ - + WinExe - net9.0 + net10.0 App.manifest App.ico $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)\\..\\VERSION")) true true + false SourceGit OpenSource GIT client @@ -24,10 +25,18 @@ link + + 13.0 + + $(DefineConstants);DISABLE_UPDATE_DETECTION + + + + @@ -39,22 +48,20 @@ - - - - - - - - - + + + + + + + - - - - - - + + + + + + @@ -62,7 +69,15 @@ - - - + + + + + + + + + + + diff --git a/src/ViewModels/AIAssistant.cs b/src/ViewModels/AIAssistant.cs index 920fafcf5..e9e4692fe 100644 --- a/src/ViewModels/AIAssistant.cs +++ b/src/ViewModels/AIAssistant.cs @@ -1,16 +1,26 @@ 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; @@ -23,28 +33,90 @@ public string Text private set => SetProperty(ref _text, value); } - public AIAssistant(Repository repo, Models.OpenAIService service, List changes, Action onApply) + public string Response + { + get => _response; + private set => SetProperty(ref _response, value); + } + + public AIAssistant(Repository repo, AI.Service service, List changes) { _repo = repo; _service = service; - _changes = changes; - _onApply = onApply; _cancel = new CancellationTokenSource(); - Gen(); + 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 void Regen() + 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(); - Gen(); + 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 Apply() + public void Use(string text) { - _onApply?.Invoke(Text); + _repo.SetCommitMessage(text); } public void Cancel() @@ -52,29 +124,34 @@ public void Cancel() _cancel?.Cancel(); } - private void Gen() + private void SerializeChange(Models.Change c, StringBuilder builder) { - Text = string.Empty; - IsGenerating = true; - - _cancel = new CancellationTokenSource(); - Task.Run(async () => + var status = c.Index switch { - await new Commands.GenerateCommitMessage(_service, _repo.FullPath, _changes, _cancel.Token, message => - { - Dispatcher.UIThread.Post(() => Text = message); - }).ExecAsync().ConfigureAwait(false); + 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'); - Dispatcher.UIThread.Post(() => IsGenerating = false); - }, _cancel.Token); + 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 Models.OpenAIService _service = null; - private List _changes = null; - private Action _onApply = 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 5a6c019fc..f5182ef9f 100644 --- a/src/ViewModels/AddRemote.cs +++ b/src/ViewModels/AddRemote.cs @@ -44,6 +44,12 @@ public string SSHKey set => SetProperty(ref _sshkey, value, true); } + public bool FetchWithoutTags + { + get; + set; + } = false; + public AddRemote(Repository repo) { _repo = repo; @@ -89,7 +95,7 @@ public static ValidationResult ValidateSSHKey(string sshkey, ValidationContext c public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Adding remote ..."; var log = _repo.CreateLog("Add Remote"); @@ -105,7 +111,7 @@ public override async Task Sure() .Use(log) .SetAsync($"remote.{_name}.sshkey", _useSSH ? SSHKey : null); - await new Commands.Fetch(_repo.FullPath, _name, false, false) + await new Commands.Fetch(_repo.FullPath, _name, FetchWithoutTags, false) .Use(log) .RunAsync(); } @@ -114,7 +120,6 @@ public override async Task Sure() _repo.MarkFetched(); _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/AddSubmodule.cs b/src/ViewModels/AddSubmodule.cs index 313b2e8e2..e9554ea34 100644 --- a/src/ViewModels/AddSubmodule.cs +++ b/src/ViewModels/AddSubmodule.cs @@ -42,7 +42,7 @@ public static ValidationResult ValidateURL(string url, ValidationContext ctx) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Adding submodule..."; var log = _repo.CreateLog("Add Submodule"); @@ -64,7 +64,6 @@ public override async Task Sure() .AddAsync(_url, relativePath, Recursive); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/AddToIgnore.cs b/src/ViewModels/AddToIgnore.cs index d2a194f43..c88cf1e7f 100644 --- a/src/ViewModels/AddToIgnore.cs +++ b/src/ViewModels/AddToIgnore.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; @@ -6,6 +7,11 @@ namespace SourceGit.ViewModels { public class AddToIgnore : Popup { + public List StorageFiles + { + get; + } + [Required(ErrorMessage = "Ignore pattern is required!")] public string Pattern { @@ -14,28 +20,34 @@ public string Pattern } [Required(ErrorMessage = "Storage file is required!!!")] - public Models.GitIgnoreFile StorageFile + public Models.GitIgnoreFile SelectedStorageFile { - get; - set; + get => _selectedStorageFile; + set + { + if (SetProperty(ref _selectedStorageFile, value, true)) + Pattern = value.Pattern; + } } public AddToIgnore(Repository repo, string pattern) { _repo = repo; _pattern = pattern; - StorageFile = Models.GitIgnoreFile.Supported[0]; + + StorageFiles = Models.GitIgnoreFile.GetSupported(repo.FullPath, repo.GitDir, pattern); + SelectedStorageFile = StorageFiles[0]; } public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Adding Ignored File(s) ..."; - var file = StorageFile.GetFullPath(_repo.FullPath, _repo.GitDir); + var file = _selectedStorageFile.FullPath; if (!File.Exists(file)) { - await File.WriteAllLinesAsync(file, [_pattern]); + await File.WriteAllLinesAsync(file!, [_pattern]); } else { @@ -47,11 +59,11 @@ public override async Task Sure() } _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); 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 3c3d69ff5..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; @@ -23,61 +24,71 @@ 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; } public static ValidationResult ValidateWorktreePath(string path, ValidationContext ctx) @@ -106,11 +117,11 @@ public static ValidationResult ValidateWorktreePath(string path, ValidationConte 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"); Use(log); @@ -120,14 +131,54 @@ public override async Task Sure() .AddAsync(_path, branchName, _createNewBranch, tracking); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } + private void AutoSelectTrackingBranch() + { + if (!_setTrackingBranch || RemoteBranches.Count == 0) + return; + + var name = GetBranchName(true); + if (!string.IsNullOrEmpty(name)) + { + var remoteBranch = RemoteBranches.Find(b => b.Name.Equals(name, StringComparison.Ordinal)); + remoteBranch ??= RemoteBranches.Find(b => b.Name.EndsWith("/" + name, StringComparison.Ordinal)); + if (remoteBranch != null) + { + SelectedTrackingBranch = remoteBranch; + return; + } + } + + SelectedTrackingBranch = RemoteBranches[0]; + } + + private string GetBranchName(bool fallback) + { + do + { + if (!_createNewBranch) + { + if (_selectedBranch != null) + return _selectedBranch.Name; + + break; + } + + if (!string.IsNullOrEmpty(_newBranchName)) + return _newBranchName; + } while (false); + + return fallback ? System.IO.Path.GetFileName(_path.TrimEnd('/', '\\')) : string.Empty; + } + private Repository _repo = null; private string _path = string.Empty; private bool _createNewBranch = true; - private 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 709a66fd6..14ecc0730 100644 --- a/src/ViewModels/Apply.cs +++ b/src/ViewModels/Apply.cs @@ -1,12 +1,14 @@ -using System.ComponentModel.DataAnnotations; +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 { @@ -26,40 +28,91 @@ public Models.ApplyWhiteSpaceMode SelectedWhiteSpaceMode set; } + 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; - 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 async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Apply patch..."; + var finalPatchFile = _patchFile; + if (_fromClipboard) + { + if (Clipboard == null) + { + _repo.SendNotification("Clipboard service is not available!!!", true); + return false; + } + + var content = await Clipboard.TryGetTextAsync(); + if (string.IsNullOrEmpty(content) || !content.StartsWith("diff --git ", StringComparison.Ordinal)) + { + _repo.SendNotification("There's no valid patch content in clipboard!!!", true); + return false; + } + + finalPatchFile = Path.GetTempFileName(); + File.WriteAllText(finalPatchFile, content); + } + var log = _repo.CreateLog("Apply Patch"); Use(log); - var succ = await new Commands.Apply(_repo.FullPath, _patchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg, null) + var extra = ThreeWayMerge ? "--3way" : string.Empty; + var succ = await new Commands.Apply(_repo.FullPath, finalPatchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg, extra) .Use(log) .ExecAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); + + 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 index b21d6fe80..8fb9d4b72 100644 --- a/src/ViewModels/ApplyStash.cs +++ b/src/ViewModels/ApplyStash.cs @@ -30,7 +30,7 @@ public ApplyStash(Repository repo, Models.Stash stash) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Applying stash: {Stash.Name}"; var log = _repo.CreateLog("Apply Stash"); @@ -40,13 +40,21 @@ public override async Task Sure() .Use(log) .ApplyAsync(Stash.Name, RestoreIndex); - if (succ && DropAfterApply) - await new Commands.Stash(_repo.FullPath) - .Use(log) - .DropAsync(Stash.Name); + if (succ) + { + _repo.MarkWorkingCopyDirtyManually(); + + if (DropAfterApply) + { + await new Commands.Stash(_repo.FullPath) + .Use(log) + .DropAsync(Stash.Name); + + _repo.MarkStashesDirtyManually(); + } + } log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/Archive.cs b/src/ViewModels/Archive.cs index 6f86357df..7f5840eec 100644 --- a/src/ViewModels/Archive.cs +++ b/src/ViewModels/Archive.cs @@ -46,7 +46,7 @@ public Archive(Repository repo, Models.Tag tag) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Archiving ..."; var log = _repo.CreateLog("Archive"); @@ -57,10 +57,9 @@ public override async Task Sure() .ExecAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); if (succ) - App.SendNotification(_repo.FullPath, $"Save archive to : {_saveFile}"); + _repo.SendNotification($"Save archive to : {_saveFile}"); return succ; } diff --git a/src/ViewModels/Blame.cs b/src/ViewModels/Blame.cs index affe40d92..be05fccd4 100644 --- a/src/ViewModels/Blame.cs +++ b/src/ViewModels/Blame.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; @@ -9,9 +10,20 @@ namespace SourceGit.ViewModels { public class Blame : ObservableObject { - public string FilePath + public string File { - get; + get => _file; + private set => SetProperty(ref _file, value); + } + + public bool IgnoreWhitespace + { + get => _ignoreWhitespace; + set + { + if (SetProperty(ref _ignoreWhitespace, value)) + SetBlameData(_navigationHistory[0]); + } } public Models.Commit Revision @@ -20,6 +32,12 @@ public Models.Commit 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; @@ -48,14 +66,9 @@ public bool CanForward public Blame(string repo, string file, Models.Commit commit) { var sha = commit.SHA.Substring(0, 10); - - FilePath = file; - Revision = commit; - _repo = repo; - _navigationHistory.Add(sha); - _commits.Add(sha, commit); - SetBlameData(sha); + _navigationHistory.Add(new RevisionInfo(file, sha)); + SetBlameData(_navigationHistory[0]); } public string GetCommitMessage(string sha) @@ -63,10 +76,7 @@ public string GetCommitMessage(string sha) if (_commitMessages.TryGetValue(sha, out var msg)) return msg; - msg = new Commands.QueryCommitFullMessage(_repo, sha) - .GetResultAsync() - .Result; - + msg = new Commands.QueryCommitFullMessage(_repo, sha).GetResult(); _commitMessages[sha] = msg; return msg; } @@ -93,33 +103,60 @@ public void Forward() NavigateToCommit(_navigationHistory[_navigationActiveIndex]); } - public void NavigateToCommit(string commitSHA) + public void GotoPrevRevision() { - if (!_navigationHistory[_navigationActiveIndex].Equals(commitSHA, StringComparison.Ordinal)) + if (_prevRevision != null) + NavigateToCommit(_file, _prevRevision.SHA.Substring(0, 10)); + } + + public void NavigateToCommit(string file, string sha) + { + if (App.GetLauncher() is { Pages: { } pages }) { - _navigationHistory.Add(commitSHA); - _navigationActiveIndex = _navigationHistory.Count - 1; - OnPropertyChanged(nameof(CanBack)); - OnPropertyChanged(nameof(CanForward)); + foreach (var page in pages) + { + if (page.Data is Repository repo && repo.FullPath.Equals(_repo)) + { + repo.NavigateToCommit(sha); + break; + } + } } - if (!Revision.SHA.StartsWith(commitSHA, StringComparison.Ordinal)) - SetBlameData(commitSHA); + 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(commitSHA); + repo.NavigateToCommit(rev.SHA); break; } } } + + if (!Revision.SHA.StartsWith(rev.SHA, StringComparison.Ordinal)) + SetBlameData(rev); } - private void SetBlameData(string commitSHA) + private void SetBlameData(RevisionInfo rev) { if (_cancellationSource is { IsCancellationRequested: false }) _cancellationSource.Cancel(); @@ -127,32 +164,34 @@ private void SetBlameData(string commitSHA) _cancellationSource = new CancellationTokenSource(); var token = _cancellationSource.Token; - if (_commits.TryGetValue(commitSHA, out var c)) - { - Revision = c; - } - else + File = rev.File; + + Task.Run(async () => { - Task.Run(async () => - { - var result = await new Commands.QuerySingleCommit(_repo, commitSHA) - .GetResultAsync() - .ConfigureAwait(false); + 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(() => + Dispatcher.UIThread.Post(() => + { + if (!token.IsCancellationRequested) { - if (!token.IsCancellationRequested) - { - _commits.Add(commitSHA, result); - Revision = result ?? new Models.Commit() { SHA = commitSHA }; - } - }); - }, token); - } + Revision = commits.Count > 0 ? commits[0] : null; + PrevRevision = commits.Count > 1 ? commits[1] : null; + } + }); + }); Task.Run(async () => { - var result = await new Commands.Blame(_repo, FilePath, commitSHA) + var result = await new Commands.Blame(_repo, rev.File, rev.SHA, _ignoreWhitespace) .ReadAsync() .ConfigureAwait(false); @@ -164,13 +203,27 @@ private void SetBlameData(string commitSHA) }, token); } + private class RevisionInfo + { + public string File { get; set; } = string.Empty; + public string SHA { get; set; } = string.Empty; + + public RevisionInfo(string file, string sha) + { + File = file; + SHA = sha; + } + } + private string _repo; + 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 List _navigationHistory = []; private Models.BlameData _data = null; - private Dictionary _commits = new(); 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 index 4a51244cb..0debfba0e 100644 --- a/src/ViewModels/BlockNavigation.cs +++ b/src/ViewModels/BlockNavigation.cs @@ -1,70 +1,54 @@ +using System; using System.Collections.Generic; -using Avalonia.Collections; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { + public enum BlockNavigationDirection + { + First = 0, + Prev, + Next, + Last + } + public class BlockNavigation : ObservableObject { - public class Block + public record Block(int Start, int End) { - public int Start { get; set; } = 0; - public int End { get; set; } = 0; - - public Block(int start, int end) - { - Start = start; - End = end; - } - - public bool IsInRange(int line) + public bool Contains(int line) { return line >= Start && line <= End; } } - public AvaloniaList Blocks - { - get; - } = []; - - public int Current - { - get => _current; - private set => SetProperty(ref _current, value); - } - public string Indicator { get { - if (Blocks.Count == 0) + if (_blocks.Count == 0) return "-/-"; - if (_current >= 0 && _current < Blocks.Count) - return $"{_current + 1}/{Blocks.Count}"; + if (_current >= 0 && _current < _blocks.Count) + return $"{_current + 1}/{_blocks.Count}"; - return $"-/{Blocks.Count}"; + return $"-/{_blocks.Count}"; } } - public BlockNavigation(object context) + public BlockNavigation(List lines, int cur) { - Blocks.Clear(); - Current = -1; - - var lines = new List(); - if (context is Models.TextDiff combined) - lines = combined.Lines; - else if (context is TwoSideTextDiff twoSide) - lines = twoSide.Old; + _blocks.Clear(); if (lines.Count == 0) + { + _current = -1; return; + } var lineIdx = 0; var blockStartIdx = 0; - var isNewBlock = true; + var isReadingBlock = false; var blocks = new List(); foreach (var line in lines) @@ -72,73 +56,109 @@ public BlockNavigation(object context) lineIdx++; if (line.Type is Models.TextDiffLineType.Added or Models.TextDiffLineType.Deleted or Models.TextDiffLineType.None) { - if (isNewBlock) + if (!isReadingBlock) { - isNewBlock = false; + isReadingBlock = true; blockStartIdx = lineIdx; } } else { - if (!isNewBlock) + if (isReadingBlock) { blocks.Add(new Block(blockStartIdx, lineIdx - 1)); - isNewBlock = true; + isReadingBlock = false; } } } - if (!isNewBlock) - blocks.Add(new Block(blockStartIdx, lines.Count - 1)); + if (isReadingBlock) + blocks.Add(new Block(blockStartIdx, lines.Count)); - Blocks.AddRange(blocks); + _blocks.AddRange(blocks); + _current = Math.Min(_blocks.Count - 1, cur); } - public Block GetCurrentBlock() + public int GetCurrentBlockIndex() { - return (_current >= 0 && _current < Blocks.Count) ? Blocks[_current] : null; + return _current; } - public Block GotoFirst() + public Block GetCurrentBlock() { - if (Blocks.Count == 0) - return null; + if (_current >= 0 && _current < _blocks.Count) + return _blocks[_current]; - Current = 0; - return Blocks[_current]; + return null; } - public Block GotoPrev() + public Block Goto(BlockNavigationDirection direction) { - if (Blocks.Count == 0) + if (_blocks.Count == 0) return null; - if (_current == -1) - Current = 0; - else if (_current > 0) - Current = _current - 1; - return Blocks[_current]; + _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 Block GotoNext() + public void UpdateByChunk(TextDiffSelectedChunk chunk) { - if (Blocks.Count == 0) - return null; + _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 (_current < Blocks.Count - 1) - Current = _current + 1; - return Blocks[_current]; + if (chunkEnd < block.Start) + { + _current = i - 1; + break; + } + + _current = i; + } } - public Block GotoLast() + public void UpdateByCaretPosition(int caretLine) { - if (Blocks.Count == 0) - return null; + 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; + } - Current = Blocks.Count - 1; - return Blocks[_current]; + OnPropertyChanged(nameof(Indicator)); } - private int _current = -1; + private int _current; + private readonly List _blocks = []; } } diff --git a/src/ViewModels/BranchCompare.cs b/src/ViewModels/BranchCompare.cs deleted file mode 100644 index f88bc32ab..000000000 --- a/src/ViewModels/BranchCompare.cs +++ /dev/null @@ -1,279 +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 bool IsLoading - { - get => _isLoading; - private set => SetProperty(ref _isLoading, value); - } - - 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 is { Count: 1 }) - DiffContext = new DiffContext(_repo, new Models.DiffOption(_based.Head, _to.Head, value[0]), _diffContext); - else - DiffContext = null; - } - } - } - - public string SearchFilter - { - get => _searchFilter; - set - { - if (SetProperty(ref _searchFilter, value)) - RefreshVisible(); - } - } - - public DiffContext DiffContext - { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); - } - - public BranchCompare(string repo, Models.Branch baseBranch, Models.Branch toBranch) - { - _repo = repo; - _based = baseBranch; - _to = toBranch; - - Refresh(); - } - - public void NavigateTo(string commitSHA) - { - var launcher = App.GetLauncher(); - if (launcher == null) - return; - - foreach (var page in launcher.Pages) - { - if (page.Data is Repository repo && repo.FullPath.Equals(_repo)) - { - repo.NavigateToCommit(commitSHA); - break; - } - } - } - - public void Swap() - { - (Base, To) = (_to, _based); - - VisibleChanges = []; - SelectedChanges = []; - - if (_baseHead != null) - (BaseHead, ToHead) = (_toHead, _baseHead); - - Refresh(); - } - - public void ClearSearchFilter() - { - SearchFilter = string.Empty; - } - - public string GetAbsPath(string path) - { - return Native.OS.GetAbsPath(_repo, path); - } - - public ContextMenu CreateChangeContextMenu() - { - if (_selectedChanges is not { Count: 1 }) - return null; - - var change = _selectedChanges[0]; - var menu = new ContextMenu(); - - var openWithMerger = new MenuItem(); - openWithMerger.Header = App.Text("OpenInExternalMergeTool"); - openWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; - openWithMerger.Click += (_, ev) => - { - var toolType = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption(_based.Head, _to.Head, change); - - new Commands.DiffTool(_repo, toolType, toolPath, opt).Open(); - ev.Handled = true; - }; - menu.Items.Add(openWithMerger); - - 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.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, ev) => - { - await App.CopyTextAsync(change.Path); - ev.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copyPath); - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await App.CopyTextAsync(Native.OS.GetAbsPath(_repo, change.Path)); - e.Handled = true; - }; - menu.Items.Add(copyFullPath); - - return menu; - } - - private void Refresh() - { - IsLoading = true; - - Task.Run(async () => - { - if (_baseHead == null) - { - var baseHead = await new Commands.QuerySingleCommit(_repo, _based.Head) - .GetResultAsync() - .ConfigureAwait(false); - - var toHead = await new Commands.QuerySingleCommit(_repo, _to.Head) - .GetResultAsync() - .ConfigureAwait(false); - - Dispatcher.UIThread.Post(() => - { - BaseHead = baseHead; - ToHead = toHead; - }); - } - - _changes = await new Commands.CompareRevisions(_repo, _based.Head, _to.Head) - .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(() => - { - 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.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 5c5d2de1d..53ac1df9b 100644 --- a/src/ViewModels/BranchTreeNode.cs +++ b/src/ViewModels/BranchTreeNode.cs @@ -54,20 +54,6 @@ public string BranchesCount get => Counter > 0 ? $"({Counter})" : string.Empty; } - public string Tooltip - { - get - { - if (Backend is Models.Branch b) - return b.FriendlyName; - - if (Backend is Models.Remote r) - return r.URL; - - return null; - } - } - private Models.FilterMode _filterMode = Models.FilterMode.None; private bool _isExpanded = false; private CornerRadius _cornerRadius = new CornerRadius(4); @@ -266,8 +252,8 @@ private void SortNodesByTime(List nodes) SortNodesByTime(node.Children); } - private readonly Models.BranchSortMode _localSortMode = Models.BranchSortMode.Name; - private readonly Models.BranchSortMode _remoteSortMode = Models.BranchSortMode.Name; + 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 index f356234bf..74deaddda 100644 --- a/src/ViewModels/ChangeSubmoduleUrl.cs +++ b/src/ViewModels/ChangeSubmoduleUrl.cs @@ -39,7 +39,7 @@ public override async Task Sure() if (_url.Equals(Submodule.URL, StringComparison.Ordinal)) return true; - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Change submodule's url..."; var log = _repo.CreateLog("Change Submodule's URL"); @@ -50,7 +50,6 @@ public override async Task Sure() .SetURLAsync(Submodule.Path, _url); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/ChangeTreeNode.cs b/src/ViewModels/ChangeTreeNode.cs index 4d71a153b..c35f4fc1f 100644 --- a/src/ViewModels/ChangeTreeNode.cs +++ b/src/ViewModels/ChangeTreeNode.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; - +using System.IO; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -7,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(); @@ -32,22 +33,22 @@ public bool 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(); @@ -57,12 +58,11 @@ public static List Build(IList changes, HashSet 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 ce6195b8d..3c47d47a3 100644 --- a/src/ViewModels/Checkout.cs +++ b/src/ViewModels/Checkout.cs @@ -4,41 +4,39 @@ namespace SourceGit.ViewModels { public class Checkout : Popup { - public string Branch + public string BranchName { - get; + get => _branch.Name; } - public bool DiscardLocalChanges + public bool HasLocalChanges { - get; - set; + get => _repo.LocalChangesCount > 0; } - public bool IsRecurseSubmoduleVisible + public Models.DealWithLocalChanges DealWithLocalChanges { - get => _repo.Submodules.Count > 0; - } - - public bool RecurseSubmodules - { - get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; - set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; + get; + set; } - public Checkout(Repository repo, string branch) + public Checkout(Repository repo, Models.Branch branch) { _repo = repo; - Branch = branch; - DiscardLocalChanges = false; + _branch = branch; + + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; } 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 '{Branch}'"); + var log = _repo.CreateLog($"Checkout '{branchName}'"); Use(log); if (_repo.CurrentBranch is { IsDetachedHead: true }) @@ -47,72 +45,71 @@ public override async Task Sure() if (refs.Count == 0) { var msg = App.Text("Checkout.WarnLostCommits"); - var shouldContinue = await App.AskConfirmAsync(msg, null); + var shouldContinue = await App.AskConfirmAsync(msg); if (!shouldContinue) - { - _repo.SetWatcherEnabled(true); return true; - } } } var succ = false; var needPopStash = false; - if (!DiscardLocalChanges) + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(branchName, false); + } + else if (DealWithLocalChanges == Models.DealWithLocalChanges.StashAndReapply) { - var changes = await new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).GetResultAsync(); + 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"); + .PushAsync("CHECKOUT_AUTO_STASH", false); if (!succ) { log.Complete(); - _repo.SetWatcherEnabled(true); + _repo.MarkWorkingCopyDirtyManually(); return false; } needPopStash = true; } - } - succ = await new Commands.Checkout(_repo.FullPath) - .Use(log) - .BranchAsync(Branch, DiscardLocalChanges); + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(branchName, false); + } + else + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(branchName, true); + } if (succ) { - if (IsRecurseSubmoduleVisible && RecurseSubmodules) - { - var submodules = await new Commands.QueryUpdatableSubmodules(_repo.FullPath).GetResultAsync(); - if (submodules.Count > 0) - await new Commands.Submodule(_repo.FullPath) - .Use(log) - .UpdateAsync(submodules, true, true); - } + await _repo.AutoUpdateSubmodulesAsync(log); if (needPopStash) await new Commands.Stash(_repo.FullPath) .Use(log) .PopAsync("stash@{0}"); + + _repo.RefreshAfterCheckoutBranch(_branch); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); } log.Complete(); - - var b = _repo.Branches.Find(x => x.IsLocal && x.Name == Branch); - if (b != null && _repo.HistoriesFilterMode == Models.FilterMode.Included) - _repo.SetBranchFilterMode(b, Models.FilterMode.Included, true, false); - - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - - ProgressDescription = "Waiting for branch updated..."; - await Task.Delay(400); 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 index 205bbbd7b..50dc99aa3 100644 --- a/src/ViewModels/CheckoutAndFastForward.cs +++ b/src/ViewModels/CheckoutAndFastForward.cs @@ -14,21 +14,15 @@ public Models.Branch RemoteBranch get; } - public bool DiscardLocalChanges + public bool HasLocalChanges { - get; - set; + get => _repo.LocalChangesCount > 0; } - public bool IsRecurseSubmoduleVisible + public Models.DealWithLocalChanges DealWithLocalChanges { - get => _repo.Submodules.Count > 0; - } - - public bool RecurseSubmodules - { - get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; - set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; + get; + set; } public CheckoutAndFastForward(Repository repo, Models.Branch localBranch, Models.Branch remoteBranch) @@ -36,11 +30,15 @@ public CheckoutAndFastForward(Repository repo, Models.Branch localBranch, Models _repo = repo; LocalBranch = localBranch; RemoteBranch = remoteBranch; + + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; } public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Checkout and Fast-Forward '{LocalBranch.Name}' ..."; var log = _repo.CreateLog($"Checkout and Fast-Forward '{LocalBranch.Name}' ..."); @@ -52,68 +50,71 @@ public override async Task Sure() if (refs.Count == 0) { var msg = App.Text("Checkout.WarnLostCommits"); - var shouldContinue = await App.AskConfirmAsync(msg, null); + var shouldContinue = await App.AskConfirmAsync(msg); if (!shouldContinue) - { - _repo.SetWatcherEnabled(true); return true; - } } } var succ = false; var needPopStash = false; - if (!DiscardLocalChanges) + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) { - var changes = await new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).GetResultAsync(); + 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"); + .PushAsync("CHECKOUT_AND_FASTFORWARD_AUTO_STASH", false); if (!succ) { log.Complete(); - _repo.SetWatcherEnabled(true); + _repo.MarkWorkingCopyDirtyManually(); return false; } needPopStash = true; } - } - succ = await new Commands.Checkout(_repo.FullPath) - .Use(log) - .BranchAsync(LocalBranch.Name, RemoteBranch.Head, DiscardLocalChanges, 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) { - if (IsRecurseSubmoduleVisible && RecurseSubmodules) - { - var submodules = await new Commands.QueryUpdatableSubmodules(_repo.FullPath).GetResultAsync(); - if (submodules.Count > 0) - await new Commands.Submodule(_repo.FullPath) - .Use(log) - .UpdateAsync(submodules, true, true); - } + await _repo.AutoUpdateSubmodulesAsync(log); if (needPopStash) await new Commands.Stash(_repo.FullPath) .Use(log) .PopAsync("stash@{0}"); - } - log.Complete(); - - if (_repo.HistoriesFilterMode == Models.FilterMode.Included) - _repo.SetBranchFilterMode(LocalBranch, Models.FilterMode.Included, true, false); + LocalBranch.Behind.Clear(); + LocalBranch.Head = RemoteBranch.Head; + LocalBranch.CommitterDate = RemoteBranch.CommitterDate; - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); + _repo.RefreshAfterCheckoutBranch(LocalBranch); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + } - ProgressDescription = "Waiting for branch updated..."; - await Task.Delay(400); + log.Complete(); return succ; } 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 a77a72356..000000000 --- a/src/ViewModels/CheckoutCommit.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.ViewModels -{ - public class CheckoutCommit : Popup - { - public Models.Commit Commit - { - get; - } - - public bool DiscardLocalChanges - { - get; - set; - } - - public bool IsRecurseSubmoduleVisible - { - get => _repo.Submodules.Count > 0; - } - - public bool RecurseSubmodules - { - get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; - set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; - } - - public CheckoutCommit(Repository repo, Models.Commit commit) - { - _repo = repo; - Commit = commit; - DiscardLocalChanges = false; - } - - public override async Task Sure() - { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Checkout Commit '{Commit.SHA}' ..."; - - 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, null); - if (!shouldContinue) - { - _repo.SetWatcherEnabled(true); - return true; - } - } - } - - var succ = false; - var needPop = false; - - if (!DiscardLocalChanges) - { - var changes = await new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).GetResultAsync(); - if (changes > 0) - { - succ = await new Commands.Stash(_repo.FullPath) - .Use(log) - .PushAsync("CHECKOUT_AUTO_STASH"); - if (!succ) - { - log.Complete(); - _repo.SetWatcherEnabled(true); - return false; - } - - needPop = true; - } - } - - succ = await new Commands.Checkout(_repo.FullPath) - .Use(log) - .CommitAsync(Commit.SHA, DiscardLocalChanges); - - if (succ) - { - if (IsRecurseSubmoduleVisible && RecurseSubmodules) - { - var submodules = await new Commands.QueryUpdatableSubmodules(_repo.FullPath).GetResultAsync(); - if (submodules.Count > 0) - await new Commands.Submodule(_repo.FullPath) - .Use(log) - .UpdateAsync(submodules, true, true); - } - - if (needPop) - await new Commands.Stash(_repo.FullPath) - .Use(log) - .PopAsync("stash@{0}"); - } - - log.Complete(); - _repo.SetWatcherEnabled(true); - return succ; - } - - private readonly Repository _repo = null; - } -} 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 ca525e0ac..55ec83c99 100644 --- a/src/ViewModels/CherryPick.cs +++ b/src/ViewModels/CherryPick.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.IO; +using System.Text; using System.Threading.Tasks; namespace SourceGit.ViewModels @@ -65,7 +67,7 @@ public CherryPick(Repository repo, Models.Commit merge, List pare public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); _repo.ClearCommitMessage(); ProgressDescription = "Cherry-Pick commit(s) ..."; @@ -93,10 +95,22 @@ public override async Task Sure() string.Empty) .Use(log) .ExecAsync(); + + if (!AutoCommit && Targets.Count > 1) + { + var builder = new StringBuilder(); + foreach (var t in Targets) + builder.Append(t.Subject).Append("\n"); + + builder.Append("\n"); + foreach (var t in Targets) + builder.Append("(cherry picked from commit ").Append(t.SHA).Append(")\n"); + + File.WriteAllText(Path.Combine(_repo.GitDir, "MERGE_MSG"), builder.ToString()); + } } log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/Cleanup.cs b/src/ViewModels/Cleanup.cs index c8788d681..974f11cf3 100644 --- a/src/ViewModels/Cleanup.cs +++ b/src/ViewModels/Cleanup.cs @@ -11,7 +11,7 @@ public Cleanup(Repository repo) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Cleanup (GC & prune) ..."; var log = _repo.CreateLog("Cleanup (GC & prune)"); @@ -22,7 +22,6 @@ public override async Task Sure() .ExecAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/ClearStashes.cs b/src/ViewModels/ClearStashes.cs index 6178d5efd..0c5092d12 100644 --- a/src/ViewModels/ClearStashes.cs +++ b/src/ViewModels/ClearStashes.cs @@ -11,7 +11,7 @@ public ClearStashes(Repository repo) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Clear all stashes..."; var log = _repo.CreateLog("Clear Stashes"); @@ -22,7 +22,7 @@ public override async Task Sure() .ClearAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); + _repo.MarkStashesDirtyManually(); return true; } diff --git a/src/ViewModels/Clone.cs b/src/ViewModels/Clone.cs index b3aadb581..e62937cbc 100644 --- a/src/ViewModels/Clone.cs +++ b/src/ViewModels/Clone.cs @@ -1,8 +1,8 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; -using Avalonia.Threading; namespace SourceGit.ViewModels { @@ -46,6 +46,23 @@ 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; @@ -62,24 +79,15 @@ 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; - - Task.Run(async () => - { - try - { - var text = await App.GetClipboardTextAsync(); - if (Models.Remote.IsValidURL(text)) - Dispatcher.UIThread.Post(() => Remote = text); - } - catch - { - // Ignore - } - }); } public static ValidationResult ValidateRemote(string remote, ValidationContext _) @@ -106,7 +114,7 @@ public override async Task Sure() var succ = await new Commands.Clone(_pageId, _parentFolder, _remote, _local, _useSSH ? _sshKey : "", _extraArgs) .Use(log) .ExecAsync(); - if (succ) + if (!succ) return false; var path = _parentFolder; @@ -127,7 +135,7 @@ public override async Task Sure() if (!Directory.Exists(path)) { - App.RaiseException(_pageId, $"Folder '{path}' can NOT be found"); + Models.Notification.Send(_pageId, $"Folder '{path}' can NOT be found", true); return false; } @@ -140,16 +148,20 @@ public override async Task Sure() if (InitAndUpdateSubmodules) { - var submodules = await new Commands.QueryUpdatableSubmodules(path).GetResultAsync(); + var submodules = await new Commands.QueryUpdatableSubmodules(path, true).GetResultAsync(); if (submodules.Count > 0) await new Commands.Submodule(path) .Use(log) - .UpdateAsync(submodules, true, true); + .UpdateAsync(submodules, true, true, false); } log.Complete(); - var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(path, null, true); + 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) @@ -166,6 +178,18 @@ public override async Task Sure() 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 _remote = string.Empty; private bool _useSSH = false; @@ -173,5 +197,7 @@ public override async Task Sure() 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 index 96faaef7a..191f0f6c5 100644 --- a/src/ViewModels/CommandLog.cs +++ b/src/ViewModels/CommandLog.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -43,10 +44,14 @@ public CommandLog(string name) Name = name; } - public void Register(Action handler) + public void Subscribe(Models.ICommandLogReceiver receiver) { - if (!IsComplete) - _onNewLineReceived += handler; + _receivers.Add(receiver); + } + + public void Unsubscribe(Models.ICommandLogReceiver receiver) + { + _receivers.Remove(receiver); } public void AppendLine(string line = null) @@ -58,8 +63,13 @@ public void AppendLine(string line = null) else { var newline = line ?? string.Empty; - _builder.AppendLine(newline); - _onNewLineReceived?.Invoke(newline); + if (_builder != null) + _builder.AppendLine(newline); + else + _content = $"{_content}{newline}\n"; + + foreach (var receiver in _receivers) + receiver.OnReceiveCommandLog(newline); } } @@ -76,20 +86,14 @@ public void Complete() _content = _builder.ToString(); _builder.Clear(); + _receivers.Clear(); _builder = null; OnPropertyChanged(nameof(IsComplete)); - - if (_onNewLineReceived != null) - { - var dumpHandlers = _onNewLineReceived.GetInvocationList(); - foreach (var d in dumpHandlers) - _onNewLineReceived -= (Action)d; - } } private string _content = string.Empty; private StringBuilder _builder = new StringBuilder(); - private event Action _onNewLineReceived; + private List _receivers = new List(); } } diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 81bc6a214..b23465774 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -1,31 +1,50 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Platform.Storage; +using Avalonia; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public partial class CommitDetail : ObservableObject, IDisposable + public class CommitDetailSharedData + { + public int ActiveTabIndex + { + get; + set; + } + + public CommitDetailSharedData() + { + ActiveTabIndex = Preferences.Instance.ShowChangesInCommitDetailByDefault ? 1 : 0; + } + } + + public partial class CommitDetail : ObservableObject { - public int ActivePageIndex + public Repository Repository { - get => _rememberActivePageIndex ? _repo.CommitDetailActivePageIndex : _activePageIndex; + get => _repo; + } + + public int ActiveTabIndex + { + get => _sharedData.ActiveTabIndex; set { - if (_rememberActivePageIndex) - _repo.CommitDetailActivePageIndex = value; - else - _activePageIndex = value; + if (value != _sharedData.ActiveTabIndex) + { + _sharedData.ActiveTabIndex = value; + OnPropertyChanged(nameof(ActiveTabIndex)); - OnPropertyChanged(); + if (value == 1 && DiffContext == null && _selectedChanges is { Count: 1 }) + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, _selectedChanges[0])); + } } } @@ -34,6 +53,9 @@ 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(); } @@ -57,12 +79,6 @@ public List WebLinks private set; } - public List Children - { - get => _children; - private set => SetProperty(ref _children, value); - } - public List Changes { get => _changes; @@ -82,7 +98,7 @@ public List SelectedChanges { if (SetProperty(ref _selectedChanges, value)) { - if (value is not { 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); @@ -140,28 +156,25 @@ public bool CanOpenRevisionFileWithDefaultEditor private set => SetProperty(ref _canOpenRevisionFileWithDefaultEditor, value); } - public CommitDetail(Repository repo, bool rememberActivePageIndex) + public Vector ScrollOffset + { + get => _scrollOffset; + set => SetProperty(ref _scrollOffset, value); + } + + public CommitDetail(Repository repo, CommitDetailSharedData sharedData) { _repo = repo; - _rememberActivePageIndex = rememberActivePageIndex; + _sharedData = sharedData ?? new CommitDetailSharedData(); WebLinks = Models.CommitLink.Get(repo.Remotes); } - public void Dispose() + public CommitDetail Clone() { - _repo = null; - _commit = null; - _changes = null; - _visibleChanges = null; - _selectedChanges = null; - _signInfo = null; - _searchChangeFilter = null; - _diffContext = null; - _viewRevisionFileContent = null; - _cancellationSource = null; - _requestingRevisionFiles = false; - _revisionFiles = null; - _revisionFileSearchSuggestion = null; + var cloned = new CommitDetail(_repo, null); + cloned.ActiveTabIndex = ActiveTabIndex; + cloned.Commit = _commit; + return cloned; } public void NavigateTo(string commitSHA) @@ -198,477 +211,253 @@ public void CancelRevisionFileSuggestions() .ConfigureAwait(false); } - public async Task> GetRevisionFilesUnderFolderAsync(string parentFolder) + public string GetAbsPath(string path) { - return await new Commands.QueryRevisionObjects(_repo.FullPath, _commit.SHA, parentFolder) - .GetResultAsync() - .ConfigureAwait(false); + return Native.OS.GetAbsPath(_repo.FullPath, path); } - public async Task ViewRevisionFileAsync(Models.Object file) + public void OpenChangeInMergeTool(Models.Change c) { - var obj = file ?? new Models.Object() { Path = string.Empty, Type = Models.ObjectType.None }; - ViewRevisionFilePath = obj.Path; - - switch (obj.Type) - { - case Models.ObjectType.Blob: - CanOpenRevisionFileWithDefaultEditor = true; - await SetViewingBlobAsync(obj); - break; - case Models.ObjectType.Commit: - CanOpenRevisionFileWithDefaultEditor = false; - await SetViewingCommitAsync(obj); - break; - default: - CanOpenRevisionFileWithDefaultEditor = false; - ViewRevisionFileContent = null; - break; - } + new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(_commit, c)).Open(); } - public async Task OpenRevisionFileWithDefaultEditorAsync(string file) + public async Task SaveChangesAsPatchAsync(List changes, string saveTo) { - 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}"); + if (_commit == null) + return; - await Commands.SaveRevisionFile - .RunAsync(_repo.FullPath, _commit.SHA, file, tmpFile) - .ConfigureAwait(false); + var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync( + _repo.FullPath, + changes, + _commit.FirstParentToCompare, + _commit.SHA, + saveTo); - Native.OS.OpenWithDefaultEditor(tmpFile); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); } - public string GetAbsPath(string path) + public async Task ResetToThisRevisionAsync(string path) { - return Native.OS.GetAbsPath(_repo.FullPath, path); - } + var c = _changes?.Find(x => x.Path.Equals(path, StringComparison.Ordinal)); + if (c != null) + { + await ResetToThisRevisionAsync(c); + return; + } - public void OpenChangeInMergeTool(Models.Change c) - { - var toolType = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption(_commit, c); - new Commands.DiffTool(_repo.FullPath, toolType, toolPath, opt).Open(); + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); + await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(path, _commit.SHA); + log.Complete(); } - public async Task SaveRevisionFile(Models.Object file) + public async Task ResetToThisRevisionAsync(Models.Change change) { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); - var options = new FolderPickerOpenOptions() { AllowMultiple = false }; - try + if (change.Index == Models.ChangeState.Deleted) { - var selected = await storageProvider.OpenFolderPickerAsync(options); - if (selected.Count == 1) - { - var folder = selected[0]; - var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString(); - var saveTo = Path.Combine(folderPath, Path.GetFileName(file.Path)!); + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + await new Commands.Remove(_repo.FullPath, [change.Path]) + .Use(log) + .ExecAsync(); + } + else if (change.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, change.OriginalPath); + if (File.Exists(old)) + await new Commands.Remove(_repo.FullPath, [change.OriginalPath]) + .Use(log) + .ExecAsync(); - await Commands.SaveRevisionFile - .RunAsync(_repo.FullPath, _commit.SHA, file.Path, saveTo) - .ConfigureAwait(false); - } + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, _commit.SHA); } - catch (Exception e) + else { - App.RaiseException(_repo.FullPath, $"Failed to save file: {e.Message}"); + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, _commit.SHA); } + + log.Complete(); } - public ContextMenu CreateChangeContextMenuByFolder(ChangeTreeNode node, List changes) + public async Task ResetToParentRevisionAsync(Models.Change change) { - var fullPath = Native.OS.GetAbsPath(_repo.FullPath, node.FullPath); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = Directory.Exists(fullPath); - explore.Click += (_, ev) => - { - Native.OS.OpenInFileManager(fullPath, true); - ev.Handled = true; - }; - - var history = new MenuItem(); - history.Header = App.Text("DirHistories"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => - { - App.ShowWindow(new DirHistories(_repo, node.FullPath, _commit.SHA)); - ev.Handled = true; - }; + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}~1'"); - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => + if (change.Index == Models.ChangeState.Added) { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var baseRevision = _commit.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : _commit.Parents[0]; - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var saveTo = storageFile.Path.LocalPath; - var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo.FullPath, changes, baseRevision, _commit.SHA, saveTo); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.Handled = true; - }; - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, ev) => + 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) { - await App.CopyTextAsync(node.FullPath); - ev.Handled = true; - }; + 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 copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.OriginalPath, $"{_commit.SHA}~1"); + } + else { - await App.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); + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, $"{_commit.SHA}~1"); + } - return menu; + log.Complete(); } - public ContextMenu CreateChangeContextMenu(Models.Change change) + public async Task ResetMultipleToThisRevisionAsync(List changes) { - var openWithMerger = new MenuItem(); - openWithMerger.Header = App.Text("OpenInExternalMergeTool"); - openWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; - openWithMerger.Click += (_, ev) => - { - OpenChangeInMergeTool(change); - ev.Handled = true; - }; + var checkouts = new List(); + var removes = new List(); - var fullPath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(fullPath); - explore.Click += (_, ev) => + foreach (var c in changes) { - Native.OS.OpenInFileManager(fullPath, true); - ev.Handled = true; - }; - - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => - { - App.ShowWindow(new FileHistories(_repo, change.Path, _commit.SHA)); - ev.Handled = true; - }; + if (c.Index == Models.ChangeState.Deleted) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, c.OriginalPath); + if (File.Exists(old)) + removes.Add(c.OriginalPath); - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.IsEnabled = change.Index != Models.ChangeState.Deleted; - blame.Click += (_, ev) => - { - App.ShowWindow(new Blame(_repo.FullPath, change.Path, _commit)); - ev.Handled = true; - }; + checkouts.Add(c.Path); + } + else + { + checkouts.Add(c.Path); + } + } - 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 log = _repo.CreateLog($"Reset Files to '{_commit.SHA}'"); - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); - var baseRevision = _commit.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : _commit.Parents[0]; - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var saveTo = storageFile.Path.LocalPath; - var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo.FullPath, [change], baseRevision, _commit.SHA, saveTo); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, _commit.SHA); - e.Handled = true; - }; + log.Complete(); + } - var menu = new ContextMenu(); - 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 = "-" }); + public async Task ResetMultipleToParentRevisionAsync(List changes) + { + var checkouts = new List(); + var removes = new List(); - if (!_repo.IsBare) + foreach (var c in changes) { - var resetToThisRevision = new MenuItem(); - resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); - resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToThisRevision.Click += async (_, ev) => + if (c.Index == Models.ChangeState.Added) { - await ResetToThisRevisionAsync(change.Path); - ev.Handled = true; - }; - - var resetToFirstParent = new MenuItem(); - resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); - resetToFirstParent.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToFirstParent.IsEnabled = _commit.Parents.Count > 0; - resetToFirstParent.Click += async (_, ev) => + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) { - await ResetToParentRevisionAsync(change); - ev.Handled = true; - }; + var renamed = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(renamed)) + removes.Add(c.Path); - menu.Items.Add(resetToThisRevision); - menu.Items.Add(resetToFirstParent); - menu.Items.Add(new MenuItem { Header = "-" }); - - TryToAddContextMenuItemsForGitLFS(menu, fullPath, change.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.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, ev) => - { - await App.CopyTextAsync(change.Path); - ev.Handled = true; - }; + var log = _repo.CreateLog($"Reset Files to '{_commit.SHA}~1'"); - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await App.CopyTextAsync(fullPath); - e.Handled = true; - }; + 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, $"{_commit.SHA}~1"); - menu.Items.Add(copyPath); - menu.Items.Add(copyFullPath); - return menu; + log.Complete(); } - public ContextMenu CreateRevisionFileContextMenuByFolder(string path) + public async Task> GetRevisionFilesUnderFolderAsync(string parentFolder) { - var fullPath = Native.OS.GetAbsPath(_repo.FullPath, path); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = Directory.Exists(fullPath); - explore.Click += (_, ev) => - { - Native.OS.OpenInFileManager(fullPath, true); - ev.Handled = true; - }; - - var history = new MenuItem(); - history.Header = App.Text("DirHistories"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => - { - App.ShowWindow(new DirHistories(_repo, path, _commit.SHA)); - ev.Handled = true; - }; - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, ev) => - { - await App.CopyTextAsync(path); - ev.Handled = true; - }; - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await App.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(new MenuItem() { Header = "-" }); - menu.Items.Add(copyPath); - menu.Items.Add(copyFullPath); - return menu; + return await new Commands.QueryRevisionObjects(_repo.FullPath, _commit.SHA, parentFolder) + .GetResultAsync() + .ConfigureAwait(false); } - public ContextMenu CreateRevisionFileContextMenu(Models.Object file) + public async Task ViewRevisionFileAsync(Models.Object file) { - if (file.Type == Models.ObjectType.Tree) - return CreateRevisionFileContextMenuByFolder(file.Path); - - var menu = new ContextMenu(); - var fullPath = Native.OS.GetAbsPath(_repo.FullPath, file.Path); - var openWith = new MenuItem(); - openWith.Header = App.Text("OpenWith"); - openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWith.Tag = OperatingSystem.IsMacOS() ? "⌘+O" : "Ctrl+O"; - openWith.IsEnabled = file.Type == Models.ObjectType.Blob; - openWith.Click += async (_, ev) => - { - await OpenRevisionFileWithDefaultEditorAsync(file.Path); - 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.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+S" : "Ctrl+Shift+S"; - saveAs.Click += async (_, ev) => - { - await SaveRevisionFile(file); - ev.Handled = true; - }; - - 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; - }; - - menu.Items.Add(openWith); - menu.Items.Add(saveAs); - menu.Items.Add(explore); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => - { - App.ShowWindow(new FileHistories(_repo, file.Path, _commit.SHA)); - ev.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.IsEnabled = file.Type == Models.ObjectType.Blob; - blame.Click += (_, ev) => - { - App.ShowWindow(new Blame(_repo.FullPath, file.Path, _commit)); - ev.Handled = true; - }; - - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(new MenuItem() { Header = "-" }); + var obj = file ?? new Models.Object() { Path = string.Empty, Type = Models.ObjectType.None }; + ViewRevisionFilePath = obj.Path; - if (!_repo.IsBare) + switch (obj.Type) { - var resetToThisRevision = new MenuItem(); - resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); - resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToThisRevision.Click += async (_, ev) => - { - await ResetToThisRevisionAsync(file.Path); - ev.Handled = true; - }; - - var change = _changes.Find(x => x.Path == file.Path) ?? new Models.Change() { Index = Models.ChangeState.None, Path = file.Path }; - var resetToFirstParent = new MenuItem(); - resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); - resetToFirstParent.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToFirstParent.IsEnabled = _commit.Parents.Count > 0; - resetToFirstParent.Click += async (_, ev) => - { - await ResetToParentRevisionAsync(change); - ev.Handled = true; - }; - - menu.Items.Add(resetToThisRevision); - menu.Items.Add(resetToFirstParent); - menu.Items.Add(new MenuItem() { Header = "-" }); - - TryToAddContextMenuItemsForGitLFS(menu, fullPath, file.Path); + 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; } + } - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, ev) => - { - await App.CopyTextAsync(file.Path); - ev.Handled = true; - }; + 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}"); - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await App.CopyTextAsync(fullPath); - e.Handled = true; - }; + await Commands.SaveRevisionFile + .RunAsync(_repo.FullPath, _commit.SHA, file, tmpFile) + .ConfigureAwait(false); - menu.Items.Add(copyPath); - menu.Items.Add(copyFullPath); - return menu; + 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; _requestingRevisionFiles = false; _revisionFiles = null; @@ -676,12 +465,17 @@ private void Refresh() ViewRevisionFileContent = null; ViewRevisionFilePath = string.Empty; CanOpenRevisionFileWithDefaultEditor = false; - Children = null; RevisionFileSearchFilter = string.Empty; RevisionFileSearchSuggestion = null; + ScrollOffset = Vector.Zero; if (_commit == null) + { + Changes = []; + VisibleChanges = []; + SelectedChanges = null; return; + } if (_cancellationSource is { IsCancellationRequested: false }) _cancellationSource.Cancel(); @@ -694,7 +488,7 @@ private void Refresh() var message = await new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA) .GetResultAsync() .ConfigureAwait(false); - var inlines = await ParseInlinesInMessageAsync(message); + var inlines = await ParseInlinesInMessageAsync(message).ConfigureAwait(false); if (!token.IsCancellationRequested) Dispatcher.UIThread.Post(() => @@ -717,23 +511,13 @@ private void Refresh() Dispatcher.UIThread.Post(() => SignInfo = signInfo); }, token); - if (Preferences.Instance.ShowChildren) - { - Task.Run(async () => - { - var max = Preferences.Instance.MaxHistoryCommits; - var cmd = new Commands.QueryCommitChildren(_repo.FullPath, _commit.SHA, max) { CancellationToken = token }; - var children = await cmd.GetResultAsync().ConfigureAwait(false); - if (!token.IsCancellationRequested) - Dispatcher.UIThread.Post(() => Children = children); - }, token); - } - Task.Run(async () => { - var parent = _commit.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : _commit.Parents[0]; - var cmd = new Commands.CompareRevisions(_repo.FullPath, parent, _commit.SHA) { CancellationToken = token }; - var changes = await cmd.ReadAsync().ConfigureAwait(false); + var changes = await new Commands.CompareRevisions(_repo.FullPath, _commit.FirstParentToCompare, _commit.SHA) + .WithCancellation(token) + .ReadAsync() + .ConfigureAwait(false); + var visible = changes; if (!string.IsNullOrWhiteSpace(_searchChangeFilter)) { @@ -764,7 +548,7 @@ private void Refresh() private async Task ParseInlinesInMessageAsync(string message) { var inlines = new Models.InlineElementCollector(); - if (_repo.Settings.IssueTrackerRules is { Count: > 0 } rules) + if (_repo.IssueTrackers is { Count: > 0 } rules) { foreach (var rule in rules) rule.Matches(inlines, message); @@ -797,15 +581,23 @@ private void Refresh() 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 (_changes == null) - return; - if (string.IsNullOrEmpty(_searchChangeFilter)) { VisibleChanges = _changes; @@ -823,75 +615,6 @@ private void RefreshVisibleChanges() } } - private void TryToAddContextMenuItemsForGitLFS(ContextMenu menu, string fullPath, string path) - { - if (_repo.Remotes.Count == 0 || !File.Exists(fullPath) || !_repo.IsLFSEnabled()) - return; - - var lfs = new MenuItem(); - lfs.Header = App.Text("GitLFS"); - lfs.Icon = App.CreateMenuIcon("Icons.LFS"); - - var lfsLock = new MenuItem(); - lfsLock.Header = App.Text("GitLFS.Locks.Lock"); - lfsLock.Icon = App.CreateMenuIcon("Icons.Lock"); - if (_repo.Remotes.Count == 1) - { - lfsLock.Click += async (_, e) => - { - await _repo.LockLFSFileAsync(_repo.Remotes[0].Name, 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, 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 = App.CreateMenuIcon("Icons.Unlock"); - if (_repo.Remotes.Count == 1) - { - lfsUnlock.Click += async (_, e) => - { - await _repo.UnlockLFSFileAsync(_repo.Remotes[0].Name, 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, path, false, true); - e.Handled = true; - }; - lfsUnlock.Items.Add(unlockRemote); - } - } - lfs.Items.Add(lfsUnlock); - - menu.Items.Add(lfs); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - private void RefreshRevisionSearchSuggestion() { if (!string.IsNullOrEmpty(_revisionFileSearchFilter)) @@ -990,61 +713,31 @@ private async Task SetViewingBlobAsync(Models.Object file) private async Task SetViewingCommitAsync(Models.Object file) { - var submoduleRoot = Path.Combine(_repo.FullPath, file.Path).Replace('\\', '/').Trim('/'); - var commit = await new Commands.QuerySingleCommit(submoduleRoot, file.SHA).GetResultAsync(); - if (commit == null) - { - ViewRevisionFileContent = new Models.RevisionSubmodule() - { - Commit = new Models.Commit() { SHA = file.SHA }, - FullMessage = new Models.CommitFullMessage() - }; - } - else + 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() { - var message = await new Commands.QueryCommitFullMessage(submoduleRoot, file.SHA).GetResultAsync(); - ViewRevisionFileContent = new Models.RevisionSubmodule() - { - Commit = commit, - FullMessage = new Models.CommitFullMessage { Message = message } - }; - } - } - - private async Task ResetToThisRevisionAsync(string path) - { - var log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); - - await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(path, $"{_commit.SHA}"); - log.Complete(); - } - - private async Task ResetToParentRevisionAsync(Models.Change change) - { - var log = _repo.CreateLog($"Reset File to '{_commit.SHA}~1'"); - - if (change.Index == Models.ChangeState.Renamed) - await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(change.OriginalPath, $"{_commit.SHA}~1"); - - await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(change.Path, $"{_commit.SHA}~1"); - log.Complete(); + Commit = new Models.Commit() { SHA = file.SHA }, + FullMessage = new Models.CommitFullMessage() + }; } [GeneratedRegex(@"\b(https?://|ftp://)[\w\d\._/\-~%@()+:?&=#!]*[\w\d/]")] private static partial Regex REG_URL_FORMAT(); - [GeneratedRegex(@"\b([0-9a-fA-F]{6,40})\b")] + [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 bool _rememberActivePageIndex = true; - private int _activePageIndex = 0; + private CommitDetailSharedData _sharedData = null; private Models.Commit _commit = null; private Models.CommitFullMessage _fullMessage = null; private Models.CommitSignInfo _signInfo = null; - private List _children = null; - private List _changes = null; - private List _visibleChanges = null; + private List _changes = []; + private List _visibleChanges = []; private List _selectedChanges = null; private string _searchChangeFilter = string.Empty; private DiffContext _diffContext = null; @@ -1056,5 +749,6 @@ private async Task ResetToParentRevisionAsync(Models.Change change) 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/ConfirmEmptyCommit.cs b/src/ViewModels/ConfirmEmptyCommit.cs deleted file mode 100644 index 87178b75e..000000000 --- a/src/ViewModels/ConfirmEmptyCommit.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; - -namespace SourceGit.ViewModels -{ - public class ConfirmEmptyCommit - { - public bool HasLocalChanges - { - get; - private set; - } - - public string Message - { - get; - private set; - } - - public ConfirmEmptyCommit(bool hasLocalChanges, Action onSure) - { - HasLocalChanges = hasLocalChanges; - Message = App.Text(hasLocalChanges ? "ConfirmEmptyCommit.WithLocalChanges" : "ConfirmEmptyCommit.NoLocalChanges"); - _onSure = onSure; - } - - public void StageAllThenCommit() - { - _onSure?.Invoke(true); - } - - public void Continue() - { - _onSure?.Invoke(false); - } - - private Action _onSure; - } -} diff --git a/src/ViewModels/Conflict.cs b/src/ViewModels/Conflict.cs index 7a14c7065..d98187d35 100644 --- a/src/ViewModels/Conflict.cs +++ b/src/ViewModels/Conflict.cs @@ -1,29 +1,11 @@ -using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public class ConflictSourceBranch - { - public string Name { get; private set; } - public string Head { get; private set; } - public Models.Commit Revision { get; private set; } - - public ConflictSourceBranch(string name, string head, Models.Commit revision) - { - Name = name; - Head = head; - Revision = revision; - } - - public ConflictSourceBranch(Repository repo, Models.Branch branch) - { - Name = branch.Name; - Head = branch.Head; - Revision = new Commands.QuerySingleCommit(repo.FullPath, branch.Head).GetResultAsync().Result ?? new Models.Commit() { SHA = branch.Head }; - } - } - - public class Conflict + public class Conflict : ObservableObject { public string Marker { @@ -35,88 +17,85 @@ public string Description get => _change.ConflictDesc; } - public object Theirs + public Models.ConflictFileState State { - get; - private set; + get => _state; + private set => SetProperty(ref _state, value); } - public object Mine + public object Theirs { - get; - private set; + get => _theirs; + private set => SetProperty(ref _theirs, value); } - public bool IsResolved - { - get; - private set; - } = false; - - public bool CanUseExternalMergeTool + public object Mine { - get; - private set; - } = false; + get => _mine; + private set => SetProperty(ref _mine, value); + } public Conflict(Repository repo, WorkingCopy wc, Models.Change change) { + _repo = repo; _wc = wc; _change = change; - var isSubmodule = repo.Submodules.Find(x => x.Path.Equals(change.Path, StringComparison.Ordinal)) != null; - if (!isSubmodule && (_change.ConflictReason is Models.ConflictReason.BothAdded or Models.ConflictReason.BothModified)) - { - CanUseExternalMergeTool = true; - IsResolved = new Commands.IsConflictResolved(repo.FullPath, change).GetResultAsync().Result; - } - - switch (wc.InProgressContext) + Task.Run(async () => { - case CherryPickInProgress cherryPick: - Theirs = cherryPick.Head; - Mine = new ConflictSourceBranch(repo, repo.CurrentBranch); - break; - case RebaseInProgress rebase: - var b = repo.Branches.Find(x => x.IsLocal && x.Name == rebase.HeadName); - if (b != null) - Theirs = new ConflictSourceBranch(b.Name, b.Head, rebase.StoppedAt); - else - Theirs = new ConflictSourceBranch(rebase.HeadName, rebase.StoppedAt?.SHA ?? "----------", rebase.StoppedAt); + _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; + }); + }); + } - Mine = rebase.Onto; - break; - case RevertInProgress revert: - Theirs = revert.Head; - Mine = new ConflictSourceBranch(repo, repo.CurrentBranch); - break; - case MergeInProgress merge: - Theirs = merge.Source; - Mine = new ConflictSourceBranch(repo, repo.CurrentBranch); - break; - default: - Theirs = "Stash or Patch"; - Mine = new ConflictSourceBranch(repo, repo.CurrentBranch); - break; - } + public async Task UseTheirsAsync() + { + await _wc.UseTheirsAsync([_change]); } - public void UseTheirs() + public async Task UseMineAsync() { - _wc.UseTheirs([_change]); + await _wc.UseMineAsync([_change]); } - public void UseMine() + public MergeConflictEditor CreateOpenMergeEditorRequest() { - _wc.UseMine([_change]); + return _state == Models.ConflictFileState.UnmergedText ? new MergeConflictEditor(_repo, _head, _change.Path) : null; } - public async void OpenExternalMergeTool() + public async Task MergeExternalAsync() { - await _wc.UseExternalMergeTool(_change); + 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 d002a77d9..99c57d819 100644 --- a/src/ViewModels/ConventionalCommitMessageBuilder.cs +++ b/src/ViewModels/ConventionalCommitMessageBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Text; @@ -9,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 @@ -47,8 +58,10 @@ public string ClosedIssue set => SetProperty(ref _closedIssue, value); } - public ConventionalCommitMessageBuilder(Action onApply) + public ConventionalCommitMessageBuilder(string conventionalTypesOverride, Action onApply) { + Types = Models.ConventionalCommitType.Load(conventionalTypesOverride); + SelectedType = Types.Count > 0 ? Types[0] : null; _onApply = onApply; } @@ -63,7 +76,7 @@ public bool Apply() return false; var builder = new StringBuilder(); - builder.Append(_type.Type); + builder.Append(_selectedType.Type); if (!string.IsNullOrEmpty(_scope)) { @@ -103,7 +116,7 @@ public bool Apply() } private Action _onApply = null; - private Models.ConventionalCommitType _type = Models.ConventionalCommitType.Supported[0]; + 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 c8d591d5b..f180c94e9 100644 --- a/src/ViewModels/CreateBranch.cs +++ b/src/ViewModels/CreateBranch.cs @@ -7,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 { @@ -20,7 +20,12 @@ public object BasedOn get; } - public bool DiscardLocalChanges + public bool HasLocalChanges + { + get => _repo.LocalChangesCount > 0; + } + + public Models.DealWithLocalChanges DealWithLocalChanges { get; set; @@ -28,13 +33,14 @@ public bool DiscardLocalChanges public bool CheckoutAfterCreated { - get => _repo.Settings.CheckoutBranchOnCreateBranch; + get => _repo.UIStates.CheckoutBranchOnCreateBranch; set { - if (_repo.Settings.CheckoutBranchOnCreateBranch != value) + if (_repo.UIStates.CheckoutBranchOnCreateBranch != value) { - _repo.Settings.CheckoutBranchOnCreateBranch = value; + _repo.UIStates.CheckoutBranchOnCreateBranch = value; OnPropertyChanged(); + UpdateOverrideTip(); } } } @@ -44,6 +50,12 @@ public bool IsBareRepository get => _repo.IsBare; } + public string OverrideTip + { + get => _overrideTip; + private set => SetProperty(ref _overrideTip, value); + } + public bool AllowOverwrite { get => _allowOverwrite; @@ -54,45 +66,49 @@ public bool AllowOverwrite } } - public bool IsRecurseSubmoduleVisible - { - get => _repo.Submodules.Count > 0; - } - - public bool RecurseSubmodules - { - get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; - set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; - } - public CreateBranch(Repository repo, Models.Branch branch) { _repo = repo; _baseOnRevision = branch.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; - DiscardLocalChanges = false; + 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; - DiscardLocalChanges = false; + 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; - DiscardLocalChanges = false; + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + UpdateOverrideTip(); } public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) @@ -101,10 +117,9 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext { if (!creator._allowOverwrite) { - var fixedName = Models.Branch.FixName(name); foreach (var b in creator._repo.Branches) { - if (b.FriendlyName.Equals(fixedName, StringComparison.Ordinal)) + if (b.FriendlyName.Equals(name, StringComparison.Ordinal)) return new ValidationResult("A branch with same name already exists!"); } } @@ -117,10 +132,9 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); - var fixedName = Models.Branch.FixName(_name); - var log = _repo.CreateLog($"Create Branch '{fixedName}'"); + var log = _repo.CreateLog($"Create Branch '{_name}'"); Use(log); if (CheckoutAfterCreated) @@ -131,53 +145,64 @@ public override async Task Sure() if (refs.Count == 0) { var msg = App.Text("Checkout.WarnLostCommits"); - var shouldContinue = await App.AskConfirmAsync(msg, null); + var shouldContinue = await App.AskConfirmAsync(msg); if (!shouldContinue) - { - _repo.SetWatcherEnabled(true); 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 (!DiscardLocalChanges) + 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.CountLocalChangesWithoutUntracked(_repo.FullPath).GetResultAsync(); + var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync(); if (changes > 0) { succ = await new Commands.Stash(_repo.FullPath) .Use(log) - .PushAsync("CREATE_BRANCH_AUTO_STASH"); + .PushAsync("CREATE_BRANCH_AUTO_STASH", false); if (!succ) { log.Complete(); - _repo.SetWatcherEnabled(true); + _repo.MarkWorkingCopyDirtyManually(); return false; } needPopStash = true; } - } - succ = await new Commands.Checkout(_repo.FullPath) - .Use(log) - .BranchAsync(fixedName, _baseOnRevision, DiscardLocalChanges, _allowOverwrite); + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(_name, _baseOnRevision, false, _allowOverwrite); + } + else + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(_name, _baseOnRevision, true, _allowOverwrite); + } if (succ) { - if (IsRecurseSubmoduleVisible && RecurseSubmodules) - { - var submodules = await new Commands.QueryUpdatableSubmodules(_repo.FullPath).GetResultAsync(); - if (submodules.Count > 0) - await new Commands.Submodule(_repo.FullPath) - .Use(log) - .UpdateAsync(submodules, true, true); - } + await _repo.AutoUpdateSubmodulesAsync(log); if (needPopStash) await new Commands.Stash(_repo.FullPath) @@ -187,43 +212,44 @@ public override async Task Sure() } else { - succ = await new Commands.Branch(_repo.FullPath, fixedName) + succ = await new Commands.Branch(_repo.FullPath, _name) .Use(log) .CreateAsync(_baseOnRevision, _allowOverwrite); } - log.Complete(); - - if (succ && CheckoutAfterCreated) + if (succ) { - var fake = new Models.Branch() { IsLocal = true, FullName = $"refs/heads/{fixedName}" }; - if (BasedOn is Models.Branch { IsLocal: false } based) - fake.Upstream = based.FullName; - - var folderEndIdx = fake.FullName.LastIndexOf('/'); - if (folderEndIdx > 10) - _repo.Settings.ExpandedBranchNodesInSideBar.Add(fake.FullName.Substring(0, folderEndIdx)); + 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); - if (_repo.HistoriesFilterMode == Models.FilterMode.Included) - _repo.SetBranchFilterMode(fake, Models.FilterMode.Included, true, false); + created.Upstream = basedOn.FullName; + } + _repo.RefreshAfterCreateBranch(created, CheckoutAfterCreated); } - - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - - if (CheckoutAfterCreated) + else { - ProgressDescription = "Waiting for branch updated..."; - await Task.Delay(400); + _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/CreateTag.cs b/src/ViewModels/CreateTag.cs index d9113c132..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 @@ -41,8 +48,8 @@ public bool SignTag public bool PushToRemotes { - get => _repo.Settings.PushToRemoteWhenCreateTag; - set => _repo.Settings.PushToRemoteWhenCreateTag = value; + get => _repo.UIStates.PushToRemoteWhenCreateTag; + set => _repo.UIStates.PushToRemoteWhenCreateTag = value; } public CreateTag(Repository repo, Models.Branch branch) @@ -51,7 +58,7 @@ public CreateTag(Repository repo, Models.Branch branch) _basedOn = branch.Head; BasedOn = branch; - SignTag = new Commands.Config(repo.FullPath).GetAsync("tag.gpgsign").Result.Equals("true", StringComparison.OrdinalIgnoreCase); + SignTag = new Commands.Config(repo.FullPath).Get("tag.gpgsign").Equals("true", StringComparison.OrdinalIgnoreCase); } public CreateTag(Repository repo, Models.Commit commit) @@ -60,7 +67,7 @@ public CreateTag(Repository repo, Models.Commit commit) _basedOn = commit.SHA; BasedOn = commit; - SignTag = new Commands.Config(repo.FullPath).GetAsync("tag.gpgsign").Result.Equals("true", StringComparison.OrdinalIgnoreCase); + SignTag = new Commands.Config(repo.FullPath).Get("tag.gpgsign").Equals("true", StringComparison.OrdinalIgnoreCase); } public static ValidationResult ValidateTagName(string name, ValidationContext ctx) @@ -76,16 +83,18 @@ public static ValidationResult ValidateTagName(string name, ValidationContext ct public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Create tag..."; var remotes = PushToRemotes ? _repo.Remotes : null; var log = _repo.CreateLog("Create Tag"); Use(log); - var cmd = new Commands.Tag(_repo.FullPath, _tagName).Use(log); - var succ = false; - if (_annotated) + 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); @@ -99,13 +108,11 @@ public override async Task Sure() } log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } private readonly Repository _repo = null; private string _tagName = string.Empty; - private bool _annotated = true; private readonly string _basedOn; } } diff --git a/src/ViewModels/DeinitSubmodule.cs b/src/ViewModels/DeinitSubmodule.cs index 1769ef80c..f9fff198d 100644 --- a/src/ViewModels/DeinitSubmodule.cs +++ b/src/ViewModels/DeinitSubmodule.cs @@ -25,7 +25,7 @@ public DeinitSubmodule(Repository repo, string submodule) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "De-initialize Submodule"; var log = _repo.CreateLog("De-initialize Submodule"); @@ -36,7 +36,7 @@ public override async Task Sure() .DeinitAsync(Submodule, false); log.Complete(); - _repo.SetWatcherEnabled(true); + _repo.MarkSubmodulesDirtyManually(); return succ; } diff --git a/src/ViewModels/DeleteBranch.cs b/src/ViewModels/DeleteBranch.cs index bcc132c9d..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 { @@ -9,83 +11,87 @@ public Models.Branch Target get; } - public Models.Branch TrackingRemoteBranch + public bool Force { get; - } - - public string 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 = App.Text("DeleteBranch.WithTrackingRemote", TrackingRemoteBranch.FriendlyName); - } } public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting branch..."; var log = _repo.CreateLog("Delete Branch"); Use(log); + var succ = false; if (Target.IsLocal) { - await new Commands.Branch(_repo.FullPath, Target.Name) + succ = await new Commands.Branch(_repo.FullPath, Target.Name) .Use(log) - .DeleteLocalAsync(); + .DeleteLocalAsync(Force); + + if (succ) + { + _repo.UIStates.RemoveHistoryFilter(Target.FullName, Models.FilterType.LocalBranch); + + var upstream = Target.Upstream ?? string.Empty; + var tracking = _repo.Branches.Find(x => x.FullName.Equals(upstream, StringComparison.Ordinal)); + if (tracking != null && tracking.Name.Equals(Target.Name, StringComparison.Ordinal)) + { + var msgBuilder = new StringBuilder(); + msgBuilder + .AppendLine(App.Text("DeleteBranch.AskForRemote")) + .AppendLine() + .Append("• ").Append(tracking.FriendlyName); - if (_alsoDeleteTrackingRemote && TrackingRemoteBranch != null) - await DeleteRemoteBranchAsync(TrackingRemoteBranch, log); + 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 { - await DeleteRemoteBranchAsync(Target, log); + succ = await DeleteRemoteBranchAsync(Target, log); + if (succ) + _repo.UIStates.RemoveHistoryFilter(Target.FullName, Models.FilterType.RemoteBranch); } log.Complete(); _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - return true; + return succ; } - private async Task DeleteRemoteBranchAsync(Models.Branch branch, CommandLog log) + 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) - await new Commands.Push(_repo.FullPath, branch.Remote, $"refs/heads/{branch.Name}", true) + return await new Commands.Push(_repo.FullPath, branch.Remote, $"refs/heads/{branch.Name}", true) .Use(log) .RunAsync() .ConfigureAwait(false); else - await new Commands.Branch(_repo.FullPath, branch.Name) + return await new Commands.Branch(_repo.FullPath, branch.Name) .Use(log) - .DeleteRemoteAsync(branch.Remote) + .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 086656e0a..eae8192dc 100644 --- a/src/ViewModels/DeleteMultipleBranches.cs +++ b/src/ViewModels/DeleteMultipleBranches.cs @@ -10,6 +10,12 @@ public List Targets get; } + public bool Force + { + get; + set; + } = false; + public DeleteMultipleBranches(Repository repo, List branches, bool isLocal) { _repo = repo; @@ -19,7 +25,7 @@ public DeleteMultipleBranches(Repository repo, List branches, boo public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting multiple branches..."; var log = _repo.CreateLog("Delete Multiple Branches"); @@ -30,7 +36,7 @@ public override async Task Sure() foreach (var target in Targets) await new Commands.Branch(_repo.FullPath, target.Name) .Use(log) - .DeleteLocalAsync(); + .DeleteLocalAsync(Force); } else { @@ -44,13 +50,12 @@ public override async Task Sure() else await new Commands.Branch(_repo.FullPath, target.Name) .Use(log) - .DeleteRemoteAsync(target.Remote); + .DeleteRemoteAsync(target.Remote, Force); } } log.Complete(); _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); return true; } 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 595a7d52b..ceef24588 100644 --- a/src/ViewModels/DeleteRemote.cs +++ b/src/ViewModels/DeleteRemote.cs @@ -18,7 +18,7 @@ public DeleteRemote(Repository repo, Models.Remote remote) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting remote ..."; var log = _repo.CreateLog("Delete Remote"); @@ -30,7 +30,6 @@ public override async Task Sure() log.Complete(); _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/DeleteSubmodule.cs b/src/ViewModels/DeleteSubmodule.cs index 34a77c6e1..998777421 100644 --- a/src/ViewModels/DeleteSubmodule.cs +++ b/src/ViewModels/DeleteSubmodule.cs @@ -18,7 +18,7 @@ public DeleteSubmodule(Repository repo, string submodule) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting submodule ..."; var log = _repo.CreateLog("Delete Submodule"); @@ -29,7 +29,6 @@ public override async Task Sure() .DeleteAsync(Submodule); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/DeleteTag.cs b/src/ViewModels/DeleteTag.cs index 04844f75d..6dc6ebb9c 100644 --- a/src/ViewModels/DeleteTag.cs +++ b/src/ViewModels/DeleteTag.cs @@ -12,8 +12,8 @@ public Models.Tag Target public bool PushToRemotes { - get => _repo.Settings.PushToRemoteWhenDeleteTag; - set => _repo.Settings.PushToRemoteWhenDeleteTag = value; + get => _repo.UIStates.PushToRemoteWhenDeleteTag; + set => _repo.UIStates.PushToRemoteWhenDeleteTag = value; } public DeleteTag(Repository repo, Models.Tag tag) @@ -24,7 +24,7 @@ public DeleteTag(Repository repo, Models.Tag tag) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Deleting tag '{Target.Name}' ..."; var remotes = PushToRemotes ? _repo.Remotes : []; @@ -44,8 +44,8 @@ public override async Task Sure() } log.Complete(); + _repo.UIStates.RemoveHistoryFilter(Target.Name, Models.FilterType.Tag); _repo.MarkTagsDirtyManually(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs index 025ceddd4..27eb93b8d 100644 --- a/src/ViewModels/DiffContext.cs +++ b/src/ViewModels/DiffContext.cs @@ -1,9 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; - using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -15,24 +13,16 @@ public string Title get; } - public bool IgnoreWhitespace + public int OldMode { - get => Preferences.Instance.IgnoreWhitespaceChangesInDiff; - set - { - if (value != Preferences.Instance.IgnoreWhitespaceChangesInDiff) - { - Preferences.Instance.IgnoreWhitespaceChangesInDiff = value; - OnPropertyChanged(); - 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 @@ -41,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; @@ -61,8 +57,10 @@ public DiffContext(string repo, Models.DiffOption option, DiffContext previous = if (previous != null) { _isTextDiff = previous._isTextDiff; + _isIgnoreWhitespaceVisible = previous._isIgnoreWhitespaceVisible; _content = previous._content; - _fileModeChange = previous._fileModeChange; + _oldMode = previous._oldMode; + _newMode = previous._newMode; _unifiedLines = previous._unifiedLines; _info = previous._info; } @@ -72,49 +70,71 @@ public DiffContext(string repo, Models.DiffOption option, DiffContext previous = else Title = $"{_option.OrgPath} → {_option.Path}"; - LoadDiffContent(); - } - - public void ToggleFullTextDiff() - { - Preferences.Instance.UseFullTextDiff = !Preferences.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 = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - new Commands.DiffTool(_repo, toolType, toolPath, _option).Open(); + new Commands.DiffTool(_repo, _option).Open(); + } + + 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 LoadDiffContent() + private void LoadContent() { if (_option.Path.EndsWith('/')) { - Content = null; + OldMode = 0; + NewMode = 160000; IsTextDiff = false; + IsIgnoreWhitespaceVisible = false; + Content = null; + _info = null; return; } Task.Run(async () => { - var numLines = Preferences.Instance.UseFullTextDiff ? 999999999 : _unifiedLines; - var ignoreWhitespace = Preferences.Instance.IgnoreWhitespaceChangesInDiff; + 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) + var latest = await new Commands.Diff(_repo, _option, numLines, ignoreWhitespace, ignoreCRAtEOL) .ReadAsync() .ConfigureAwait(false); @@ -125,103 +145,33 @@ private void LoadDiffContent() _info = info; object rs = null; - if (latest.TextDiff != 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('\\', '/').TrimEnd('/'); - isSubmodule = true; - for (int i = 1; i < count; i++) - { - var line = latest.TextDiff.Lines[i]; - if (!line.Content.StartsWith("Subproject commit ", StringComparison.Ordinal)) - { - isSubmodule = false; - break; - } - - var sha = line.Content.Substring(18); - if (line.Type == Models.TextDiffLineType.Added) - submoduleDiff.New = await QuerySubmoduleRevisionAsync(submoduleRoot, sha).ConfigureAwait(false); - else if (line.Type == Models.TextDiffLineType.Deleted) - submoduleDiff.Old = await QuerySubmoduleRevisionAsync(submoduleRoot, sha).ConfigureAwait(false); - } - - if (isSubmodule) - rs = submoduleDiff; - } - - if (!isSubmodule) - { - latest.TextDiff.File = _option.Path; - rs = latest.TextDiff; - } + rs = textDiff; } - else if (latest.IsBinary) + else if (latest.LFSDiff is { } lfs) { - var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; var imgDecoder = ImageSource.GetDecoder(_option.Path); - if (imgDecoder != Models.ImageDecoder.None) - { - var imgDiff = new Models.ImageDiff(); - - if (_option.Revisions.Count == 2) - { - var oldImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[0], oldPath, imgDecoder).ConfigureAwait(false); - var newImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[1], _option.Path, imgDecoder).ConfigureAwait(false); - imgDiff.Old = oldImage.Bitmap; - imgDiff.OldFileSize = oldImage.Size; - imgDiff.New = newImage.Bitmap; - imgDiff.NewFileSize = newImage.Size; - } - else - { - if (!oldPath.Equals("/dev/null", StringComparison.Ordinal)) - { - var oldImage = await ImageSource.FromRevisionAsync(_repo, "HEAD", oldPath, imgDecoder).ConfigureAwait(false); - imgDiff.Old = oldImage.Bitmap; - imgDiff.OldFileSize = oldImage.Size; - } - - var fullPath = Path.Combine(_repo, _option.Path); - if (File.Exists(fullPath)) - { - var newImage = await ImageSource.FromFileAsync(fullPath, imgDecoder).ConfigureAwait(false); - imgDiff.New = newImage.Bitmap; - imgDiff.NewFileSize = newImage.Size; - } - } - - rs = imgDiff; - } + rs = new LFSImageDiff(_repo, lfs, imgDecoder); else - { - var binaryDiff = new Models.BinaryDiff(); - if (_option.Revisions.Count == 2) - { - binaryDiff.OldSize = await new Commands.QueryFileSize(_repo, oldPath, _option.Revisions[0]).GetResultAsync().ConfigureAwait(false); - binaryDiff.NewSize = await new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).GetResultAsync().ConfigureAwait(false); - } - else - { - var fullPath = Path.Combine(_repo, _option.Path); - binaryDiff.OldSize = await new Commands.QueryFileSize(_repo, oldPath, "HEAD").GetResultAsync().ConfigureAwait(false); - binaryDiff.NewSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; - } - rs = binaryDiff; - } + rs = lfs; } - else if (latest.IsLFS) + else if (latest.IsBinary) { var imgDecoder = ImageSource.GetDecoder(_option.Path); if (imgDecoder != Models.ImageDecoder.None) - rs = new LFSImageDiff(_repo, latest.LFSDiff, imgDecoder); + rs = await CreateImageDiffAsync(imgDecoder).ConfigureAwait(false); else - rs = latest.LFSDiff; + rs = await CreateBinaryDiffAsync().ConfigureAwait(false); + } + else if (latest.IsSubmoduleChange) + { + rs = await CreateSubmoduleDiffAsync(latest.OldHash, latest.NewHash).ConfigureAwait(false); + } + else if (IsEmptyFileHash(latest.OldHash) || IsEmptyFileHash(latest.NewHash)) + { + rs = new Models.EmptyFile(); } else { @@ -230,28 +180,144 @@ 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; + + if (rs is Models.TextDiff cur) + { + IsTextDiff = true; + IsIgnoreWhitespaceVisible = true; - FileModeChange = latest.FileModeChange; - Content = rs; - IsTextDiff = rs is Models.TextDiff; + 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 async Task QuerySubmoduleRevisionAsync(string repo, string sha) + private async Task CreateImageDiffAsync(Models.ImageDecoder imgDecoder) { - var commit = await new Commands.QuerySingleCommit(repo, sha).GetResultAsync().ConfigureAwait(false); - if (commit == null) - return new Models.RevisionSubmodule() { Commit = new Models.Commit() { SHA = sha } }; + var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; + var imgDiff = new Models.ImageDiff(); + var fullPath = Path.Combine(_repo, _option.Path); - var body = await new Commands.QueryCommitFullMessage(repo, sha).GetResultAsync().ConfigureAwait(false); - return new Models.RevisionSubmodule() + if (_option.Revisions.Count == 2) { - Commit = commit, - FullMessage = new Models.CommitFullMessage { Message = body } - }; + if (_option.Revisions[0].Equals("-R", StringComparison.Ordinal)) + { + var oldImage = await ImageSource.FromFileAsync(fullPath, imgDecoder).ConfigureAwait(false); + imgDiff.Old = oldImage.Bitmap; + imgDiff.OldFileSize = oldImage.Size; + } + else + { + var oldImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[0], oldPath, imgDecoder).ConfigureAwait(false); + imgDiff.Old = oldImage.Bitmap; + imgDiff.OldFileSize = oldImage.Size; + } + + var newImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[1], _option.Path, imgDecoder).ConfigureAwait(false); + imgDiff.New = newImage.Bitmap; + imgDiff.NewFileSize = newImage.Size; + } + else + { + if (!oldPath.Equals("/dev/null", StringComparison.Ordinal)) + { + var oldImage = await ImageSource.FromRevisionAsync(_repo, "HEAD", oldPath, imgDecoder).ConfigureAwait(false); + imgDiff.Old = oldImage.Bitmap; + imgDiff.OldFileSize = oldImage.Size; + } + + var newImage = await ImageSource.FromFileAsync(fullPath, imgDecoder).ConfigureAwait(false); + imgDiff.New = newImage.Bitmap; + imgDiff.NewFileSize = newImage.Size; + } + + return imgDiff; + } + + private async Task CreateBinaryDiffAsync() + { + var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; + var binaryDiff = new Models.BinaryDiff(); + var fullPath = Path.Combine(_repo, _option.Path); + + if (_option.Revisions.Count == 2) + { + if (_option.Revisions[0].Equals("-R", StringComparison.Ordinal)) + { + binaryDiff.OldSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; + binaryDiff.NewSize = await new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).GetResultAsync().ConfigureAwait(false); + } + else + { + binaryDiff.OldSize = await new Commands.QueryFileSize(_repo, oldPath, _option.Revisions[0]).GetResultAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(_option.Revisions[1])) + binaryDiff.NewSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; + else + binaryDiff.NewSize = await new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).GetResultAsync().ConfigureAwait(false); + } + } + else + { + binaryDiff.OldSize = await new Commands.QueryFileSize(_repo, oldPath, "HEAD").GetResultAsync().ConfigureAwait(false); + binaryDiff.NewSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; + } + + return binaryDiff; + } + + private async Task CreateSubmoduleDiffAsync(string oldRevision, string newRevision) + { + var submoduleDiff = new Models.SubmoduleDiff(); + var submoduleRoot = $"{_repo}/{_option.Path}".Replace('\\', '/').TrimEnd('/'); + submoduleDiff.FullPath = submoduleRoot; + + if (IsValidSubmoduleHash(oldRevision)) + submoduleDiff.Old = await new Commands.QuerySubmoduleRevision(submoduleRoot, oldRevision) + .GetResultAsync() + .ConfigureAwait(false); + + if (IsValidSubmoduleHash(newRevision)) + submoduleDiff.New = await new Commands.QuerySubmoduleRevision(submoduleRoot, newRevision) + .GetResultAsync() + .ConfigureAwait(false); + + return submoduleDiff; + } + + private bool IsValidSubmoduleHash(string hash) + { + for (int i = 0; i < hash.Length; i++) + { + if (hash[i] != '0') + return true; + } + + return false; + } + + private bool IsEmptyFileHash(string hash) + { + if (string.IsNullOrEmpty(hash)) + return false; + + if (hash.Length == 40) + return hash.Equals(Models.EmptyFile.SHA1, StringComparison.Ordinal); + + if (hash.Length == 64) + return hash.Equals(Models.EmptyFile.SHA256, StringComparison.Ordinal); + + return false; } private class Info @@ -281,11 +347,14 @@ public bool IsSame(Info other) } } + private readonly int _entireFileLines = 999999999; private readonly string _repo; private readonly Models.DiffOption _option = null; - private string _fileModeChange = string.Empty; + private int _oldMode = 0; + private int _newMode = 0; private int _unifiedLines = 4; private bool _isTextDiff = false; + private bool _isIgnoreWhitespaceVisible = true; private object _content = null; private Info _info = null; } diff --git a/src/ViewModels/DirHistories.cs b/src/ViewModels/DirHistories.cs index 04fee2b27..a1448009c 100644 --- a/src/ViewModels/DirHistories.cs +++ b/src/ViewModels/DirHistories.cs @@ -38,7 +38,6 @@ public Models.Commit SelectedCommit public CommitDetail Detail { get => _detail; - private set => SetProperty(ref _detail, value); } public DirHistories(Repository repo, string dir, string revision = null) @@ -49,7 +48,7 @@ public DirHistories(Repository repo, string dir, string revision = null) Title = dir; _repo = repo; - _detail = new CommitDetail(repo, false); + _detail = new CommitDetail(repo, null); _detail.SearchChangeFilter = dir; Task.Run(async () => @@ -76,6 +75,39 @@ public DirHistories(Repository repo, string dir, string revision = null) }); } + 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); @@ -87,7 +119,7 @@ public string GetCommitFullMessage(Models.Commit commit) if (_cachedCommitFullMessage.TryGetValue(sha, out var msg)) return msg; - msg = new Commands.QueryCommitFullMessage(_repo.FullPath, sha).GetResultAsync().Result; + msg = new Commands.QueryCommitFullMessage(_repo.FullPath, sha).GetResult(); _cachedCommitFullMessage[sha] = msg; return msg; } diff --git a/src/ViewModels/Discard.cs b/src/ViewModels/Discard.cs index 1fcda9049..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; @@ -58,20 +70,24 @@ public Discard(Repository repo, List changes) 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 ..."; - var log = _repo.CreateLog("Discard all"); + var log = _repo.CreateLog("Discard Changes"); Use(log); if (Mode is DiscardAllMode all) - await Commands.Discard.AllAsync(_repo.FullPath, all.IncludeIgnored, log); + { + await Commands.Discard.AllAsync(_repo.FullPath, all.IncludeModified, all.IncludeUntracked, all.IncludeIgnored, log); + _repo.ClearCommitMessage(); + } else + { await Commands.Discard.ChangesAsync(_repo.FullPath, _changes, log); + } log.Complete(); _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/DropStash.cs b/src/ViewModels/DropStash.cs index 8c826c2cd..7d4136ab9 100644 --- a/src/ViewModels/DropStash.cs +++ b/src/ViewModels/DropStash.cs @@ -14,6 +14,7 @@ public DropStash(Repository repo, Models.Stash stash) public override async Task Sure() { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Dropping stash: {Stash.Name}"; var log = _repo.CreateLog("Drop Stash"); @@ -24,6 +25,7 @@ public override async Task Sure() .DropAsync(Stash.Name); log.Complete(); + _repo.MarkStashesDirtyManually(); return true; } 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 4c7e3237a..84b950e2a 100644 --- a/src/ViewModels/EditRemote.cs +++ b/src/ViewModels/EditRemote.cs @@ -53,7 +53,7 @@ public EditRemote(Repository repo, Models.Remote remote) _useSSH = Models.Remote.IsSSH(remote.URL); if (_useSSH) - SSHKey = new Commands.Config(repo.FullPath).GetAsync($"remote.{remote.Name}.sshkey").Result; + _sshkey = new Commands.Config(repo.FullPath).Get($"remote.{remote.Name}.sshkey"); } public static ValidationResult ValidateRemoteName(string name, ValidationContext ctx) @@ -100,7 +100,7 @@ public static ValidationResult ValidateSSHKey(string sshkey, ValidationContext c public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Editing remote '{_remote.Name}' ..."; if (_remote.Name != _name) @@ -122,8 +122,6 @@ public override async Task Sure() await new Commands.Remote(_repo.FullPath).SetURLAsync(_name, _url, true); await new Commands.Config(_repo.FullPath).SetAsync($"remote.{_name}.sshkey", _useSSH ? SSHKey : null); - - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/EditRepositoryNode.cs b/src/ViewModels/EditRepositoryNode.cs index 38d072a14..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,19 +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; + + Target = node.IsRepository ? node.Id : node.Name; + IsRepository = node.IsRepository; } public override Task Sure() @@ -55,9 +54,7 @@ public override Task Sure() } 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 fb0f2f819..1c01fb820 100644 --- a/src/ViewModels/ExecuteCustomAction.cs +++ b/src/ViewModels/ExecuteCustomAction.cs @@ -15,25 +15,32 @@ public interface ICustomActionControlParameter public class CustomActionControlTextBox : ICustomActionControlParameter { - public string Label { get; set; } = string.Empty; - public string Placeholder { get; set; } = string.Empty; - public string Text { get; set; } = string.Empty; + 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) + public CustomActionControlTextBox(string label, string placeholder, string defaultValue, string formatter) { Label = label + ":"; Placeholder = placeholder; Text = defaultValue; + Formatter = formatter; } - public string GetValue() => Text; + 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; } = string.Empty; - public string Placeholder { get; set; } = string.Empty; - public bool IsFolder { get; set; } = false; + public string Label { get; set; } + public string Placeholder { get; set; } + public bool IsFolder { get; set; } public string Path { @@ -51,14 +58,14 @@ public CustomActionControlPathSelector(string label, string placeholder, bool is public string GetValue() => _path; - private string _path = string.Empty; + private string _path; } public class CustomActionControlCheckBox : ICustomActionControlParameter { - public string Label { get; set; } = string.Empty; - public string ToolTip { get; set; } = string.Empty; - public string CheckedValue { get; set; } = string.Empty; + 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) @@ -74,15 +81,10 @@ public CustomActionControlCheckBox(string label, string tooltip, string checkedV public class CustomActionControlComboBox : ObservableObject, ICustomActionControlParameter { - public string Label { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; + public string Label { get; set; } + public string Description { get; set; } public List Options { get; set; } = []; - - public string Value - { - get => _value; - set => SetProperty(ref _value, value); - } + public string Value { get; set; } public CustomActionControlComboBox(string label, string description, string options) { @@ -93,13 +95,69 @@ public CustomActionControlComboBox(string label, string description, string opti if (parts.Length > 0) { Options.AddRange(parts); - _value = parts[0]; + Value = parts[0]; } } - public string GetValue() => _value; + 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; + } + } + } - private string _value = string.Empty; + 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 @@ -119,41 +177,41 @@ public List ControlParameters get; } = []; - public ExecuteCustomAction(Repository repo, Models.CustomAction action) - { - _repo = repo; - CustomAction = action; - Target = new Models.Null(); - PrepareControlParameters(); - } - - public ExecuteCustomAction(Repository repo, Models.CustomAction action, Models.Branch branch) + public ExecuteCustomAction(Repository repo, Models.CustomAction action, object scopeTarget) { _repo = repo; CustomAction = action; - Target = branch; - PrepareControlParameters(); - } - - public ExecuteCustomAction(Repository repo, Models.CustomAction action, Models.Commit commit) - { - _repo = repo; - CustomAction = action; - Target = commit; - PrepareControlParameters(); - } + Target = scopeTarget ?? new Models.Null(); - public ExecuteCustomAction(Repository repo, Models.CustomAction action, Models.Tag tag) - { - _repo = repo; - CustomAction = action; - Target = tag; - PrepareControlParameters(); + foreach (var ctl in CustomAction.Controls) + { + switch (ctl.Type) + { + case Models.CustomActionControlType.TextBox: + ControlParameters.Add(new CustomActionControlTextBox(ctl.Label, ctl.Description, PrepareStringByTarget(ctl.StringValue), ctl.StringFormatter)); + break; + case Models.CustomActionControlType.PathSelector: + ControlParameters.Add(new CustomActionControlPathSelector(ctl.Label, ctl.Description, ctl.BoolValue, PrepareStringByTarget(ctl.StringValue))); + break; + case Models.CustomActionControlType.CheckBox: + ControlParameters.Add(new CustomActionControlCheckBox(ctl.Label, ctl.Description, ctl.StringValue, ctl.BoolValue)); + break; + case Models.CustomActionControlType.ComboBox: + ControlParameters.Add(new CustomActionControlComboBox(ctl.Label, ctl.Description, PrepareStringByTarget(ctl.StringValue))); + break; + case Models.CustomActionControlType.LocalBranchSelector: + ControlParameters.Add(new CustomActionControlBranchSelector(ctl.Label, ctl.Description, _repo, true, false, scopeTarget ?? _repo.CurrentBranch)); + break; + case Models.CustomActionControlType.RemoteBranchSelector: + ControlParameters.Add(new CustomActionControlBranchSelector(ctl.Label, ctl.Description, _repo, false, ctl.BoolValue, scopeTarget ?? _repo.CurrentBranch)); + break; + } + } } public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Run custom action ..."; var cmdline = PrepareStringByTarget(CustomAction.Arguments); @@ -174,50 +232,32 @@ public override async Task Sure() _ = Task.Run(() => Run(cmdline)); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } - private void PrepareControlParameters() - { - foreach (var ctl in CustomAction.Controls) - { - switch (ctl.Type) - { - case Models.CustomActionControlType.TextBox: - ControlParameters.Add(new CustomActionControlTextBox(ctl.Label, ctl.Description, PrepareStringByTarget(ctl.StringValue))); - 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; - } - } - } - private string PrepareStringByTarget(string org) { - org = org.Replace("${REPO}", GetWorkdir()); + 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("${BRANCH}", b.FriendlyName), + Models.Branch b => org.Replace("${SHA}", b.Head), Models.Commit c => org.Replace("${SHA}", c.SHA), - Models.Tag t => org.Replace("${TAG}", t.Name), + 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 string GetWorkdir() - { - return OperatingSystem.IsWindows() ? _repo.FullPath.Replace("/", "\\") : _repo.FullPath; - } - private void Run(string args) { var start = new ProcessStartInfo(); @@ -233,7 +273,7 @@ private void Run(string args) } catch (Exception e) { - App.RaiseException(_repo.FullPath, e.Message); + _repo.SendNotification(e.Message, true); } } @@ -250,8 +290,8 @@ private async Task RunAsync(string args, Models.ICommandLog log) start.StandardErrorEncoding = Encoding.UTF8; start.WorkingDirectory = _repo.FullPath; - using var proc = new Process() { StartInfo = start }; - var builder = new StringBuilder(); + using var proc = new Process(); + proc.StartInfo = start; proc.OutputDataReceived += (_, e) => { @@ -259,6 +299,7 @@ private async Task RunAsync(string args, Models.ICommandLog log) log?.AppendLine(e.Data); }; + var builder = new StringBuilder(); proc.ErrorDataReceived += (_, e) => { if (e.Data != null) @@ -280,12 +321,12 @@ private async Task RunAsync(string args, Models.ICommandLog log) { var errMsg = builder.ToString().Trim(); if (!string.IsNullOrEmpty(errMsg)) - App.RaiseException(_repo.FullPath, errMsg); + _repo.SendNotification(errMsg, true); } } catch (Exception e) { - App.RaiseException(_repo.FullPath, e.Message); + _repo.SendNotification(e.Message, true); } } 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/Fetch.cs b/src/ViewModels/Fetch.cs index 0930e7773..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,20 +33,21 @@ 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 bool Force { - get => _repo.Settings.EnableForceOnFetch; - set => _repo.Settings.EnableForceOnFetch = value; + get => _repo.UIStates.EnableForceOnFetch; + set => _repo.UIStates.EnableForceOnFetch = value; } public Fetch(Repository repo, Models.Remote preferredRemote = null) { _repo = repo; - _fetchAllRemotes = preferredRemote == null; + IsFetchAllRemoteVisible = repo.Remotes.Count > 1 && preferredRemote == null; + _fetchAllRemotes = IsFetchAllRemoteVisible && _repo.UIStates.FetchAllRemotes; if (preferredRemote != null) { @@ -56,10 +66,14 @@ public Fetch(Repository repo, Models.Remote preferredRemote = null) 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.Settings.FetchWithoutTags; - var force = _repo.Settings.EnableForceOnFetch; + var notags = _repo.UIStates.FetchWithoutTags; + var force = _repo.UIStates.EnableForceOnFetch; var log = _repo.CreateLog("Fetch"); Use(log); @@ -79,19 +93,21 @@ public override async Task Sure() log.Complete(); - var upstream = _repo.CurrentBranch?.Upstream; - if (!string.IsNullOrEmpty(upstream)) + if (navigateToUpstreamHEAD) { - var upstreamHead = await new Commands.QueryRevisionByRefName(_repo.FullPath, upstream.Substring(13)).GetResultAsync(); - _repo.NavigateToCommit(upstreamHead, true); + var upstream = _repo.CurrentBranch?.Upstream; + if (!string.IsNullOrEmpty(upstream)) + { + var upstreamHead = await new Commands.QueryRevisionByRefName(_repo.FullPath, upstream.Substring(13)).GetResultAsync(); + _repo.NavigateToCommit(upstreamHead, true); + } } _repo.MarkFetched(); - _repo.SetWatcherEnabled(true); 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 3a0879b9e..bcf461203 100644 --- a/src/ViewModels/FetchInto.cs +++ b/src/ViewModels/FetchInto.cs @@ -23,7 +23,7 @@ public FetchInto(Repository repo, Models.Branch local, Models.Branch upstream) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Fast-Forward ..."; var log = _repo.CreateLog($"Fetch Into '{Local.FriendlyName}'"); @@ -35,9 +35,12 @@ public override async Task Sure() log.Complete(); - var newHead = await new Commands.QueryRevisionByRefName(_repo.FullPath, Local.Name).GetResultAsync(); - _repo.NavigateToCommit(newHead, true); - _repo.SetWatcherEnabled(true); + if (_repo.SelectedViewIndex == 0) + { + var newHead = await new Commands.QueryRevisionByRefName(_repo.FullPath, Local.Name).GetResultAsync(); + _repo.NavigateToCommit(newHead, true); + } + return true; } diff --git a/src/ViewModels/FileHistories.cs b/src/ViewModels/FileHistories.cs index 19066ca21..374396f1f 100644 --- a/src/ViewModels/FileHistories.cs +++ b/src/ViewModels/FileHistories.cs @@ -1,12 +1,8 @@ using System; using System.Collections.Generic; using System.IO; -using System.Text; using System.Threading.Tasks; - -using Avalonia.Collections; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -18,15 +14,27 @@ public class FileHistoriesRevisionFile(string path, object content = null, bool public bool CanOpenWithDefaultEditor { get; set; } = canOpenWithDefaultEditor; } + public class FileHistoriesSingleRevisionViewMode + { + public bool IsDiff + { + get; + set; + } = true; + } + public class FileHistoriesSingleRevision : ObservableObject { public bool IsDiffMode { - get => _isDiffMode; + get => _viewMode.IsDiff; set { - if (SetProperty(ref _isDiffMode, value)) + if (_viewMode.IsDiff != value) + { + _viewMode.IsDiff = value; RefreshViewContent(); + } } } @@ -36,20 +44,27 @@ public object ViewContent set => SetProperty(ref _viewContent, value); } - public FileHistoriesSingleRevision(Repository repo, string file, Models.Commit revision, bool prevIsDiffMode) + public FileHistoriesSingleRevision(string repo, Models.FileVersion revision, FileHistoriesSingleRevisionViewMode viewMode) { _repo = repo; - _file = file; + _file = revision.Path; _revision = revision; - _isDiffMode = prevIsDiffMode; + _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.FullPath) + return await new Commands.Checkout(_repo) .FileWithRevisionAsync(_file, $"{_revision.SHA}") .ConfigureAwait(false); } @@ -59,13 +74,13 @@ public async Task OpenWithDefaultEditorAsync() if (_viewContent is not FileHistoriesRevisionFile { CanOpenWithDefaultEditor: true }) return; - var fullPath = Native.OS.GetAbsPath(_repo.FullPath, _file); + 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.FullPath, _revision.SHA, _file, tmpFile) + .RunAsync(_repo, _revision.SHA, _file, tmpFile) .ConfigureAwait(false); Native.OS.OpenWithDefaultEditor(tmpFile); @@ -73,114 +88,104 @@ await Commands.SaveRevisionFile private void RefreshViewContent() { - if (_isDiffMode) - SetViewContentAsDiff(); - else - SetViewContentAsRevisionFile(); + if (_viewMode.IsDiff) + { + ViewContent = new DiffContext(_repo, new(_revision), _viewContent as DiffContext); + return; + } + + Task.Run(async () => + { + var objs = await new Commands.QueryRevisionObjects(_repo, _revision.SHA, _file) + .GetResultAsync() + .ConfigureAwait(false); + + if (objs.Count == 0) + { + Dispatcher.UIThread.Post(() => ViewContent = new FileHistoriesRevisionFile(_file)); + return; + } + + var revisionContent = await GetRevisionFileContentAsync(objs[0]).ConfigureAwait(false); + Dispatcher.UIThread.Post(() => ViewContent = revisionContent); + }); } - private void SetViewContentAsRevisionFile() + private async Task GetRevisionFileContentAsync(Models.Object obj) { - var objs = new Commands.QueryRevisionObjects(_repo.FullPath, _revision.SHA, _file).GetResultAsync().Result; - if (objs.Count == 0) + if (obj.Type == Models.ObjectType.Blob) { - ViewContent = new FileHistoriesRevisionFile(_file); - return; + 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); } - var obj = objs[0]; - switch (obj.Type) + if (obj.Type == Models.ObjectType.Commit) { - case Models.ObjectType.Blob: - Task.Run(async () => - { - var isBinary = await new Commands.IsBinary(_repo.FullPath, _revision.SHA, _file).GetResultAsync().ConfigureAwait(false); - if (isBinary) - { - var imgDecoder = ImageSource.GetDecoder(_file); - if (imgDecoder != Models.ImageDecoder.None) - { - var source = await ImageSource.FromRevisionAsync(_repo.FullPath, _revision.SHA, _file, imgDecoder).ConfigureAwait(false); - var image = new Models.RevisionImageFile(_file, source.Bitmap, source.Size); - Dispatcher.UIThread.Post(() => ViewContent = new FileHistoriesRevisionFile(_file, image, true)); - } - else - { - var size = await new Commands.QueryFileSize(_repo.FullPath, _file, _revision.SHA).GetResultAsync().ConfigureAwait(false); - var binaryFile = new Models.RevisionBinaryFile() { Size = size }; - Dispatcher.UIThread.Post(() => ViewContent = new FileHistoriesRevisionFile(_file, binaryFile, true)); - } - - return; - } - - var contentStream = await Commands.QueryFileContent.RunAsync(_repo.FullPath, _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.FullPath, _file, lfs, imgDecoder); - Dispatcher.UIThread.Post(() => ViewContent = new FileHistoriesRevisionFile(_file, combined, true)); - } - else - { - var rlfs = new Models.RevisionLFSObject() { Object = lfs }; - Dispatcher.UIThread.Post(() => ViewContent = new FileHistoriesRevisionFile(_file, rlfs, true)); - } - } - else - { - var txt = new Models.RevisionTextFile() { FileName = obj.Path, Content = content }; - Dispatcher.UIThread.Post(() => ViewContent = new FileHistoriesRevisionFile(_file, txt, true)); - } - }); - break; - case Models.ObjectType.Commit: - Task.Run(async () => + 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() { - var submoduleRoot = Path.Combine(_repo.FullPath, _file); - var commit = await new Commands.QuerySingleCommit(submoduleRoot, obj.SHA).GetResultAsync().ConfigureAwait(false); - var message = commit != null ? await new Commands.QueryCommitFullMessage(submoduleRoot, obj.SHA).GetResultAsync().ConfigureAwait(false) : null; - var module = new Models.RevisionSubmodule() - { - Commit = commit ?? new Models.Commit() { SHA = obj.SHA }, - FullMessage = new Models.CommitFullMessage { Message = message } - }; - - Dispatcher.UIThread.Post(() => ViewContent = new FileHistoriesRevisionFile(_file, module)); - }); - break; - default: - ViewContent = new FileHistoriesRevisionFile(_file); - break; + Commit = new Models.Commit() { SHA = obj.SHA }, + FullMessage = new Models.CommitFullMessage { Message = null } + }; + } + + return new FileHistoriesRevisionFile(_file, module); } - } - private void SetViewContentAsDiff() - { - var option = new Models.DiffOption(_revision, _file); - ViewContent = new DiffContext(_repo.FullPath, option, _viewContent as DiffContext); + return new FileHistoriesRevisionFile(_file); } - private Repository _repo = null; + private string _repo = null; private string _file = null; - private Models.Commit _revision = null; - private bool _isDiffMode = false; + private Models.FileVersion _revision = null; + private FileHistoriesSingleRevisionViewMode _viewMode = null; private object _viewContent = null; } public class FileHistoriesCompareRevisions : ObservableObject { - public Models.Commit StartPoint + public Models.FileVersion StartPoint { get => _startPoint; set => SetProperty(ref _startPoint, value); } - public Models.Commit EndPoint + public Models.FileVersion EndPoint { get => _endPoint; set => SetProperty(ref _endPoint, value); @@ -192,49 +197,30 @@ public DiffContext ViewContent set => SetProperty(ref _viewContent, value); } - public FileHistoriesCompareRevisions(Repository repo, string file, Models.Commit start, Models.Commit end) + public FileHistoriesCompareRevisions(string repo, Models.FileVersion start, Models.FileVersion end) { _repo = repo; - _file = file; _startPoint = start; _endPoint = end; - RefreshViewContent(); + _viewContent = new(_repo, new(start, end)); } public void Swap() { (StartPoint, EndPoint) = (_endPoint, _startPoint); - RefreshViewContent(); + ViewContent = new(_repo, new(_startPoint, _endPoint), _viewContent); } public async Task SaveAsPatch(string saveTo) { return await Commands.SaveChangesAsPatch - .ProcessRevisionCompareChangesAsync(_repo.FullPath, _changes, _startPoint.SHA, _endPoint.SHA, saveTo) + .ProcessRevisionCompareChangesAsync(_repo, _changes, _startPoint.SHA, _endPoint.SHA, saveTo) .ConfigureAwait(false); } - private void RefreshViewContent() - { - Task.Run(async () => - { - _changes = await new Commands.CompareRevisions(_repo.FullPath, _startPoint.SHA, _endPoint.SHA, _file).ReadAsync().ConfigureAwait(false); - if (_changes.Count == 0) - { - Dispatcher.UIThread.Post(() => ViewContent = null); - } - else - { - var option = new Models.DiffOption(_startPoint.SHA, _endPoint.SHA, _changes[0]); - Dispatcher.UIThread.Post(() => ViewContent = new DiffContext(_repo.FullPath, option, _viewContent)); - } - }); - } - - private Repository _repo = null; - private string _file = null; - private Models.Commit _startPoint = null; - private Models.Commit _endPoint = null; + private string _repo = null; + private Models.FileVersion _startPoint = null; + private Models.FileVersion _endPoint = null; private List _changes = []; private DiffContext _viewContent = null; } @@ -252,17 +238,21 @@ public bool IsLoading private set => SetProperty(ref _isLoading, value); } - public List Commits + public List Revisions { - get => _commits; - set => SetProperty(ref _commits, value); + get => _revisions; + set => SetProperty(ref _revisions, value); } - public AvaloniaList SelectedCommits + public List SelectedRevisions { - get; - set; - } = []; + get => _selectedRevisions; + set + { + if (SetProperty(ref _selectedRevisions, value)) + RefreshViewContent(); + } + } public object ViewContent { @@ -270,7 +260,7 @@ 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}"; @@ -281,60 +271,74 @@ public FileHistories(Repository repo, string file, string commit = null) Task.Run(async () => { - var argsBuilder = new StringBuilder(); - argsBuilder - .Append("--date-order -n 10000 ") - .Append(commit ?? string.Empty) - .Append(" -- ") - .Append(file.Quoted()); - - var commits = await new Commands.QueryCommits(_repo.FullPath, argsBuilder.ToString(), false) + var revisions = await new Commands.QueryFileHistory(_repo, file, commit) .GetResultAsync() .ConfigureAwait(false); Dispatcher.UIThread.Post(() => { IsLoading = false; - Commits = commits; - if (Commits.Count > 0) - SelectedCommits.Add(Commits[0]); + Revisions = revisions; }); }); - - SelectedCommits.CollectionChanged += (_, _) => - { - if (_viewContent is FileHistoriesSingleRevision singleRevision) - _prevIsDiffMode = singleRevision.IsDiffMode; - - ViewContent = SelectedCommits.Count switch - { - 1 => new FileHistoriesSingleRevision(_repo, file, SelectedCommits[0], _prevIsDiffMode), - 2 => new FileHistoriesCompareRevisions(_repo, file, SelectedCommits[0], SelectedCommits[1]), - _ => SelectedCommits.Count, - }; - }; } - 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 string GetCommitFullMessage(Models.Commit commit) + public string GetCommitFullMessage(Models.FileVersion revision) { - var sha = commit.SHA; + var sha = revision.SHA; if (_fullCommitMessages.TryGetValue(sha, out var msg)) return msg; - msg = new Commands.QueryCommitFullMessage(_repo.FullPath, sha).GetResultAsync().Result; + msg = new Commands.QueryCommitFullMessage(_repo, sha).GetResult(); _fullCommitMessages[sha] = msg; return msg; } - private readonly Repository _repo = null; + private void RefreshViewContent() + { + var count = _selectedRevisions?.Count ?? 0; + if (count == 0) + { + ViewContent = null; + } + else if (count == 1) + { + if (_viewContent is FileHistoriesSingleRevision single) + single.SetRevision(_selectedRevisions[0]); + else + ViewContent = new FileHistoriesSingleRevision(_repo, _selectedRevisions[0], _viewMode); + } + else if (count == 2) + { + ViewContent = new FileHistoriesCompareRevisions(_repo, _selectedRevisions[0], _selectedRevisions[1]); + } + else + { + ViewContent = _selectedRevisions.Count; + } + } + + private readonly string _repo = null; private bool _isLoading = true; - private bool _prevIsDiffMode = true; - private List _commits = null; + private FileHistoriesSingleRevisionViewMode _viewMode = new(); + private List _revisions = null; + private List _selectedRevisions = []; private Dictionary _fullCommitMessages = new(); private object _viewContent = null; } diff --git a/src/ViewModels/FileHistoryCommandPalette.cs b/src/ViewModels/FileHistoryCommandPalette.cs new file mode 100644 index 000000000..097e85669 --- /dev/null +++ b/src/ViewModels/FileHistoryCommandPalette.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class FileHistoryCommandPalette : ICommandPalette + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List VisibleFiles + { + get => _visibleFiles; + private set => SetProperty(ref _visibleFiles, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateVisible(); + } + } + + public string SelectedFile + { + get => _selectedFile; + set => SetProperty(ref _selectedFile, value); + } + + public FileHistoryCommandPalette(string repo) + { + _repo = repo; + _isLoading = true; + + Task.Run(async () => + { + var files = await new Commands.QueryRevisionFileNames(_repo, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + IsLoading = false; + _repoFiles = files; + UpdateVisible(); + }); + }); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public FileHistories Launch() + { + _repoFiles.Clear(); + _visibleFiles.Clear(); + Close(); + return !string.IsNullOrEmpty(_selectedFile) ? new FileHistories(_repo, _selectedFile) : null; + } + + private void UpdateVisible() + { + if (_repoFiles is { Count: > 0 }) + { + if (string.IsNullOrEmpty(_filter)) + { + VisibleFiles = _repoFiles; + + if (string.IsNullOrEmpty(_selectedFile)) + SelectedFile = _repoFiles[0]; + } + else + { + var visible = new List(); + + foreach (var f in _repoFiles) + { + if (f.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(f); + } + + var autoSelected = _selectedFile; + if (visible.Count == 0) + autoSelected = null; + else if (string.IsNullOrEmpty(_selectedFile) || !visible.Contains(_selectedFile)) + autoSelected = visible[0]; + + VisibleFiles = visible; + SelectedFile = autoSelected; + } + } + } + + private string _repo = null; + private bool _isLoading = false; + private List _repoFiles = null; + private string _filter = string.Empty; + private List _visibleFiles = []; + private string _selectedFile = null; + } +} diff --git a/src/ViewModels/FilterModeInGraph.cs b/src/ViewModels/FilterModeInGraph.cs index 9930b8164..773f4bc5b 100644 --- a/src/ViewModels/FilterModeInGraph.cs +++ b/src/ViewModels/FilterModeInGraph.cs @@ -1,5 +1,4 @@ -using System; -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { @@ -23,20 +22,9 @@ public FilterModeInGraph(Repository repo, object target) _target = target; if (_target is Models.Branch b) - _mode = GetFilterMode(b.FullName); + _mode = _repo.UIStates.GetHistoryFilterMode(b.FullName); else if (_target is Models.Tag t) - _mode = GetFilterMode(t.Name); - } - - private Models.FilterMode GetFilterMode(string pattern) - { - foreach (var filter in _repo.Settings.HistoriesFilters) - { - if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) - return filter.Mode; - } - - return Models.FilterMode.None; + _mode = _repo.UIStates.GetHistoryFilterMode(t.Name); } private void SetFilterMode(Models.FilterMode mode) diff --git a/src/ViewModels/GitFlowFinish.cs b/src/ViewModels/GitFlowFinish.cs index 959fa10e0..373e5398b 100644 --- a/src/ViewModels/GitFlowFinish.cs +++ b/src/ViewModels/GitFlowFinish.cs @@ -15,13 +15,13 @@ public Models.GitFlowBranchType Type private set; } - public bool Squash + public bool RebaseBeforeMerging { get; set; } = false; - public bool AutoPush + public bool Squash { get; set; @@ -42,7 +42,7 @@ public GitFlowFinish(Repository repo, Models.Branch branch, Models.GitFlowBranch public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Git Flow - Finish {Branch.Name} ..."; var log = _repo.CreateLog("GitFlow - Finish"); @@ -50,10 +50,11 @@ public override async Task Sure() var prefix = _repo.GitFlow.GetPrefix(Type); var name = Branch.Name.StartsWith(prefix) ? Branch.Name.Substring(prefix.Length) : Branch.Name; - var succ = await Commands.GitFlow.FinishAsync(_repo.FullPath, Type, name, Squash, AutoPush, KeepBranch, log); + var succ = await new Commands.GitFlow(_repo.FullPath) + .Use(log) + .FinishAsync(Type, name, RebaseBeforeMerging, Squash, KeepBranch); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/GitFlowStart.cs b/src/ViewModels/GitFlowStart.cs index e6055f8e7..2fe9c0784 100644 --- a/src/ViewModels/GitFlowStart.cs +++ b/src/ViewModels/GitFlowStart.cs @@ -1,4 +1,6 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; namespace SourceGit.ViewModels @@ -11,6 +13,18 @@ public Models.GitFlowBranchType Type private set; } + public List LocalBranches + { + get; + private set; + } + + public Models.Branch StartPoint + { + get => _startPoint; + set => SetProperty(ref _startPoint, value); + } + public string Prefix { get; @@ -32,6 +46,21 @@ public GitFlowStart(Repository repo, Models.GitFlowBranchType type) Type = type; Prefix = _repo.GitFlow.GetPrefix(type); + LocalBranches = new List(); + + foreach (var b in _repo.Branches) + { + if (b.IsLocal && !b.IsDetachedHead) + LocalBranches.Add(b); + } + + 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) @@ -51,19 +80,22 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Git Flow - Start {Prefix}{_name} ..."; var log = _repo.CreateLog("GitFlow - Start"); Use(log); - var succ = await Commands.GitFlow.StartAsync(_repo.FullPath, Type, _name, log); + var succ = await new Commands.GitFlow(_repo.FullPath) + .Use(log) + .StartAsync(Type, _name, _startPoint); + log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } private readonly Repository _repo; private string _name = null; + private Models.Branch _startPoint = null; } } diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 7d9b9573a..15cf15748 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -1,17 +1,16 @@ using System; -using System.Collections; using System.Collections.Generic; using System.IO; -using System.Text; +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, IDisposable + public class Histories : ObservableObject { public bool IsLoading { @@ -19,17 +18,66 @@ public bool 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(); } } @@ -39,22 +87,38 @@ 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 IDisposable DetailContext + public object DetailContext { get => _detailContext; - set => SetProperty(ref _detailContext, value); + set + { + if (SetProperty(ref _detailContext, value)) + OnPropertyChanged(nameof(IsOpenAsStandaloneVisible)); + } } public Models.Bisect Bisect @@ -63,6 +127,16 @@ public Models.Bisect Bisect private set => SetProperty(ref _bisect, value); } + public Models.Branch CurrentBranch + { + get => _repo.CurrentBranch; + } + + public AvaloniaList IssueTrackers + { + get => _repo.IssueTrackers; + } + public GridLength LeftArea { get => _leftArea; @@ -83,23 +157,47 @@ 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 double AuthorColumnWidth + { + get => _repo.UIStates.AuthorColumnWidth; + set => _repo.UIStates.AuthorColumnWidth = value; + } + + public bool IsOpenAsStandaloneVisible + { + get => DetailContext is CommitDetail or RevisionCompare; + } + + public bool IsCollapseDetails + { + get => _isCollapseDetails; + set + { + if (!Preferences.Instance.UseTwoColumnsLayoutInHistories && SetProperty(ref _isCollapseDetails, value)) + { + OnPropertyChanged(nameof(TopArea)); + OnPropertyChanged(nameof(BottomArea)); + } + } } public Histories(Repository repo) { _repo = repo; + _commitDetailSharedData = new CommitDetailSharedData(); } - public void Dispose() + public void NotifyCurrentBranchChanged() { - Commits = []; - _repo = null; - _graph = null; - _autoSelectedCommit = null; - _detailContext?.Dispose(); - _detailContext = null; + OnPropertyChanged(nameof(CurrentBranch)); } public Models.BisectState UpdateBisectInfo() @@ -111,104 +209,92 @@ public Models.BisectState UpdateBisectInfo() return Models.BisectState.None; } + var head = new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").GetResult(); var info = new Models.Bisect(); + var markedHead = false; var dir = Path.Combine(_repo.GitDir, "refs", "bisect"); if (Directory.Exists(dir)) { var files = new DirectoryInfo(dir).GetFiles(); foreach (var file in files) { + var sha = File.ReadAllText(file.FullName).Trim(); + if (!markedHead) + markedHead = head.Equals(sha, StringComparison.Ordinal); + if (file.Name.StartsWith("bad")) - info.Bads.Add(File.ReadAllText(file.FullName).Trim()); + info.Bads.Add(sha); else if (file.Name.StartsWith("good")) - info.Goods.Add(File.ReadAllText(file.FullName).Trim()); + info.Goods.Add(sha); + else if (file.Name.StartsWith("skip")) + info.Skipped.Add(sha); } } Bisect = info; - if (info.Bads.Count == 0 || info.Goods.Count == 0) - return Models.BisectState.WaitingForRange; - else - return Models.BisectState.Detecting; + if (info.Bads.Count == 0) + return Models.BisectState.WaitingForFirstBad; + + if (markedHead) + return Models.BisectState.WaitingForCheckoutAnother; + + if (info.Goods.Count == 0) + return Models.BisectState.WaitingForFirstGood; + + return Models.BisectState.WaitingForMark; } public void NavigateTo(string commitSHA) { var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); - if (commit == null) - { - AutoSelectedCommit = null; - commit = new Commands.QuerySingleCommit(_repo.FullPath, commitSHA).GetResultAsync().Result; - } - else + if (commit != null) { - AutoSelectedCommit = commit; - NavigationId = _navigationId + 1; + SelectedCommits = [commit]; + return; } - if (commit != null) + Task.Run(async () => { - if (_detailContext is CommitDetail detail) - { - detail.Commit = commit; - } - else + var c = await new Commands.QuerySingleCommit(_repo.FullPath, commitSHA) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => { - var commitDetail = new CommitDetail(_repo, true); - commitDetail.Commit = commit; - DetailContext = commitDetail; - } - } - else - { - DetailContext = null; - } - } + _ignoreSelectionChange = true; + SelectedCommits = []; - public void Select(IList commits) - { - if (commits.Count == 0) - { - _repo.SelectedSearchedCommit = null; - DetailContext = null; - } - else if (commits.Count == 1) - { - var commit = (commits[0] as Models.Commit)!; - if (_repo.SelectedSearchedCommit == null || _repo.SelectedSearchedCommit.SHA != commit.SHA) - _repo.SelectedSearchedCommit = _repo.SearchedCommits.Find(x => x.SHA == commit.SHA); + if (_detailContext is CommitDetail detail) + { + detail.Commit = c; + } + else + { + var commitDetail = new CommitDetail(_repo, _commitDetailSharedData); + commitDetail.Commit = c; + DetailContext = commitDetail; + } - AutoSelectedCommit = commit; - NavigationId = _navigationId + 1; + _ignoreSelectionChange = false; + }); + }); + } - if (_detailContext is CommitDetail detail) - { - detail.Commit = commit; - } - else - { - var commitDetail = new CommitDetail(_repo, true); - commitDetail.Commit = commit; - DetailContext = commitDetail; - } - } - else if (commits.Count == 2) - { - _repo.SelectedSearchedCommit = null; + public async Task GetCommitAsync(string sha) + { + return await new Commands.QuerySingleCommit(_repo.FullPath, sha) + .GetResultAsync() + .ConfigureAwait(false); + } - var end = commits[0] as Models.Commit; - var start = commits[1] as Models.Commit; - DetailContext = new RevisionCompare(_repo.FullPath, start, end); - } - else - { - _repo.SelectedSearchedCommit = null; - DetailContext = new Models.Count(commits.Count); - } + public void CheckoutCommitDetached(Models.Commit c) + { + if (!c.IsCurrentHead && _repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutDetached(_repo, c)); } - public bool CheckoutBranchByDecorator(Models.Decorator decorator) + public async Task CheckoutBranchByDecoratorAsync(Models.Decorator decorator) { if (decorator == null) return false; @@ -223,7 +309,7 @@ public bool CheckoutBranchByDecorator(Models.Decorator decorator) if (b == null) return false; - _repo.CheckoutBranch(b); + await _repo.CheckoutBranchAsync(b); return true; } @@ -234,19 +320,19 @@ public bool CheckoutBranchByDecorator(Models.Decorator decorator) return false; var lb = _repo.Branches.Find(x => x.IsLocal && x.Upstream == rb.FullName); - if (lb == null || lb.TrackStatus.Ahead.Count > 0) + if (lb == null || lb.Ahead.Count > 0) { if (_repo.CanCreatePopup()) _repo.ShowPopup(new CreateBranch(_repo, rb)); } - else if (lb.TrackStatus.Behind.Count > 0) + else if (lb.Behind.Count > 0) { if (_repo.CanCreatePopup()) _repo.ShowPopup(new CheckoutAndFastForward(_repo, lb, rb)); } else if (!lb.IsCurrent) { - _repo.CheckoutBranch(lb); + await _repo.CheckoutBranchAsync(lb); } return true; @@ -255,7 +341,7 @@ public bool CheckoutBranchByDecorator(Models.Decorator decorator) return false; } - public void CheckoutBranchByCommit(Models.Commit commit) + public async Task CheckoutBranchByCommitAsync(Models.Commit commit) { if (commit.IsCurrentHead) return; @@ -269,7 +355,7 @@ public void CheckoutBranchByCommit(Models.Commit commit) if (b == null) continue; - _repo.CheckoutBranch(b); + await _repo.CheckoutBranchAsync(b); return; } @@ -280,7 +366,7 @@ public void CheckoutBranchByCommit(Models.Commit commit) continue; var lb = _repo.Branches.Find(x => x.IsLocal && x.Upstream == rb.FullName); - if (lb is { TrackStatus.Ahead.Count: 0 }) + if (lb != null && lb.Behind.Count > 0 && lb.Ahead.Count == 0) { if (_repo.CanCreatePopup()) _repo.ShowPopup(new CheckoutAndFastForward(_repo, lb, rb)); @@ -296,944 +382,163 @@ public void CheckoutBranchByCommit(Models.Commit commit) if (firstRemoteBranch != null) _repo.ShowPopup(new CreateBranch(_repo, firstRemoteBranch)); else if (!_repo.IsBare) - _repo.ShowPopup(new CheckoutCommit(_repo, commit)); + _repo.ShowPopup(new CheckoutDetached(_repo, commit)); } } - public ContextMenu CreateContextMenuForSelectedCommits(List selected, Action onAddSelected) + public async Task CherryPickAsync(Models.Commit commit) { - var current = _repo.CurrentBranch; - if (current == null) - return null; - - if (selected.Count > 1) - { - var canCherryPick = true; - var canMerge = true; - - foreach (var c in selected) - { - if (c.IsMerged) - { - canMerge = false; - canCherryPick = false; - } - else if (c.Parents.Count > 1) - { - canCherryPick = false; - } - } - - var multipleMenu = new ContextMenu(); - - if (!_repo.IsBare) - { - if (canCherryPick) - { - var cherryPickMultiple = new MenuItem(); - cherryPickMultiple.Header = App.Text("CommitCM.CherryPickMultiple"); - cherryPickMultiple.Icon = App.CreateMenuIcon("Icons.CherryPick"); - cherryPickMultiple.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new CherryPick(_repo, selected)); - e.Handled = true; - }; - multipleMenu.Items.Add(cherryPickMultiple); - } - - if (canMerge) - { - var mergeMultiple = new MenuItem(); - mergeMultiple.Header = App.Text("CommitCM.MergeMultiple"); - mergeMultiple.Icon = App.CreateMenuIcon("Icons.Merge"); - mergeMultiple.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new MergeMultiple(_repo, selected)); - e.Handled = true; - }; - multipleMenu.Items.Add(mergeMultiple); - } - - if (canCherryPick || canMerge) - multipleMenu.Items.Add(new MenuItem() { Header = "-" }); - } - - var saveToPatchMultiple = new MenuItem(); - saveToPatchMultiple.Icon = App.CreateMenuIcon("Icons.Diff"); - saveToPatchMultiple.Header = App.Text("CommitCM.SaveAsPatch"); - saveToPatchMultiple.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FolderPickerOpenOptions() { AllowMultiple = false }; - CommandLog log = null; - try - { - var picker = await storageProvider.OpenFolderPickerAsync(options); - if (picker.Count == 1) - { - log = _repo.CreateLog("Save as Patch"); - - var folder = picker[0]; - var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString(); - var succ = false; - for (var i = 0; i < selected.Count; i++) - { - var saveTo = GetPatchFileName(folderPath, selected[i], i); - succ = await new Commands.FormatPatch(_repo.FullPath, selected[i].SHA, saveTo).Use(log).ExecAsync(); - if (!succ) - break; - } - - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - } - catch (Exception exception) - { - App.RaiseException(_repo.FullPath, $"Failed to save as patch: {exception.Message}"); - } - - log?.Complete(); - e.Handled = true; - }; - multipleMenu.Items.Add(saveToPatchMultiple); - multipleMenu.Items.Add(new MenuItem() { Header = "-" }); - - var copyMultipleSHAs = new MenuItem(); - copyMultipleSHAs.Header = App.Text("CommitCM.CopySHA"); - copyMultipleSHAs.Icon = App.CreateMenuIcon("Icons.Fingerprint"); - copyMultipleSHAs.Click += async (_, e) => - { - var builder = new StringBuilder(); - foreach (var c in selected) - builder.AppendLine(c.SHA); - - await App.CopyTextAsync(builder.ToString()); - e.Handled = true; - }; - - var copyMultipleInfo = new MenuItem(); - copyMultipleInfo.Header = App.Text("CommitCM.CopySHA") + " - " + App.Text("CommitCM.CopySubject"); - copyMultipleInfo.Icon = App.CreateMenuIcon("Icons.Info"); - copyMultipleInfo.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyMultipleInfo.Click += async (_, e) => - { - var builder = new StringBuilder(); - foreach (var c in selected) - builder.Append(c.SHA.AsSpan(0, 10)).Append(" - ").AppendLine(c.Subject); - - await App.CopyTextAsync(builder.ToString()); - e.Handled = true; - }; - - var copyMultiple = new MenuItem(); - copyMultiple.Header = App.Text("Copy"); - copyMultiple.Icon = App.CreateMenuIcon("Icons.Copy"); - copyMultiple.Items.Add(copyMultipleSHAs); - copyMultiple.Items.Add(copyMultipleInfo); - multipleMenu.Items.Add(copyMultiple); - - return multipleMenu; - } - - var commit = selected[0]; - var menu = new ContextMenu(); - var tags = new List(); - - if (commit.HasDecorators) - { - foreach (var d in commit.Decorators) - { - switch (d.Type) - { - case Models.DecoratorType.CurrentBranchHead: - FillCurrentBranchMenu(menu, current); - break; - case Models.DecoratorType.LocalBranchHead: - var lb = _repo.Branches.Find(x => x.IsLocal && d.Name == x.Name); - FillOtherLocalBranchMenu(menu, lb, current, commit.IsMerged); - break; - case Models.DecoratorType.RemoteBranchHead: - var rb = _repo.Branches.Find(x => !x.IsLocal && d.Name == x.FriendlyName); - FillRemoteBranchMenu(menu, rb, current, commit.IsMerged); - break; - case Models.DecoratorType.Tag: - var t = _repo.Tags.Find(x => x.Name == d.Name); - 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, tag, current, commit.IsMerged); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - if (!_repo.IsBare) + if (_repo.CanCreatePopup()) { - var target = commit.GetFriendlyName(); - - if (current.Head != commit.SHA) + if (commit.Parents.Count <= 1) { - var reset = new MenuItem(); - reset.Header = App.Text("CommitCM.Reset", current.Name, target); - reset.Icon = App.CreateMenuIcon("Icons.Reset"); - reset.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Reset(_repo, current, commit)); - e.Handled = true; - }; - menu.Items.Add(reset); - - if (commit.IsMerged) - { - var squash = new MenuItem(); - squash.Header = App.Text("CommitCM.SquashCommitsSinceThis", target); - squash.Icon = App.CreateMenuIcon("Icons.SquashIntoParent"); - squash.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Squash(_repo, commit, commit.SHA)); - - e.Handled = true; - }; - menu.Items.Add(squash); - } + _repo.ShowPopup(new CherryPick(_repo, [commit])); } 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 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) => + var parents = new List(); + foreach (var sha in commit.Parents) { - if (commit.Parents.Count == 1) - { - var parent = _commits.Find(x => x.SHA == commit.Parents[0]); - if (parent != null && _repo.CanCreatePopup()) - _repo.ShowPopup(new Squash(_repo, parent, commit.SHA)); - } - - e.Handled = true; - }; - menu.Items.Add(squash); - } + var parent = _commits.Find(x => x.SHA.Equals(sha, StringComparison.Ordinal)); + if (parent == null) + parent = await new Commands.QuerySingleCommit(_repo.FullPath, sha).GetResultAsync(); - if (!commit.IsMerged) - { - var rebase = new MenuItem(); - rebase.Header = App.Text("CommitCM.Rebase", current.Name, target); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Rebase(_repo, current, commit)); - e.Handled = true; - }; - menu.Items.Add(rebase); - - if (!commit.HasDecorators) - { - var merge = new MenuItem(); - merge.Header = App.Text("CommitCM.Merge", current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Merge(_repo, commit, current.Name)); - - e.Handled = true; - }; - menu.Items.Add(merge); + if (parent != null) + parents.Add(parent); } - var cherryPick = new MenuItem(); - cherryPick.Header = App.Text("CommitCM.CherryPick"); - cherryPick.Icon = App.CreateMenuIcon("Icons.CherryPick"); - cherryPick.Click += async (_, e) => - { - if (_repo.CanCreatePopup()) - { - if (commit.Parents.Count <= 1) - { - _repo.ShowPopup(new CherryPick(_repo, [commit])); - } - else - { - var parents = new List(); - foreach (var sha in commit.Parents) - { - var parent = _commits.Find(x => x.SHA == sha); - if (parent == null) - parent = await new Commands.QuerySingleCommit(_repo.FullPath, sha) - .GetResultAsync(); - - if (parent != null) - parents.Add(parent); - } - - _repo.ShowPopup(new CherryPick(_repo, commit, parents)); - } - } - - e.Handled = true; - }; - menu.Items.Add(cherryPick); - } - else - { - var revert = new MenuItem(); - revert.Header = App.Text("CommitCM.Revert"); - revert.Icon = App.CreateMenuIcon("Icons.Undo"); - revert.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Revert(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(revert); - } - - if (current.Head != commit.SHA) - { - var checkoutCommit = new MenuItem(); - checkoutCommit.Header = App.Text("CommitCM.Checkout"); - checkoutCommit.Icon = App.CreateMenuIcon("Icons.Detached"); - checkoutCommit.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new CheckoutCommit(_repo, commit)); - e.Handled = true; - }; - - var interactiveRebase = new MenuItem(); - interactiveRebase.Header = App.Text("CommitCM.InteractiveRebase", current.Name, target); - interactiveRebase.Icon = App.CreateMenuIcon("Icons.InteractiveRebase"); - interactiveRebase.Click += async (_, e) => - { - await App.ShowDialog(new InteractiveRebase(_repo, current, commit)); - e.Handled = true; - }; - - menu.Items.Add(checkoutCommit); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(interactiveRebase); - } - - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - if (current.Head != commit.SHA) - { - if (current.TrackStatus.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 = App.CreateMenuIcon("Icons.Push"); - pushRevision.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new PushRevision(_repo, commit, upstream)); - e.Handled = true; - }; - menu.Items.Add(pushRevision); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var compareWithHead = new MenuItem(); - compareWithHead.Header = App.Text("CommitCM.CompareWithHead"); - compareWithHead.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithHead.Click += async (_, e) => - { - var head = _commits.Find(x => x.SHA == current.Head); - if (head == null) - { - _repo.SelectedSearchedCommit = null; - head = await new Commands.QuerySingleCommit(_repo.FullPath, current.Head).GetResultAsync(); - if (head != null) - DetailContext = new RevisionCompare(_repo.FullPath, commit, head); - } - else - { - onAddSelected?.Invoke(head); - } - - e.Handled = true; - }; - menu.Items.Add(compareWithHead); - - if (_repo.LocalChangesCount > 0) - { - var compareWithWorktree = new MenuItem(); - compareWithWorktree.Header = App.Text("CommitCM.CompareWithWorktree"); - compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithWorktree.Click += (_, e) => - { - DetailContext = new RevisionCompare(_repo.FullPath, commit, null); - e.Handled = true; - }; - menu.Items.Add(compareWithWorktree); - } - - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+B" : "Ctrl+Shift+B"; - createBranch.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new CreateBranch(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(createBranch); - - var createTag = new MenuItem(); - createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); - createTag.Header = App.Text("CreateTag"); - createTag.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+T" : "Ctrl+Shift+T"; - createTag.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new CreateTag(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(createTag); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var saveToPatch = new MenuItem(); - saveToPatch.Icon = App.CreateMenuIcon("Icons.Diff"); - saveToPatch.Header = App.Text("CommitCM.SaveAsPatch"); - saveToPatch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FolderPickerOpenOptions() { AllowMultiple = false }; - CommandLog log = null; - try - { - var selected = await storageProvider.OpenFolderPickerAsync(options); - if (selected.Count == 1) - { - log = _repo.CreateLog("Save as Patch"); - - var folder = selected[0]; - var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString(); - var saveTo = GetPatchFileName(folderPath, commit); - var succ = await new Commands.FormatPatch(_repo.FullPath, commit.SHA, saveTo).Use(log).ExecAsync(); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - } - catch (Exception exception) - { - App.RaiseException(_repo.FullPath, $"Failed to save as patch: {exception.Message}"); - } - - log?.Complete(); - e.Handled = true; - }; - menu.Items.Add(saveToPatch); - - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Archive(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(archive); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var actions = _repo.GetCustomActions(Models.CustomActionScope.Commit); - if (actions.Count > 0) - { - var custom = new MenuItem(); - custom.Header = App.Text("CommitCM.CustomAction"); - custom.Icon = App.CreateMenuIcon("Icons.Action"); - - foreach (var action in actions) - { - var (dup, label) = action; - var item = new MenuItem(); - item.Icon = App.CreateMenuIcon("Icons.Action"); - item.Header = label; - item.Click += (_, e) => - { - _repo.ExecCustomAction(dup, commit); - e.Handled = true; - }; - - custom.Items.Add(item); + _repo.ShowPopup(new CherryPick(_repo, commit, parents)); } - - menu.Items.Add(custom); - menu.Items.Add(new MenuItem() { Header = "-" }); } - - var copySHA = new MenuItem(); - copySHA.Header = App.Text("CommitCM.CopySHA"); - copySHA.Icon = App.CreateMenuIcon("Icons.Fingerprint"); - copySHA.Click += async (_, e) => - { - await App.CopyTextAsync(commit.SHA); - e.Handled = true; - }; - - var copySubject = new MenuItem(); - copySubject.Header = App.Text("CommitCM.CopySubject"); - copySubject.Icon = App.CreateMenuIcon("Icons.Subject"); - copySubject.Click += async (_, e) => - { - await App.CopyTextAsync(commit.Subject); - e.Handled = true; - }; - - var copyInfo = new MenuItem(); - copyInfo.Header = App.Text("CommitCM.CopySHA") + " - " + App.Text("CommitCM.CopySubject"); - copyInfo.Icon = App.CreateMenuIcon("Icons.Info"); - copyInfo.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyInfo.Click += async (_, e) => - { - await App.CopyTextAsync($"{commit.SHA.AsSpan(0, 10)} - {commit.Subject}"); - e.Handled = true; - }; - - var copyMessage = new MenuItem(); - copyMessage.Header = App.Text("CommitCM.CopyCommitMessage"); - copyMessage.Icon = App.CreateMenuIcon("Icons.Info"); - copyMessage.Click += async (_, e) => - { - var message = await new Commands.QueryCommitFullMessage(_repo.FullPath, commit.SHA).GetResultAsync(); - await App.CopyTextAsync(message); - e.Handled = true; - }; - - var copyAuthor = new MenuItem(); - copyAuthor.Header = App.Text("CommitCM.CopyAuthor"); - copyAuthor.Icon = App.CreateMenuIcon("Icons.User"); - copyAuthor.Click += async (_, e) => - { - await App.CopyTextAsync(commit.Author.ToString()); - e.Handled = true; - }; - - var copyCommitter = new MenuItem(); - copyCommitter.Header = App.Text("CommitCM.CopyCommitter"); - copyCommitter.Icon = App.CreateMenuIcon("Icons.User"); - copyCommitter.Click += async (_, e) => - { - await App.CopyTextAsync(commit.Committer.ToString()); - e.Handled = true; - }; - - var copy = new MenuItem(); - copy.Header = App.Text("Copy"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Items.Add(copySHA); - copy.Items.Add(copySubject); - copy.Items.Add(copyInfo); - copy.Items.Add(copyMessage); - copy.Items.Add(copyAuthor); - copy.Items.Add(copyCommitter); - menu.Items.Add(copy); - - return menu; } - private void FillCurrentBranchMenu(ContextMenu menu, Models.Branch current) + public async Task GetCommitFullMessageAsync(Models.Commit commit) { - var submenu = new MenuItem(); - submenu.Icon = App.CreateMenuIcon("Icons.Branch"); - submenu.Header = current.Name; - - var visibility = new MenuItem(); - visibility.Classes.Add("filter_mode_switcher"); - visibility.Header = new FilterModeInGraph(_repo, current); - submenu.Items.Add(visibility); - submenu.Items.Add(new MenuItem() { Header = "-" }); + return await new Commands.QueryCommitFullMessage(_repo.FullPath, commit.SHA) + .GetResultAsync() + .ConfigureAwait(false); + } - if (!string.IsNullOrEmpty(current.Upstream)) + public async Task CompareWithHeadAsync(Models.Commit commit) + { + var head = _commits.Find(x => x.IsCurrentHead); + if (head == null) { - var upstream = current.Upstream.Substring(13); - - var fastForward = new MenuItem(); - fastForward.Header = App.Text("BranchCM.FastForward", upstream); - fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); - fastForward.IsEnabled = current.TrackStatus.Ahead.Count == 0; - fastForward.Click += (_, e) => - { - var b = _repo.Branches.Find(x => x.FriendlyName == upstream); - if (b == null) - return; - - if (_repo.CanCreatePopup()) - _repo.ShowAndStartPopup(new Merge(_repo, b, current.Name, true)); - - e.Handled = true; - }; - submenu.Items.Add(fastForward); + _repo.SearchCommitContext.Selected = null; + head = await new Commands.QuerySingleCommit(_repo.FullPath, "HEAD").GetResultAsync(); + if (head != null) + DetailContext = new RevisionCompare(_repo, commit, head); - var pull = new MenuItem(); - pull.Header = App.Text("BranchCM.Pull", upstream); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Pull(_repo, null)); - e.Handled = true; - }; - submenu.Items.Add(pull); - } - - var push = new MenuItem(); - push.Header = App.Text("BranchCM.Push", current.Name); - push.Icon = App.CreateMenuIcon("Icons.Push"); - push.IsEnabled = _repo.Remotes.Count > 0; - push.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Push(_repo, current)); - e.Handled = true; - }; - submenu.Items.Add(push); - - var rename = new MenuItem(); - rename.Header = App.Text("BranchCM.Rename", current.Name); - rename.Icon = App.CreateMenuIcon("Icons.Rename"); - rename.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new RenameBranch(_repo, current)); - e.Handled = true; - }; - submenu.Items.Add(rename); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - if (!_repo.IsBare) - { - var type = _repo.GetGitFlowType(current); - if (type != Models.GitFlowBranchType.None) - { - var finish = new MenuItem(); - finish.Header = App.Text("BranchCM.Finish", current.Name); - finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); - finish.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new GitFlowFinish(_repo, current, type)); - e.Handled = true; - }; - submenu.Items.Add(finish); - submenu.Items.Add(new MenuItem() { Header = "-" }); - } + return null; } - var copy = new MenuItem(); - copy.Header = App.Text("BranchCM.CopyName"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, e) => - { - await App.CopyTextAsync(current.Name); - e.Handled = true; - }; - submenu.Items.Add(copy); - - menu.Items.Add(submenu); + return head; } - private void FillOtherLocalBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) + public void CompareWithWorktree(Models.Commit commit) { - var submenu = new MenuItem(); - submenu.Icon = App.CreateMenuIcon("Icons.Branch"); - submenu.Header = branch.Name; + DetailContext = new RevisionCompare(_repo, commit, null); + } - var visibility = new MenuItem(); - visibility.Classes.Add("filter_mode_switcher"); - visibility.Header = new FilterModeInGraph(_repo, branch); - submenu.Items.Add(visibility); - submenu.Items.Add(new MenuItem() { Header = "-" }); + private void PostCommitsChanged() + { + if (_selectedCommits.Count == 0) + return; - if (!_repo.IsBare) + if (_commits.Count == 0 || _selectedCommits.Count > 20) { - var checkout = new MenuItem(); - checkout.Header = App.Text("BranchCM.Checkout", branch.Name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (_, e) => - { - _repo.CheckoutBranch(branch); - e.Handled = true; - }; - submenu.Items.Add(checkout); - - var merge = new MenuItem(); - merge.Header = App.Text("BranchCM.Merge", branch.Name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.IsEnabled = !merged; - merge.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Merge(_repo, branch, current.Name, false)); - e.Handled = true; - }; - submenu.Items.Add(merge); + SelectedCommits = []; + return; } - var rename = new MenuItem(); - rename.Header = App.Text("BranchCM.Rename", branch.Name); - rename.Icon = App.CreateMenuIcon("Icons.Rename"); - rename.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new RenameBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(rename); - - var delete = new MenuItem(); - delete.Header = App.Text("BranchCM.Delete", branch.Name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new DeleteBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(delete); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - if (!_repo.IsBare) + var set = new HashSet(); + foreach (var c in _selectedCommits) + set.Add(c.SHA); + + var selected = new List(); + foreach (var c in _commits) { - var type = _repo.GetGitFlowType(branch); - if (type != Models.GitFlowBranchType.None) + if (set.Contains(c.SHA)) { - var finish = new MenuItem(); - finish.Header = App.Text("BranchCM.Finish", branch.Name); - finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); - finish.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new GitFlowFinish(_repo, branch, type)); - e.Handled = true; - }; - submenu.Items.Add(finish); - submenu.Items.Add(new MenuItem() { Header = "-" }); + selected.Add(c); + set.Remove(c.SHA); + if (set.Count == 0) + break; } } - var copy = new MenuItem(); - copy.Header = App.Text("BranchCM.CopyName"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, e) => - { - await App.CopyTextAsync(branch.Name); - e.Handled = true; - }; - submenu.Items.Add(copy); - - menu.Items.Add(submenu); + SelectedCommits = selected; } - private void FillRemoteBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) + private void PostSelectedCommitsChanged() { - var name = branch.FriendlyName; - - var submenu = new MenuItem(); - submenu.Icon = App.CreateMenuIcon("Icons.Branch"); - submenu.Header = name; - - var visibility = new MenuItem(); - visibility.Classes.Add("filter_mode_switcher"); - visibility.Header = new FilterModeInGraph(_repo, branch); - submenu.Items.Add(visibility); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - var checkout = new MenuItem(); - checkout.Header = App.Text("BranchCM.Checkout", name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (_, e) => - { - _repo.CheckoutBranch(branch); - e.Handled = true; - }; - submenu.Items.Add(checkout); - - var merge = new MenuItem(); - merge.Header = App.Text("BranchCM.Merge", name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.IsEnabled = !merged; - merge.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Merge(_repo, branch, current.Name, false)); - e.Handled = true; - }; - - submenu.Items.Add(merge); + if (_ignoreSelectionChange) + return; - var delete = new MenuItem(); - delete.Header = App.Text("BranchCM.Delete", name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => + if (_selectedCommits.Count == 0) { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new DeleteBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(delete); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - var copy = new MenuItem(); - copy.Header = App.Text("BranchCM.CopyName"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, e) => + _repo.SearchCommitContext.Selected = null; + DetailContext = new Models.Null(); + } + else if (_selectedCommits.Count == 1) { - await App.CopyTextAsync(name); - e.Handled = true; - }; - submenu.Items.Add(copy); + 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 FillTagMenu(ContextMenu menu, Models.Tag tag, Models.Branch current, bool merged) - { - var submenu = new MenuItem(); - submenu.Header = tag.Name; - submenu.Icon = App.CreateMenuIcon("Icons.Tag"); - submenu.MinWidth = 200; - - var visibility = new MenuItem(); - visibility.Classes.Add("filter_mode_switcher"); - visibility.Header = new FilterModeInGraph(_repo, tag); - submenu.Items.Add(visibility); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - var push = new MenuItem(); - push.Header = App.Text("TagCM.Push", tag.Name); - push.Icon = App.CreateMenuIcon("Icons.Push"); - push.IsEnabled = _repo.Remotes.Count > 0; - push.Click += (_, e) => + if (_detailContext is CommitDetail detail) + detail.Commit = c; + else + DetailContext = new CommitDetail(_repo, _commitDetailSharedData) { Commit = c }; + } + else if (_selectedCommits.Count == 2) { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new PushTag(_repo, tag)); - e.Handled = true; - }; - submenu.Items.Add(push); + _repo.SearchCommitContext.Selected = null; - if (!_repo.IsBare && !merged) - { - var merge = new MenuItem(); - merge.Header = App.Text("TagCM.Merge", tag.Name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new Merge(_repo, tag, current.Name)); - e.Handled = true; - }; - submenu.Items.Add(merge); + if (_detailContext is RevisionCompare compare) + compare.SetTargets(_selectedCommits[1], _selectedCommits[0]); + else + DetailContext = new RevisionCompare(_repo, _selectedCommits[1], _selectedCommits[0]); } - - var delete = new MenuItem(); - delete.Header = App.Text("TagCM.Delete", tag.Name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new DeleteTag(_repo, tag)); - e.Handled = true; - }; - submenu.Items.Add(delete); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - var copy = new MenuItem(); - copy.Header = App.Text("TagCM.Copy"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, e) => + else { - await App.CopyTextAsync(tag.Name); - e.Handled = true; - }; - submenu.Items.Add(copy); + _repo.SearchCommitContext.Selected = null; + DetailContext = new Models.Count(_selectedCommits.Count); + } - menu.Items.Add(submenu); + if (_repo.UIStates.GraphHighlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) + GenerateGraph(_commits); } - private string GetPatchFileName(string dir, Models.Commit commit, int index = 0) + private void GenerateGraph(List commits, bool commitsChanged = false) { - var ignore_chars = new HashSet { '/', '\\', ':', ',', '*', '?', '\"', '<', '>', '|', '`', '$', '^', '%', '[', ']', '+', '-' }; - var builder = new StringBuilder(); - builder.Append(index.ToString("D4")); - builder.Append('-'); - - var chars = commit.Subject.ToCharArray(); - var len = 0; - foreach (var c in chars) - { - if (!ignore_chars.Contains(c)) - { - if (c == ' ' || c == '\t') - builder.Append('-'); - else - builder.Append(c); + var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); + var highlighting = _repo.UIStates.GraphHighlighting; + var extraHeads = new HashSet(); - len++; - - if (len >= 48) - break; - } + if (highlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) + { + foreach (var c in _selectedCommits) + extraHeads.Add(c.SHA); } - builder.Append(".patch"); - return Path.Combine(dir, builder.ToString()); + 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 IDisposable _detailContext = null; - + private List _selectedCommits = []; private Models.Bisect _bisect = null; - - private GridLength _leftArea = new GridLength(1, GridUnitType.Star); - private GridLength _rightArea = new GridLength(1, GridUnitType.Star); - private GridLength _topArea = new GridLength(1, GridUnitType.Star); - private GridLength _bottomArea = new GridLength(1, GridUnitType.Star); + 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 index 3095e4c6f..392615ab3 100644 --- a/src/ViewModels/ImageSource.cs +++ b/src/ViewModels/ImageSource.cs @@ -3,11 +3,13 @@ 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 { @@ -31,18 +33,30 @@ public static Models.ImageDecoder GetDecoder(string file) ".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); } @@ -52,7 +66,12 @@ public static async Task FromLFSObjectAsync(string repo, Models.LFS if (string.IsNullOrEmpty(lfs.Oid) || lfs.Size == 0) return new ImageSource(null, 0); - var stream = await Commands.QueryFileContent.FromLFSAsync(repo, lfs.Oid, lfs.Size).ConfigureAwait(false); + 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); } @@ -71,6 +90,8 @@ private static ImageSource LoadFromStream(Stream stream, Models.ImageDecoder dec return DecodeWithPfim(stream, size); case Models.ImageDecoder.Tiff: return DecodeWithTiff(stream, size); + case Models.ImageDecoder.StbImage: + return DecodeWithStbImage(stream, size); } } catch (Exception e) @@ -102,10 +123,30 @@ private static ImageSource DecodeWithPfim(Stream stream, long size) case ImageFormat.Rgb8: pixelFormat = PixelFormats.Gray8; break; + case ImageFormat.R16f: + pixelFormat = PixelFormats.Gray16; + break; + case ImageFormat.R32f: + pixelFormat = PixelFormats.Gray32Float; + break; case ImageFormat.R5g5b5: - case ImageFormat.R5g5b5a1: 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; @@ -125,20 +166,32 @@ private static ImageSource DecodeWithPfim(Stream stream, long size) data[i * 4 + 3] = (byte)Math.Round(((src >> 12) & 0x0F) / 15F * 255); // A } - alphaFormat = AlphaFormat.Premul; + alphaFormat = AlphaFormat.Unpremul; break; case ImageFormat.Rgba32: - alphaFormat = AlphaFormat.Premul; + alphaFormat = AlphaFormat.Unpremul; break; default: return new ImageSource(null, 0); } - var ptr = Marshal.UnsafeAddrOfPinnedArrayElement(data, 0); - var pixelSize = new PixelSize(pfiImage.Width, pfiImage.Height); - var dpi = new Vector(96, 96); - var bitmap = new Bitmap(pixelFormat, alphaFormat, ptr, pixelSize, dpi, stride); - return new ImageSource(bitmap, size); + 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(); + } } } @@ -149,19 +202,34 @@ private static ImageSource DecodeWithTiff(Stream stream, long size) 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]; - - // Currently only supports image when its `BITSPERSAMPLE` is one in [1,2,4,8,16] tiff.ReadRGBAImageOriented(width, height, pixels, Orientation.TOPLEFT); - var ptr = Marshal.UnsafeAddrOfPinnedArrayElement(pixels, 0); var pixelSize = new PixelSize(width, height); var dpi = new Vector(96, 96); - var bitmap = new Bitmap(PixelFormats.Rgba8888, AlphaFormat.Premul, ptr, pixelSize, dpi, width * 4); + 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 5104130ec..97699eeab 100644 --- a/src/ViewModels/InProgressContexts.cs +++ b/src/ViewModels/InProgressContexts.cs @@ -5,45 +5,39 @@ namespace SourceGit.ViewModels { public abstract class InProgressContext { - protected InProgressContext(string repo, string cmd) + public string Name { - _repo = repo; - _cmd = cmd; + get; + protected set; } - public Task AbortAsync() + public async Task ContinueAsync(CommandLog log) { - return new Commands.Command() - { - WorkingDirectory = _repo, - Context = _repo, - Args = $"{_cmd} --abort", - }.ExecAsync(); + if (_continueCmd != null) + await _continueCmd.Use(log).ExecAsync(); } - public virtual Task SkipAsync() + public async Task SkipAsync(CommandLog log) { - return new Commands.Command() - { - WorkingDirectory = _repo, - Context = _repo, - Args = $"{_cmd} --skip", - }.ExecAsync(); + if (_skipCmd != null) + await _skipCmd.Use(log).ExecAsync(); } - public virtual Task ContinueAsync() + public async Task AbortAsync(CommandLog log) { - return new Commands.Command() - { - WorkingDirectory = _repo, - Context = _repo, - Editor = Commands.Command.EditorType.None, - Args = $"{_cmd} --continue", - }.ExecAsync(); + if (_abortCmd != null) + await _abortCmd.Use(log).ExecAsync(); + + OnAborted(); } - protected string _repo = string.Empty; - protected string _cmd = string.Empty; + protected virtual void OnAborted() + { + } + + protected Commands.Command _continueCmd = null; + protected Commands.Command _skipCmd = null; + protected Commands.Command _abortCmd = null; } public class CherryPickInProgress : InProgressContext @@ -58,10 +52,34 @@ public string HeadName get; } - public CherryPickInProgress(Repository repo) : base(repo.FullPath, "cherry-pick") + 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).GetResultAsync().Result ?? new Models.Commit() { SHA = headSHA }; + Head = new Commands.QuerySingleCommit(repo.FullPath, headSHA).GetResult() ?? new Models.Commit() { SHA = headSHA }; HeadName = Head.GetFriendlyName(); } } @@ -88,8 +106,34 @@ public Models.Commit Onto get; } - public RebaseInProgress(Repository repo) : base(repo.FullPath, "rebase") + public RebaseInProgress(Repository repo) { + _gitDir = repo.GitDir; + Name = "Rebase"; + + _continueCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Editor = Commands.Command.EditorType.RebaseEditor, + Args = "-c core.commentChar=\"^\" -c core.commentString=\"±\" rebase --continue", + }; + + _skipCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "rebase --skip", + }; + + _abortCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "rebase --abort", + RaiseError = false, + }; + HeadName = File.ReadAllText(Path.Combine(repo.GitDir, "rebase-merge", "head-name")).Trim(); if (HeadName.StartsWith("refs/heads/")) HeadName = HeadName.Substring(11); @@ -99,26 +143,32 @@ public RebaseInProgress(Repository repo) : base(repo.FullPath, "rebase") 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).GetResultAsync().Result; + : new Commands.QueryRevisionByRefName(repo.FullPath, HeadName).GetResult(); if (!string.IsNullOrEmpty(stoppedSHA)) - StoppedAt = new Commands.QuerySingleCommit(repo.FullPath, stoppedSHA).GetResultAsync().Result ?? new Models.Commit() { SHA = 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).GetResultAsync().Result ?? new Models.Commit() { SHA = ontoSHA }; + Onto = new Commands.QuerySingleCommit(repo.FullPath, ontoSHA).GetResult() ?? new Models.Commit() { SHA = ontoSHA }; BaseName = Onto.GetFriendlyName(); } - public override Task ContinueAsync() + protected override void OnAborted() { - return new Commands.Command() - { - WorkingDirectory = _repo, - Context = _repo, - Editor = Commands.Command.EditorType.RebaseEditor, - Args = "rebase --continue", - }.ExecAsync(); + 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 @@ -128,10 +178,34 @@ public Models.Commit Head get; } - public RevertInProgress(Repository repo) : base(repo.FullPath, "revert") + 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).GetResultAsync().Result ?? new Models.Commit() { SHA = headSHA }; + Head = new Commands.QuerySingleCommit(repo.FullPath, headSHA).GetResult() ?? new Models.Commit() { SHA = headSHA }; } } @@ -152,18 +226,30 @@ public string SourceName get; } - public MergeInProgress(Repository repo) : base(repo.FullPath, "merge") + public MergeInProgress(Repository repo) { - Current = new Commands.QueryCurrentBranch(repo.FullPath).GetResultAsync().Result; + 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).GetResultAsync().Result ?? new Models.Commit() { SHA = sourceSHA }; + Source = new Commands.QuerySingleCommit(repo.FullPath, sourceSHA).GetResult() ?? new Models.Commit() { SHA = sourceSHA }; SourceName = Source.GetFriendlyName(); } - - public override Task SkipAsync() - { - return Task.FromResult(true); - } } } diff --git a/src/ViewModels/Init.cs b/src/ViewModels/Init.cs index 7f349917f..882d3d647 100644 --- a/src/ViewModels/Init.cs +++ b/src/ViewModels/Init.cs @@ -16,12 +16,15 @@ public string Reason private set; } - public Init(string pageId, string path, RepositoryNode parent, string reason) + public Init(string pageId, string path, RepositoryNode parent, int bookmark, string reason) { _pageId = pageId; _targetPath = path; _parentNode = parent; - Reason = string.IsNullOrEmpty(reason) ? "Invalid repository detected!" : reason; + _bookmark = bookmark; + + Reason = string.IsNullOrEmpty(reason) ? "unknown error" : reason; + Reason = Reason.Trim(); } public override async Task Sure() @@ -39,7 +42,10 @@ public override async Task Sure() if (succ) { - Preferences.Instance.FindOrAddNodeByRepositoryPath(_targetPath, _parentNode, true); + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(_targetPath, _parentNode, true); + node.Bookmark = _bookmark; + await node.UpdateStatusAsync(false, null); + Welcome.Instance.Refresh(); } return succ; @@ -48,5 +54,6 @@ public override async Task Sure() private readonly string _pageId = null; private string _targetPath = null; private readonly RepositoryNode _parentNode = null; + private readonly int _bookmark = 0; } } diff --git a/src/ViewModels/InitGitFlow.cs b/src/ViewModels/InitGitFlow.cs index d433c8f81..f00ad01dd 100644 --- a/src/ViewModels/InitGitFlow.cs +++ b/src/ViewModels/InitGitFlow.cs @@ -11,16 +11,16 @@ public partial class InitGitFlow : Popup [GeneratedRegex(@"^[\w\-/\.]+$")] private static partial Regex REG_TAG_PREFIX(); - [Required(ErrorMessage = "Master branch name is required!!!")] + [Required(ErrorMessage = "Production branch name is required!!!")] [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] [CustomValidation(typeof(InitGitFlow), nameof(ValidateBaseBranch))] - public string Master + 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 @@ -72,21 +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"; + _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; @@ -102,7 +102,7 @@ public static ValidationResult ValidateTagPrefix(string tagPrefix, ValidationCon public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Init git-flow ..."; var log = _repo.CreateLog("Gitflow - Init"); @@ -111,16 +111,15 @@ public override async Task Sure() bool succ; var current = _repo.CurrentBranch; - var masterBranch = _repo.Branches.Find(x => x.IsLocal && x.Name.Equals(_master, StringComparison.Ordinal)); - if (masterBranch == null) + var productionBranch = _repo.Branches.Find(x => x.IsLocal && x.Name.Equals(_production, StringComparison.Ordinal)); + if (productionBranch == null) { - succ = await new Commands.Branch(_repo.FullPath, _master) + succ = await new Commands.Branch(_repo.FullPath, _production) .Use(log) .CreateAsync(current.Head, true); if (!succ) { log.Complete(); - _repo.SetWatcherEnabled(true); return false; } } @@ -134,40 +133,32 @@ public override async Task Sure() if (!succ) { log.Complete(); - _repo.SetWatcherEnabled(true); return false; } } - succ = await Commands.GitFlow.InitAsync( - _repo.FullPath, - _master, - _develop, - _featurePrefix, - _releasePrefix, - _hotfixPrefix, - _tagPrefix, - log); + 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.Master = _master; - gitflow.Develop = _develop; + gitflow.ProductionBranch = _production; + gitflow.DevelopmentBranch = _develop; gitflow.FeaturePrefix = _featurePrefix; gitflow.ReleasePrefix = _releasePrefix; gitflow.HotfixPrefix = _hotfixPrefix; _repo.GitFlow = gitflow; } - _repo.SetWatcherEnabled(true); return succ; } private readonly Repository _repo; - private string _master; + private string _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 78d806008..a8f013fe4 100644 --- a/src/ViewModels/InteractiveRebase.cs +++ b/src/ViewModels/InteractiveRebase.cs @@ -1,8 +1,10 @@ -using System.Collections.Generic; +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; @@ -10,25 +12,19 @@ namespace SourceGit.ViewModels { + public record InteractiveRebasePrefill(string SHA, Models.InteractiveRebaseAction Action); + public record InteractiveRebaseReorderItem(string Key, InteractiveRebaseItem Item); + public class InteractiveRebaseItem : ObservableObject { - public Models.Commit Commit + public int OriginalOrder { get; - private set; } - public bool CanSquashOrFixup + public Models.Commit Commit { - get => _canSquashOrFixup; - set - { - if (SetProperty(ref _canSquashOrFixup, value)) - { - if (_action == Models.InteractiveRebaseAction.Squash || _action == Models.InteractiveRebaseAction.Fixup) - Action = Models.InteractiveRebaseAction.Pick; - } - } + get; } public Models.InteractiveRebaseAction Action @@ -37,6 +33,12 @@ public Models.InteractiveRebaseAction Action set => SetProperty(ref _action, value); } + public Models.InteractiveRebasePendingType PendingType + { + get => _pendingType; + set => SetProperty(ref _pendingType, value); + } + public string Subject { get => _subject; @@ -57,17 +59,51 @@ public string FullMessage } } - public InteractiveRebaseItem(Models.Commit c, string message, bool canSquashOrFixup) + public string OriginalFullMessage { + get; + set; + } + + public bool CanSquashOrFixup + { + get => _canSquashOrFixup; + set => SetProperty(ref _canSquashOrFixup, value); + } + + public bool ShowEditMessageButton + { + get => _showEditMessageButton; + set => SetProperty(ref _showEditMessageButton, value); + } + + public Thickness DropDirectionIndicator + { + get => _dropDirectionIndicator; + set => SetProperty(ref _dropDirectionIndicator, value); + } + + public bool IsMessageUserEdited + { + get; + set; + } = false; + + public InteractiveRebaseItem(int order, Models.Commit c, string message) + { + OriginalOrder = order; Commit = c; FullMessage = message; - CanSquashOrFixup = canSquashOrFixup; + 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 @@ -81,7 +117,6 @@ public Models.Branch Current public Models.Commit On { get; - private set; } public bool AutoStash @@ -90,9 +125,20 @@ public bool AutoStash set; } = true; - public AvaloniaList IssueTrackerRules + public bool NoVerify + { + get; + set; + } + + public AvaloniaList IssueTrackers { - get => _repo.Settings.IssueTrackerRules; + get => _repo.IssueTrackers; + } + + public string ConventionalTypesOverride + { + get => _repo.Settings.ConventionalTypesOverride; } public bool IsLoading @@ -104,126 +150,251 @@ public bool IsLoading public AvaloniaList Items { get; - private set; } = []; - public InteractiveRebaseItem SelectedItem + public InteractiveRebaseItem PreSelected { - get => _selectedItem; - set - { - if (SetProperty(ref _selectedItem, value)) - DetailContext.Commit = value?.Commit; - } + 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, false); Task.Run(async () => { - var commits = await new Commands.QueryCommitsForInteractiveRebase(repoPath, on.SHA) + 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]; - list.Add(new InteractiveRebaseItem(c.Commit, c.Message, i < commits.Count - 1)); + var item = new InteractiveRebaseItem(commits.Count - i, c.Commit, c.Message); + var subject = c.Commit.Subject; + + if (item.OriginalOrder > 1) + { + if (subject.StartsWith("fixup! ", StringComparison.Ordinal)) + { + item.Action = Models.InteractiveRebaseAction.Fixup; + + var targetSubject = subject.Substring(7).Trim(); + needReorder.Add(new(targetSubject, item)); + needReorderKeySet.Add(targetSubject); + continue; + } + + if (subject.StartsWith("squash! ", StringComparison.Ordinal)) + { + item.Action = Models.InteractiveRebaseAction.Squash; + + var targetSubject = subject.Substring(8).Trim(); + needReorder.Add(new(targetSubject, item)); + needReorderKeySet.Add(targetSubject); + continue; + } + } + + list.Add(item); + } + + // Second-pass to find the place for reordered commits and insert them. + for (var i = list.Count - 1; i >= 0; i--) + { + var subject = list[i].Commit.Subject.Trim(); + if (!needReorderKeySet.Contains(subject)) + continue; + + for (var j = needReorder.Count - 1; j >= 0; j--) + { + var test = needReorder[j]; + if (subject.Equals(test.Key, StringComparison.Ordinal)) + { + list.Insert(i, test.Item); + needReorder.RemoveAt(j); + } + } + + needReorderKeySet.Remove(subject); + if (needReorderKeySet.Count == 0) + break; + } + + // Last-pass to insert the remaining commits that are marked as fixup!/squash! but their target commit is not found. + foreach (var v in needReorder) + { + // For safety, reset to pick if the target commit is not found + v.Item.Action = Models.InteractiveRebaseAction.Pick; + + // Find out the first picked (exclude reordered) commit that should be picked after this commit + var insertPos = 0; + for (var i = 0; i < list.Count; i++) + { + var item = list[i]; + if (item.Action == Models.InteractiveRebaseAction.Pick) + { + if (v.Item.OriginalOrder < item.OriginalOrder) + insertPos = i + 1; + else + break; + } + } + + list.Insert(insertPos, v.Item); + } + + var selected = list.Count > 0 ? list[0] : null; + if (prefill != null) + { + var item = list.Find(x => x.Commit.SHA.Equals(prefill.SHA, StringComparison.Ordinal)); + if (item != null) + { + item.Action = prefill.Action; + selected = item; + } } Dispatcher.UIThread.Post(() => { Items.AddRange(list); - if (list.Count > 0) - SelectedItem = list[0]; + 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; - UpdateItems(); + 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) { - var next = Items[idx + 1]; - Items.RemoveAt(idx + 1); - Items.Insert(idx, next); - SelectedItem = item; - UpdateItems(); + foreach (var item in selected) + { + if (item.CanSquashOrFixup) + item.Action = action; + } + } + else + { + foreach (var item in selected) + item.Action = action; } + + UpdateItems(); } - public void ChangeAction(InteractiveRebaseItem item, Models.InteractiveRebaseAction action) + public void Move(List commits, int index) { - if (!item.CanSquashOrFixup) + var hashes = new HashSet(); + foreach (var c in commits) + hashes.Add(c.Commit.SHA); + + var before = new List(); + var ordered = new List(); + var after = new List(); + + for (int i = 0; i < index; i++) + { + var item = Items[i]; + if (!hashes.Contains(item.Commit.SHA)) + before.Add(item); + else + ordered.Add(item); + } + + for (int i = index; i < Items.Count; i++) { - if (action == Models.InteractiveRebaseAction.Squash || action == Models.InteractiveRebaseAction.Fixup) - return; + var item = Items[i]; + if (!hashes.Contains(item.Commit.SHA)) + after.Add(item); + else + ordered.Add(item); } - item.Action = action; + Items.Clear(); + Items.AddRange(before); + Items.AddRange(ordered); + Items.AddRange(after); UpdateItems(); } public async Task Start() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); - var saveFile = Path.Combine(_repo.GitDir, "sourcegit_rebase_jobs.json"); + 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; } + await using (var stream = File.Create(saveFile)) { await JsonSerializer.SerializeAsync(stream, collection, JsonCodeGen.Default.InteractiveRebaseJobCollection); } var log = _repo.CreateLog("Interactive Rebase"); - var succ = await new Commands.InteractiveRebase(_repo.FullPath, On.SHA, AutoStash) + var succ = await new Commands.InteractiveRebase(_repo.FullPath, On.SHA, AutoStash, NoVerify) .Use(log) .ExecAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } @@ -243,13 +414,127 @@ private void UpdateItems() 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 6f8dc7762..4a869abb4 100644 --- a/src/ViewModels/LFSFetch.cs +++ b/src/ViewModels/LFSFetch.cs @@ -21,7 +21,7 @@ public LFSFetch(Repository repo) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Fetching LFS objects from remote ..."; var log = _repo.CreateLog("LFS Fetch"); @@ -32,7 +32,6 @@ public override async Task Sure() .FetchAsync(SelectedRemote.Name); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/LFSLocks.cs b/src/ViewModels/LFSLocks.cs index 0125d7f61..d048398eb 100644 --- a/src/ViewModels/LFSLocks.cs +++ b/src/ViewModels/LFSLocks.cs @@ -10,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 { @@ -62,7 +62,7 @@ public async Task UnlockAsync(Models.LFSLock lfsLock, bool force) IsLoading = true; - var succ = await _repo.UnlockLFSFileAsync(_remote, lfsLock.File, force, false); + var succ = await _repo.UnlockLFSFileAsync(_remote, lfsLock.Path, force, false); if (succ) { _cachedLocks.Remove(lfsLock); @@ -72,6 +72,35 @@ public async Task UnlockAsync(Models.LFSLock lfsLock, bool force) IsLoading = false; } + public async Task UnlockAllMyLocksAsync() + { + if (_isLoading || string.IsNullOrEmpty(_userName)) + return; + + var locks = new List(); + foreach (var lfsLock in _cachedLocks) + { + if (lfsLock.Owner.Name.Equals(_userName, StringComparison.Ordinal)) + locks.Add(lfsLock.Path); + } + + if (locks.Count == 0) + return; + + IsLoading = true; + + var log = _repo.CreateLog("Unlock LFS Locks"); + var succ = await new Commands.LFS(_repo.FullPath).Use(log).UnlockMultipleAsync(_remote, locks, true); + if (succ) + { + _cachedLocks.RemoveAll(lfsLock => lfsLock.Owner.Name.Equals(_userName, StringComparison.Ordinal)); + UpdateVisibleLocks(); + } + + log.Complete(); + IsLoading = false; + } + private void UpdateVisibleLocks() { var visible = new List(); @@ -84,7 +113,7 @@ private void UpdateVisibleLocks() { foreach (var lfsLock in _cachedLocks) { - if (lfsLock.User.Equals(_userName, StringComparison.Ordinal)) + if (lfsLock.Owner.Name.Equals(_userName, StringComparison.Ordinal)) visible.Add(lfsLock); } } @@ -99,5 +128,6 @@ private void UpdateVisibleLocks() 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 fb9c7fe28..1353bc0d1 100644 --- a/src/ViewModels/LFSPrune.cs +++ b/src/ViewModels/LFSPrune.cs @@ -11,7 +11,7 @@ public LFSPrune(Repository repo) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "LFS prune ..."; var log = _repo.CreateLog("LFS Prune"); @@ -22,7 +22,6 @@ public override async Task Sure() .PruneAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/LFSPull.cs b/src/ViewModels/LFSPull.cs index f4ae697f0..8b4b1081e 100644 --- a/src/ViewModels/LFSPull.cs +++ b/src/ViewModels/LFSPull.cs @@ -21,7 +21,7 @@ public LFSPull(Repository repo) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Pull LFS objects from remote ..."; var log = _repo.CreateLog("LFS Pull"); @@ -32,7 +32,6 @@ public override async Task Sure() .PullAsync(SelectedRemote.Name); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/LFSPush.cs b/src/ViewModels/LFSPush.cs index 11c17c1ec..e5c28783f 100644 --- a/src/ViewModels/LFSPush.cs +++ b/src/ViewModels/LFSPush.cs @@ -21,7 +21,7 @@ public LFSPush(Repository repo) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Push LFS objects to remote ..."; var log = _repo.CreateLog("LFS Push"); @@ -32,7 +32,6 @@ public override async Task Sure() .PushAsync(SelectedRemote.Name); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/LFSTrackCustomPattern.cs b/src/ViewModels/LFSTrackCustomPattern.cs index 7d66e0f85..d9a98a24d 100644 --- a/src/ViewModels/LFSTrackCustomPattern.cs +++ b/src/ViewModels/LFSTrackCustomPattern.cs @@ -25,7 +25,7 @@ public LFSTrackCustomPattern(Repository repo) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Adding custom LFS tracking pattern ..."; var log = _repo.CreateLog("LFS Add Custom Pattern"); @@ -36,7 +36,6 @@ public override async Task Sure() .TrackAsync(_pattern, IsFilename); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs index b079ccf61..7b17290a7 100644 --- a/src/ViewModels/Launcher.cs +++ b/src/ViewModels/Launcher.cs @@ -1,8 +1,8 @@ using System; using System.IO; +using System.Text; using Avalonia.Collections; -using Avalonia.Controls; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -35,90 +35,84 @@ public LauncherPage ActivePage set { if (SetProperty(ref _activePage, value)) - { - UpdateTitle(); - - if (!_ignoreIndexChange && value is { Data: Repository repo }) - _activeWorkspace.ActiveIdx = _activeWorkspace.Repositories.IndexOf(repo.FullPath); - } + PostActivePageChanged(); } } - public IDisposable Switcher + public ICommandPalette CommandPalette + { + get => _commandPalette; + set => SetProperty(ref _commandPalette, value); + } + + public Models.Version NewVersion { - get => _switcher; - private set => SetProperty(ref _switcher, value); + 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 = Preferences.Instance; - if (string.IsNullOrEmpty(startupRepo)) - { - ActiveWorkspace = pref.GetActiveWorkspace(); - - var repos = ActiveWorkspace.Repositories.ToArray(); - foreach (var repo in repos) - { - var node = pref.FindNode(repo) ?? - 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).GetResultAsync().Result; - 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 node = pref.FindOrAddNodeByRepositoryPath(test.StdOut.Trim(), 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(); - _ignoreIndexChange = false; + ActivePage.Popup = new Init(ActivePage.Node.Id, repo, null, 0, test.StdErr ?? "Unknown error occurred while opening the repository."); + return true; + } + } - if (string.IsNullOrEmpty(_title)) - UpdateTitle(); + return false; } - public void Quit() + public void CloseAll() { _ignoreIndexChange = true; @@ -128,36 +122,11 @@ public void Quit() _ignoreIndexChange = false; } - public void OpenWorkspaceSwitcher() - { - Switcher = new WorkspaceSwitcher(this); - } - - public void OpenTabSwitcher() - { - Switcher = new LauncherPageSwitcher(this); - } - - public void CancelSwitcher() - { - Switcher?.Dispose(); - Switcher = null; - } - public void SwitchWorkspace(Workspace to) { if (to == null || to.IsActive) return; - foreach (var one in Pages) - { - if (!one.CanCreatePopup() || one.Data is Repository { IsAutoFetching: true }) - { - App.RaiseException(null, "You have unfinished task(s) in opened pages. Please wait!!!"); - return; - } - } - _ignoreIndexChange = true; var pref = Preferences.Instance; @@ -175,31 +144,16 @@ public void SwitchWorkspace(Workspace to) var repos = to.Repositories.ToArray(); foreach (var repo in repos) - { - var node = pref.FindNode(repo) ?? - new RepositoryNode - { - Id = repo, - Name = Path.GetFileName(repo), - Bookmark = 0, - IsRepository = true, - }; - - OpenRepositoryInTab(node, null); - } + OpenRepositoryInTab(repo, null); var activeIdx = to.ActiveIdx; if (activeIdx >= 0 && activeIdx < Pages.Count) - { ActivePage = Pages[activeIdx]; - } else - { ActivePage = Pages[0]; - to.ActiveIdx = 0; - } _ignoreIndexChange = false; + PostActivePageChanged(); Preferences.Instance.Save(); GC.Collect(); } @@ -218,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() @@ -258,17 +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; - UpdateTitle(); + PostActivePageChanged(); GC.Collect(); } else @@ -306,9 +262,9 @@ public void CloseOtherTabs() } Pages = new AvaloniaList { ActivePage }; - ActiveWorkspace.ActiveIdx = 0; OnPropertyChanged(nameof(Pages)); + _activeWorkspace.ActiveIdx = 0; _ignoreIndexChange = false; GC.Collect(); } @@ -328,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) @@ -339,33 +310,46 @@ public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page) } } - if (!Path.Exists(node.Id)) + if (!Directory.Exists(node.Id)) { - App.RaiseException(node.Id, "Repository does NOT exist 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 isBare = new Commands.IsBareRepository(node.Id).GetResultAsync().Result; + var isBare = new Commands.IsBareRepository(node.Id).GetResult(); var gitDir = isBare ? node.Id : GetRepositoryGitDir(node.Id); if (string.IsNullOrEmpty(gitDir)) { - App.RaiseException(node.Id, "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; } + 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; } @@ -376,40 +360,37 @@ public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page) page.Data = repo; } - if (page != _activePage) - ActivePage = page; - else - UpdateTitle(); - - 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) { if (!Dispatcher.UIThread.CheckAccess()) { - Dispatcher.UIThread.Invoke(() => DispatchNotification(pageId, message, isError)); + Dispatcher.UIThread.Invoke(() => DispatchNotification(notification)); return; } - var notification = new Models.Notification() + if (string.IsNullOrEmpty(notification.Group)) { - IsError = isError, - Message = message, - }; + _activePage?.Notifications.Add(notification); + return; + } foreach (var page in Pages) { var id = page.Node.Id.Replace('\\', '/').TrimEnd('/'); - if (id == pageId) + if (id.Equals(notification.Group, StringComparison.OrdinalIgnoreCase)) { page.Notifications.Add(notification); return; @@ -419,121 +400,6 @@ public void DispatchNotification(string pageId, string message, bool isError) _activePage?.Notifications.Add(notification); } - public ContextMenu CreateContextForWorkspace() - { - var pref = Preferences.Instance; - var menu = new ContextMenu(); - - for (var i = 0; i < pref.Workspaces.Count; i++) - { - var workspace = pref.Workspaces[i]; - - var icon = App.CreateMenuIcon(workspace.IsActive ? "Icons.Check" : "Icons.Workspace"); - icon.Fill = workspace.Brush; - - var item = new MenuItem(); - item.Header = workspace.Name; - item.Icon = icon; - item.Click += (_, e) => - { - if (!workspace.IsActive) - SwitchWorkspace(workspace); - - e.Handled = true; - }; - - menu.Items.Add(item); - } - - menu.Items.Add(new MenuItem() { Header = "-" }); - - var configure = new MenuItem(); - configure.Header = App.Text("Workspace.Configure"); - configure.Click += async (_, e) => - { - await App.ShowDialog(new ConfigureWorkspace()); - e.Handled = true; - }; - menu.Items.Add(configure); - - return menu; - } - - public ContextMenu CreateContextForPageTab(LauncherPage page) - { - if (page == null) - return null; - - var menu = new ContextMenu(); - var close = new MenuItem(); - close.Header = App.Text("PageTabBar.Tab.Close"); - close.Tag = 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 += async (_, e) => - { - await page.CopyPathAsync(); - e.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copyPath); - } - - return menu; - } - private string GetRepositoryGitDir(string repo) { var fullpath = Path.Combine(repo, ".git"); @@ -562,7 +428,7 @@ private string GetRepositoryGitDir(string repo) return null; } - return new Commands.QueryGitDir(repo).GetResultAsync().Result; + return new Commands.QueryGitDir(repo).GetResult(); } private void CloseRepositoryInTab(LauncherPage page, bool removeFromWorkspace = true) @@ -570,46 +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 void UpdateTitle() + private void PostActivePageChanged() { - if (_activeWorkspace == null) + if (_ignoreIndexChange) return; - var workspace = _activeWorkspace.Name; - if (_activePage is { Data: Repository }) - { - var node = _activePage.Node; - var name = node.Name; - var path = node.Id; + if (_activePage is { Data: Repository repo }) + _activeWorkspace.ActiveIdx = _activeWorkspace.Repositories.IndexOf(repo.FullPath); - if (!OperatingSystem.IsWindows()) - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; - if (path.StartsWith(home, StringComparison.Ordinal)) - path = $"~{path.AsSpan(prefixLen)}"; - } + var builder = new StringBuilder(512); + builder.Append(string.IsNullOrEmpty(_activePage.Node.Name) ? "Repositories" : _activePage.Node.Name); - Title = $"[{workspace}] {name} ({path})"; - } - else - { - Title = $"[{workspace}] Repositories"; - } + 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 = null; - private LauncherPage _activePage = null; - private bool _ignoreIndexChange = false; + private Workspace _activeWorkspace; + private LauncherPage _activePage; + private bool _ignoreIndexChange; private string _title = string.Empty; - private IDisposable _switcher = null; + private ICommandPalette _commandPalette; + private Models.Version _newVersion = null; } } diff --git a/src/ViewModels/LauncherPage.cs b/src/ViewModels/LauncherPage.cs index 0b4b3d8ad..db5e0241b 100644 --- a/src/ViewModels/LauncherPage.cs +++ b/src/ViewModels/LauncherPage.cs @@ -1,7 +1,6 @@ using System; using System.Threading.Tasks; using Avalonia.Collections; -using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -20,10 +19,10 @@ public object Data set => SetProperty(ref _data, value); } - public IBrush DirtyBrush + public Models.DirtyState DirtyState { - get => _dirtyBrush; - private set => SetProperty(ref _dirtyBrush, value); + get => _dirtyState; + private set => SetProperty(ref _dirtyState, value); } public Popup Popup @@ -58,30 +57,20 @@ public void ClearNotifications() Notifications.Clear(); } - public async Task CopyPathAsync() - { - if (_node.IsRepository) - await App.CopyTextAsync(_node.Id); - } - public void ChangeDirtyState(Models.DirtyState flag, bool remove) { + var state = _dirtyState; if (remove) { - if (_dirtyState.HasFlag(flag)) - _dirtyState -= flag; + if (state.HasFlag(flag)) + state -= flag; } else { - _dirtyState |= flag; + state |= flag; } - if (_dirtyState.HasFlag(Models.DirtyState.HasLocalChanges)) - DirtyBrush = Brushes.Gray; - else if (_dirtyState.HasFlag(Models.DirtyState.HasPendingPullOrPush)) - DirtyBrush = Brushes.RoyalBlue; - else - DirtyBrush = null; + DirtyState = state; } public bool CanCreatePopup() @@ -89,15 +78,7 @@ public bool CanCreatePopup() return _popup is not { InProgress: true }; } - public void StartPopup(Popup popup) - { - Popup = popup; - - if (popup.CanStartDirectly()) - ProcessPopup(); - } - - public async void ProcessPopup() + public async Task ProcessPopupAsync() { if (_popup is { InProgress: false } dump) { @@ -110,11 +91,14 @@ public async void ProcessPopup() { var finished = await dump.Sure(); if (finished) + { + dump.Cleanup(); Popup = null; + } } catch (Exception e) { - App.LogException(e); + Native.OS.LogException(e); } dump.InProgress = false; @@ -123,16 +107,15 @@ public async void ProcessPopup() public void CancelPopup() { - if (_popup == null) - return; - if (_popup.InProgress) + if (_popup == null || _popup.InProgress) return; + + _popup?.Cleanup(); Popup = null; } private RepositoryNode _node = null; private object _data = null; - private IBrush _dirtyBrush = null; private Models.DirtyState _dirtyState = Models.DirtyState.None; private Popup _popup = null; } diff --git a/src/ViewModels/LauncherPageSwitcher.cs b/src/ViewModels/LauncherPageSwitcher.cs deleted file mode 100644 index 5f53021d6..000000000 --- a/src/ViewModels/LauncherPageSwitcher.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; -using CommunityToolkit.Mvvm.ComponentModel; - -namespace SourceGit.ViewModels -{ - public class LauncherPageSwitcher : ObservableObject, IDisposable - { - public List VisiblePages - { - get => _visiblePages; - private set => SetProperty(ref _visiblePages, value); - } - - public string SearchFilter - { - get => _searchFilter; - set - { - if (SetProperty(ref _searchFilter, value)) - UpdateVisiblePages(); - } - } - - public LauncherPage SelectedPage - { - get => _selectedPage; - set => SetProperty(ref _selectedPage, value); - } - - public LauncherPageSwitcher(Launcher launcher) - { - _launcher = launcher; - UpdateVisiblePages(); - } - - public void ClearFilter() - { - SearchFilter = string.Empty; - } - - public void Switch() - { - _launcher.ActivePage = _selectedPage ?? _launcher.ActivePage; - _launcher.CancelSwitcher(); - } - - public void Dispose() - { - _visiblePages.Clear(); - _selectedPage = null; - _searchFilter = string.Empty; - } - - private void UpdateVisiblePages() - { - var visible = new List(); - if (string.IsNullOrEmpty(_searchFilter)) - { - visible.AddRange(_launcher.Pages); - } - else - { - foreach (var page in _launcher.Pages) - { - if (page.Node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase) || - (page.Node.IsRepository && page.Node.Id.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase))) - { - visible.Add(page); - } - } - } - - VisiblePages = visible; - SelectedPage = visible.Count > 0 ? visible[0] : null; - } - - private Launcher _launcher = null; - private List _visiblePages = []; - private string _searchFilter = string.Empty; - private LauncherPage _selectedPage = null; - } -} diff --git a/src/ViewModels/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 26ae128db..f993e242e 100644 --- a/src/ViewModels/LayoutInfo.cs +++ b/src/ViewModels/LayoutInfo.cs @@ -65,17 +65,10 @@ public GridLength CommitDetailFilesLeftWidth set => SetProperty(ref _commitDetailFilesLeftWidth, value); } - public DataGridLength AuthorColumnWidth - { - get => _authorColumnWidth; - set => SetProperty(ref _authorColumnWidth, value); - } - private GridLength _repositorySidebarWidth = new GridLength(250, GridUnitType.Pixel); private GridLength _workingCopyLeftWidth = new GridLength(300, GridUnitType.Pixel); private GridLength _stashesLeftWidth = new GridLength(300, GridUnitType.Pixel); private GridLength _commitDetailChangesLeftWidth = new GridLength(256, GridUnitType.Pixel); private GridLength _commitDetailFilesLeftWidth = new GridLength(256, GridUnitType.Pixel); - private DataGridLength _authorColumnWidth = new DataGridLength(120, DataGridLengthUnitType.Pixel, 120, 120); } } diff --git a/src/ViewModels/Merge.cs b/src/ViewModels/Merge.cs index 3ccf6a6fe..35f9567f6 100644 --- a/src/ViewModels/Merge.cs +++ b/src/ViewModels/Merge.cs @@ -1,7 +1,18 @@ -using System.Threading.Tasks; +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 object Source @@ -16,8 +27,18 @@ public string Into public Models.MergeMode Mode { - get; - set; + 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 @@ -26,6 +47,12 @@ public bool Edit set; } = false; + public MergeTestingState TestingState + { + get => _testingState; + private set => SetProperty(ref _testingState, value); + } + public Merge(Repository repo, Models.Branch source, string into, bool forceFastForward) { _repo = repo; @@ -34,6 +61,9 @@ public Merge(Repository repo, Models.Branch source, string into, bool forceFastF Source = source; Into = into; Mode = forceFastForward ? Models.MergeMode.FastForward : AutoSelectMergeMode(); + + if (!forceFastForward) + Test(); } public Merge(Repository repo, Models.Commit source, string into) @@ -44,6 +74,8 @@ public Merge(Repository repo, Models.Commit source, string into) Source = source; Into = into; Mode = AutoSelectMergeMode(); + + Test(); } public Merge(Repository repo, Models.Tag source, string into) @@ -54,32 +86,49 @@ public Merge(Repository repo, Models.Tag source, string into) Source = source; Into = into; Mode = AutoSelectMergeMode(); + + Test(); } public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); _repo.ClearCommitMessage(); ProgressDescription = $"Merging '{_sourceName}' into '{Into}' ..."; var log = _repo.CreateLog($"Merging '{_sourceName}' into '{Into}'"); Use(log); - await new Commands.Merge(_repo.FullPath, _sourceName, Mode.Arg, Edit) + var succ = await new Commands.Merge(_repo.FullPath, _sourceName, Mode.Arg, _canEditMessage && Edit) .Use(log) .ExecAsync(); + if (succ) + { + var squashMsgFile = Path.Combine(_repo.GitDir, "SQUASH_MSG"); + if (Mode == Models.MergeMode.Squash && File.Exists(squashMsgFile)) + { + var msg = await File.ReadAllTextAsync(squashMsgFile); + _repo.SetCommitMessage(msg); + } + + await _repo.AutoUpdateSubmodulesAsync(log); + } + log.Complete(); - var head = await new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").GetResultAsync(); - _repo.NavigateToCommit(head, true); - _repo.SetWatcherEnabled(true); + 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).GetAsync($"branch.{Into}.mergeoptions").Result; + var config = new Commands.Config(_repo.FullPath).Get($"branch.{Into}.mergeoptions"); var mode = config switch { "--ff-only" => Models.MergeMode.FastForward, @@ -99,7 +148,32 @@ private Models.MergeMode AutoSelectMergeMode() return Models.MergeMode.Supported[preferredMergeModeIdx]; } + 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 index 03f199a87..781cadce1 100644 --- a/src/ViewModels/MergeMultiple.cs +++ b/src/ViewModels/MergeMultiple.cs @@ -40,7 +40,7 @@ public MergeMultiple(Repository repo, List branches) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); _repo.ClearCommitMessage(); ProgressDescription = "Merge head(s) ..."; @@ -56,7 +56,6 @@ public override async Task Sure() .ExecAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/MoveRepositoryNode.cs b/src/ViewModels/MoveRepositoryNode.cs index 0c213058b..716274295 100644 --- a/src/ViewModels/MoveRepositoryNode.cs +++ b/src/ViewModels/MoveRepositoryNode.cs @@ -32,6 +32,7 @@ public MoveRepositoryNode(RepositoryNode target) Id = Guid.NewGuid().ToString() }); MakeRows(Preferences.Instance.RepositoryNodes, 1); + Selected = Rows[0]; } public override Task Sure() diff --git a/src/ViewModels/MoveSubmodule.cs b/src/ViewModels/MoveSubmodule.cs index 52210f41f..b17f21128 100644 --- a/src/ViewModels/MoveSubmodule.cs +++ b/src/ViewModels/MoveSubmodule.cs @@ -34,8 +34,8 @@ public override async Task Sure() if (oldPath.Equals(newPath, StringComparison.Ordinal)) return true; + using var lockWatcher = _repo.LockWatcher(); var log = _repo.CreateLog("Move Submodule"); - _repo.SetWatcherEnabled(false); Use(log); var succ = await new Commands.Move(_repo.FullPath, oldPath, newPath, false) @@ -43,7 +43,6 @@ public override async Task Sure() .ExecAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } 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 942d33f9f..9d800c50c 100644 --- a/src/ViewModels/Popup.cs +++ b/src/ViewModels/Popup.cs @@ -4,7 +4,7 @@ namespace SourceGit.ViewModels { - public class Popup : ObservableValidator + public class Popup : ObservableValidator, Models.ICommandLogReceiver { public bool InProgress { @@ -27,6 +27,18 @@ public bool Check() return !HasErrors; } + public void OnReceiveCommandLog(string data) + { + var desc = data.Trim(); + if (!string.IsNullOrEmpty(desc)) + ProgressDescription = desc; + } + + public void Cleanup() + { + _log?.Unsubscribe(this); + } + public virtual bool CanStartDirectly() { return true; @@ -39,17 +51,12 @@ public virtual Task Sure() protected void Use(CommandLog log) { - log.Register(SetDescription); - } - - private void SetDescription(string data) - { - var desc = data.Trim(); - if (!string.IsNullOrEmpty(desc)) - ProgressDescription = desc; + _log = log; + _log.Subscribe(this); } private bool _inProgress = false; private string _progressDescription = string.Empty; + private CommandLog _log = null; } } diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 8e5f24c50..25bbb431f 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -3,6 +3,7 @@ using System.IO; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading.Tasks; using Avalonia.Collections; using CommunityToolkit.Mvvm.ComponentModel; @@ -23,6 +24,7 @@ public static Preferences Instance _instance.PrepareGit(); _instance.PrepareShellOrTerminal(); + _instance.PrepareExternalDiffMergeTool(); _instance.PrepareWorkspaces(); return _instance; @@ -65,7 +67,7 @@ public string DefaultFontFamily set { if (SetProperty(ref _defaultFontFamily, value) && !_isLoading) - App.SetFonts(value, _monospaceFontFamily, _onlyUseMonoFontInEditor); + App.SetFonts(value, _monospaceFontFamily); } } @@ -75,17 +77,7 @@ public string MonospaceFontFamily set { if (SetProperty(ref _monospaceFontFamily, value) && !_isLoading) - App.SetFonts(_defaultFontFamily, value, _onlyUseMonoFontInEditor); - } - } - - public bool OnlyUseMonoFontInEditor - { - get => _onlyUseMonoFontInEditor; - set - { - if (SetProperty(ref _onlyUseMonoFontInEditor, value) && !_isLoading) - App.SetFonts(_defaultFontFamily, _monospaceFontFamily, _onlyUseMonoFontInEditor); + App.SetFonts(_defaultFontFamily, value); } } @@ -113,12 +105,30 @@ public int 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; @@ -146,28 +156,41 @@ public int DateTimeFormat } } + 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 ShowChildren + public bool Check4UpdatesOnStartup { - get => _showChildren; - set => SetProperty(ref _showChildren, value); + get => _check4UpdatesOnStartup; + set => SetProperty(ref _check4UpdatesOnStartup, value); } public string IgnoreUpdateTag @@ -176,23 +199,17 @@ public string IgnoreUpdateTag set => SetProperty(ref _ignoreUpdateTag, value); } - public bool ShowTagsAsTree - { - get; - set; - } = false; - public bool ShowTagsInGraph { get => _showTagsInGraph; set => SetProperty(ref _showTagsInGraph, value); } - public bool ShowSubmodulesAsTree + public bool UseCompactBranchNamesInGraph { - get; - set; - } = false; + get => _useCompactBranchNamesInGraph; + set => SetProperty(ref _useCompactBranchNamesInGraph, value); + } public bool UseTwoColumnsLayoutInHistories { @@ -220,17 +237,28 @@ public bool UseSyntaxHighlighting public bool IgnoreCRAtEOLInDiff { - get => Models.DiffOption.IgnoreCRAtEOL; - set - { - if (Models.DiffOption.IgnoreCRAtEOL != value) - { - Models.DiffOption.IgnoreCRAtEOL = value; - OnPropertyChanged(); - } - } + 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; @@ -255,18 +283,24 @@ public bool UseFullTextDiff set => SetProperty(ref _useFullTextDiff, value); } - public bool UseBlockNavigationInDiffView - { - get => _useBlockNavigationInDiffView; - set => SetProperty(ref _useBlockNavigationInDiffView, value); - } - public int LFSImageActiveIdx { get => _lfsImageActiveIdx; set => SetProperty(ref _lfsImageActiveIdx, value); } + public 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; @@ -310,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]); @@ -323,6 +371,7 @@ public int ShellOrTerminal Native.OS.SetShellOrTerminal(null); OnPropertyChanged(nameof(ShellOrTerminalPath)); + OnPropertyChanged(nameof(ShellOrTerminalArgs)); } } } @@ -340,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 @@ -387,7 +480,7 @@ public AvaloniaList CustomActions set; } = []; - public AvaloniaList OpenAIServices + public AvaloniaList OpenAIServices { get; set; @@ -523,14 +616,35 @@ public void AutoRemoveInvalidNode() RemoveInvalidRepositoriesRecursive(RepositoryNodes); } + public void UpdateAvailableAIModels() + { + Task.Run(() => + { + foreach (var service in OpenAIServices) + { + try + { + service.FetchAvailableModels(); + } + catch + { + // Ignore errors. + } + } + }); + } + public void Save() { if (_isLoading || _isReadonly) return; - var file = Path.Combine(Native.OS.DataDir, "preference.json"); - using var stream = File.Create(file); - JsonSerializer.Serialize(stream, this, JsonCodeGen.Default.Preferences); + var tmpfile = Path.Combine(Native.OS.DataDir, "preference_tmp.json"); + var content = JsonSerializer.Serialize(this, JsonCodeGen.Default.Preferences); + File.WriteAllText(tmpfile, content); + + var finalFile = Path.Combine(Native.OS.DataDir, "preference.json"); + File.Move(tmpfile, finalFile, true); } private static Preferences Load() @@ -559,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++) @@ -567,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) @@ -668,17 +795,18 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private string _themeOverrides = string.Empty; private string _defaultFontFamily = string.Empty; private string _monospaceFontFamily = string.Empty; - private bool _onlyUseMonoFontInEditor = true; private double _defaultFontSize = 13; private double _editorFontSize = 13; private int _editorTabWidth = 4; - private LayoutInfo _layout = new LayoutInfo(); + private 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 _showChildren = false; + private bool _useAutoHideScrollBars = true; + private bool _useGitHubStyleAvatar = true; + private bool _useCompactBranchNamesInGraph = true; private bool _check4UpdatesOnStartup = true; private double _lastCheckUpdateTime = 0; @@ -689,12 +817,14 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) 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 bool _useBlockNavigationInDiffView = 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; @@ -702,11 +832,6 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) 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 56ba598de..cba2213cc 100644 --- a/src/ViewModels/PruneRemote.cs +++ b/src/ViewModels/PruneRemote.cs @@ -17,7 +17,7 @@ public PruneRemote(Repository repo, Models.Remote remote) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Run `prune` on remote ..."; var log = _repo.CreateLog($"Prune Remote '{Remote.Name}'"); @@ -28,7 +28,6 @@ public override async Task Sure() .PruneAsync(Remote.Name); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/PruneWorktrees.cs b/src/ViewModels/PruneWorktrees.cs index cce335276..561168362 100644 --- a/src/ViewModels/PruneWorktrees.cs +++ b/src/ViewModels/PruneWorktrees.cs @@ -11,7 +11,7 @@ public PruneWorktrees(Repository repo) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Prune worktrees ..."; var log = _repo.CreateLog("Prune Worktrees"); @@ -22,7 +22,6 @@ public override async Task Sure() .PruneAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/Pull.cs b/src/ViewModels/Pull.cs index 9372f309f..367bca40d 100644 --- a/src/ViewModels/Pull.cs +++ b/src/ViewModels/Pull.cs @@ -38,27 +38,21 @@ public Models.Branch SelectedBranch set => SetProperty(ref _selectedBranch, value, true); } - public bool DiscardLocalChanges + public bool HasLocalChanges { - get; - set; - } = false; - - public bool UseRebase - { - get => _repo.Settings.PreferRebaseInsteadOfMerge; - set => _repo.Settings.PreferRebaseInsteadOfMerge = value; + get => _repo.LocalChangesCount > 0; } - public bool IsRecurseSubmoduleVisible + public Models.DealWithLocalChanges DealWithLocalChanges { - get => _repo.Submodules.Count > 0; + get; + set; } - public bool RecurseSubmodules + public bool UseRebase { - get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; - set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; + get => _repo.UIStates.PreferRebaseInsteadOfMerge; + set => _repo.UIStates.PreferRebaseInsteadOfMerge = value; } public Pull(Repository repo, Models.Branch specifiedRemoteBranch) @@ -66,6 +60,10 @@ public Pull(Repository repo, Models.Branch specifiedRemoteBranch) _repo = repo; Current = repo.CurrentBranch; + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + if (specifiedRemoteBranch != null) { _selectedRemote = repo.Remotes.Find(x => x.Name == specifiedRemoteBranch.Remote); @@ -113,32 +111,35 @@ public Pull(Repository repo, Models.Branch specifiedRemoteBranch) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); var log = _repo.CreateLog("Pull"); Use(log); - var updateSubmodules = IsRecurseSubmoduleVisible && RecurseSubmodules; - var changes = await new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).GetResultAsync(); + var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync(); var needPopStash = false; if (changes > 0) { - if (DiscardLocalChanges) + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) { - await Commands.Discard.AllAsync(_repo.FullPath, false, log); + // Do nothing, just let the pull command fail and show the error to user } - else + else if (DealWithLocalChanges == Models.DealWithLocalChanges.StashAndReapply) { - var succ = await new Commands.Stash(_repo.FullPath).Use(log).PushAsync("PULL_AUTO_STASH"); + var succ = await new Commands.Stash(_repo.FullPath).Use(log).PushAsync("PULL_AUTO_STASH", false); if (!succ) { log.Complete(); - _repo.SetWatcherEnabled(true); + _repo.MarkWorkingCopyDirtyManually(); return false; } needPopStash = true; } + else + { + await Commands.Discard.AllAsync(_repo.FullPath, true, false, false, log); + } } bool rs = await new Commands.Pull( @@ -148,12 +149,7 @@ public override async Task Sure() UseRebase).Use(log).RunAsync(); if (rs) { - if (updateSubmodules) - { - var submodules = await new Commands.QueryUpdatableSubmodules(_repo.FullPath).GetResultAsync(); - if (submodules.Count > 0) - await new Commands.Submodule(_repo.FullPath).Use(log).UpdateAsync(submodules, true, true); - } + await _repo.AutoUpdateSubmodulesAsync(log); if (needPopStash) await new Commands.Stash(_repo.FullPath).Use(log).PopAsync("stash@{0}"); @@ -161,9 +157,12 @@ public override async Task Sure() log.Complete(); - var head = await new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").GetResultAsync(); - _repo.NavigateToCommit(head, true); - _repo.SetWatcherEnabled(true); + if (_repo.SelectedViewIndex == 0) + { + var head = await new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").GetResultAsync(); + _repo.NavigateToCommit(head, true); + } + return rs; } diff --git a/src/ViewModels/Push.cs b/src/ViewModels/Push.cs index 85969ee5a..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; @@ -57,7 +58,7 @@ public Models.Branch SelectedRemoteBranch set { if (SetProperty(ref _selectedRemoteBranch, value, true)) - IsSetTrackOptionVisible = value != null && _selectedLocalBranch.Upstream != value.FullName; + 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 @@ -127,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) @@ -138,6 +141,10 @@ 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) @@ -153,6 +160,27 @@ public Push(Repository repo, Models.Branch localBranch) AutoSelectBranchByRemote(); } + public void PushToNewBranch(string name) + { + var exist = _remoteBranches.Find(x => x.Name.Equals(name, StringComparison.Ordinal)); + if (exist != null) + { + SelectedRemoteBranch = exist; + return; + } + + var fake = new Models.Branch() + { + Name = name, + Remote = _selectedRemote.Name, + }; + var collection = new List(); + collection.AddRange(_remoteBranches); + collection.Add(fake); + RemoteBranches = collection; + SelectedRemoteBranch = fake; + } + public override bool CanStartDirectly() { return !string.IsNullOrEmpty(_selectedRemoteBranch?.Head); @@ -160,7 +188,7 @@ public override bool CanStartDirectly() public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); var remoteBranchName = _selectedRemoteBranch.Name; ProgressDescription = $"Push {_selectedLocalBranch.Name} -> {_selectedRemote.Name}/{remoteBranchName} ..."; @@ -175,21 +203,23 @@ public override async Task Sure() remoteBranchName, PushAllTags, _repo.Submodules.Count > 0 && CheckSubmodules, - _isSetTrackOptionVisible && Tracking, + _isSetTrackOptionVisible && _tracking, ForcePush).Use(log).RunAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); 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); } @@ -198,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; @@ -210,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; @@ -235,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 index 491488c4b..4d49b5272 100644 --- a/src/ViewModels/PushRevision.cs +++ b/src/ViewModels/PushRevision.cs @@ -31,7 +31,7 @@ public PushRevision(Repository repo, Models.Commit revision, Models.Branch remot public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Push {Revision.SHA.AsSpan(0, 10)} -> {RemoteBranch.FriendlyName} ..."; var log = _repo.CreateLog("Push Revision"); @@ -48,7 +48,6 @@ public override async Task Sure() Force).Use(log).RunAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/PushTag.cs b/src/ViewModels/PushTag.cs index f2bb4500b..d59548254 100644 --- a/src/ViewModels/PushTag.cs +++ b/src/ViewModels/PushTag.cs @@ -36,7 +36,7 @@ public PushTag(Repository repo, Models.Tag target) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Pushing tag ..."; var log = _repo.CreateLog("Push Tag"); @@ -63,7 +63,6 @@ public override async Task Sure() } log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/Rebase.cs b/src/ViewModels/Rebase.cs index 1727bc868..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,6 +52,8 @@ public Rebase(Repository repo, Models.Branch current, Models.Branch on) Current = current; On = on; AutoStash = true; + + Test(); } public Rebase(Repository repo, Models.Branch current, Models.Commit on) @@ -38,27 +63,66 @@ public Rebase(Repository repo, Models.Branch current, Models.Commit on) Current = current; On = on; AutoStash = true; + + Test(); } public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); _repo.ClearCommitMessage(); ProgressDescription = "Rebasing ..."; var log = _repo.CreateLog("Rebase"); Use(log); - await new Commands.Rebase(_repo.FullPath, _revision, AutoStash) + await new Commands.Rebase(_repo.FullPath, _revision, AutoStash, NoVerify) .Use(log) .ExecAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } + private void Test() + { + if (Native.OS.GitVersion < Models.GitVersions.REPLAY) + return; + + var head = Current.Head; + TestingState = RebaseTestingState.Testing; + Task.Run(async () => + { + var mergeBase = await new Commands.MergeBase(_repo.FullPath, head, _revision) + .GetResultAsync() + .ConfigureAwait(false); + + if (string.IsNullOrEmpty(mergeBase)) + { + Dispatcher.UIThread.Post(() => TestingState = RebaseTestingState.UnknownError); + return; + } + else if (head.Equals(mergeBase, StringComparison.Ordinal)) + { + Dispatcher.UIThread.Post(() => TestingState = RebaseTestingState.NoConflicts); + return; + } + + var exitCode = await new Commands.Replay(_repo.FullPath, _revision, $"{mergeBase}..{head}") + .GetExitCodeAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => TestingState = exitCode switch + { + 0 => RebaseTestingState.NoConflicts, + 1 => RebaseTestingState.WillCauseConflicts, + _ => RebaseTestingState.UnknownError, + }); + }); + } + private 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 a78cedc2f..40c3b87b3 100644 --- a/src/ViewModels/RemoveWorktree.cs +++ b/src/ViewModels/RemoveWorktree.cs @@ -4,7 +4,7 @@ namespace SourceGit.ViewModels { public class RemoveWorktree : Popup { - public Models.Worktree Target + public Worktree Target { get; } @@ -15,7 +15,7 @@ public bool Force set; } = false; - public RemoveWorktree(Repository repo, Models.Worktree target) + public RemoveWorktree(Repository repo, Worktree target) { _repo = repo; Target = target; @@ -23,7 +23,7 @@ public RemoveWorktree(Repository repo, Models.Worktree target) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Remove worktree ..."; var log = _repo.CreateLog("Remove worktree"); @@ -34,7 +34,6 @@ public override async Task Sure() .RemoveAsync(Target.FullPath, Force); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/RenameBranch.cs b/src/ViewModels/RenameBranch.cs index 807e3406a..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 { @@ -31,10 +31,9 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext { if (ctx.ObjectInstance is RenameBranch rename) { - var fixedName = Models.Branch.FixName(name); foreach (var b in rename._repo.Branches) { - if (b.IsLocal && b != rename.Target && b.Name.Equals(fixedName, StringComparison.Ordinal)) + if (b.IsLocal && b != rename.Target && b.Name.Equals(name, StringComparison.Ordinal)) return new ValidationResult("A branch with same name already exists!!!"); } } @@ -44,11 +43,10 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext public override async Task Sure() { - var fixedName = Models.Branch.FixName(_name); - if (fixedName.Equals(Target.Name, StringComparison.Ordinal)) + if (Target.Name.Equals(_name, StringComparison.Ordinal)) return true; - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Rename '{Target.Name}'"; var log = _repo.CreateLog($"Rename Branch '{Target.Name}'"); @@ -59,31 +57,12 @@ public override async Task Sure() var succ = await new Commands.Branch(_repo.FullPath, Target.Name) .Use(log) - .RenameAsync(fixedName); + .RenameAsync(_name); if (succ) - { - foreach (var filter in _repo.Settings.HistoriesFilters) - { - if (filter.Type == Models.FilterType.LocalBranch && - filter.Pattern.Equals(oldName, StringComparison.Ordinal)) - { - filter.Pattern = $"refs/heads/{fixedName}"; - break; - } - } - } + _repo.RefreshAfterRenameBranch(Target, _name); log.Complete(); - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - - if (isCurrent) - { - ProgressDescription = "Waiting for branch updated..."; - await Task.Delay(400); - } - return succ; } diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index e51347457..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; @@ -25,25 +21,12 @@ public bool IsBare public string FullPath { - get => _fullpath; - set - { - if (value != null) - { - var normalized = value.Replace('\\', '/').TrimEnd('/'); - 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 @@ -51,16 +34,34 @@ public Models.RepositorySettings Settings get => _settings; } + public Models.RepositoryUIStates UIStates + { + get => _uiStates; + } + public Models.GitFlow GitFlow { get; set; - } = new Models.GitFlow(); + } = new(); + + public Models.FilterMode HistoryFilterMode + { + get => _historyFilterMode; + private set => SetProperty(ref _historyFilterMode, value); + } - public Models.FilterMode HistoriesFilterMode + 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 @@ -75,59 +76,65 @@ public int SelectedViewIndex { if (SetProperty(ref _selectedViewIndex, value)) { - SelectedView = value switch - { - 1 => _workingCopy, - 2 => _stashesPage, - _ => _histories, - }; + OnPropertyChanged(nameof(IsHistoriesVisible)); + OnPropertyChanged(nameof(IsWorkingCopyVisible)); + OnPropertyChanged(nameof(IsStashesVisible)); } } } - public object SelectedView + public Histories Histories { - get => _selectedView; - set => SetProperty(ref _selectedView, value); + get => _histories; } - public bool EnableReflog + public WorkingCopy WorkingCopy { - get => _settings.EnableReflog; - set - { - if (value != _settings.EnableReflog) - { - _settings.EnableReflog = value; - OnPropertyChanged(); - Task.Run(RefreshCommits); - } - } + get => _workingCopy; + } + + public StashesPage StashesPage + { + get => _stashesPage; } - public bool EnableFirstParentInHistories + public bool IsHistoriesVisible { - get => _settings.EnableFirstParentInHistories; + get => SelectedViewIndex == 0; + } + + public bool IsWorkingCopyVisible + { + get => SelectedViewIndex == 1; + } + + public bool IsStashesVisible + { + get => SelectedViewIndex == 2; + } + + public bool EnableTopoOrderInHistory + { + get => _uiStates.EnableTopoOrderInHistory; set { - if (value != _settings.EnableFirstParentInHistories) + if (value != _uiStates.EnableTopoOrderInHistory) { - _settings.EnableFirstParentInHistories = value; - OnPropertyChanged(); - Task.Run(RefreshCommits); + _uiStates.EnableTopoOrderInHistory = value; + RefreshCommits(); } } } - public bool OnlyHighlightCurrentBranchInHistories + public Models.HistoryShowFlags HistoryShowFlags { - get => _settings.OnlyHighlightCurrentBranchInHistories; - set + get => _uiStates.HistoryShowFlags; + private set { - if (value != _settings.OnlyHighlightCurrentBranchInHistories) + if (value != _uiStates.HistoryShowFlags) { - _settings.OnlyHighlightCurrentBranchInHistories = value; - OnPropertyChanged(); + _uiStates.HistoryShowFlags = value; + RefreshCommits(); } } } @@ -166,9 +173,10 @@ public Models.Branch CurrentBranch private set { var oldHead = _currentBranch?.Head; - if (SetProperty(ref _currentBranch, value) && value != null) + if (SetProperty(ref _currentBranch, value)) { - if (oldHead != _currentBranch.Head && _workingCopy is { UseAmend: true }) + _histories?.NotifyCurrentBranchChanged(); + if (value != null && !value.Head.Equals(oldHead, StringComparison.Ordinal) && _workingCopy is { UseAmend: true }) _workingCopy.UseAmend = false; } } @@ -186,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); @@ -200,12 +208,12 @@ public List Tags public bool ShowTagsAsTree { - get => Preferences.Instance.ShowTagsAsTree; + get => _uiStates.ShowTagsAsTree; set { - if (value != Preferences.Instance.ShowTagsAsTree) + if (value != _uiStates.ShowTagsAsTree) { - Preferences.Instance.ShowTagsAsTree = value; + _uiStates.ShowTagsAsTree = value; VisibleTags = BuildVisibleTags(); OnPropertyChanged(); } @@ -226,12 +234,12 @@ public List Submodules public bool ShowSubmodulesAsTree { - get => Preferences.Instance.ShowSubmodulesAsTree; + get => _uiStates.ShowSubmodulesAsTree; set { - if (value != Preferences.Instance.ShowSubmodulesAsTree) + if (value != _uiStates.ShowSubmodulesAsTree) { - Preferences.Instance.ShowSubmodulesAsTree = value; + _uiStates.ShowSubmodulesAsTree = value; VisibleSubmodules = BuildVisibleSubmodules(); OnPropertyChanged(); } @@ -264,113 +272,46 @@ public int LocalBranchesCount public bool IncludeUntracked { - get => _settings.IncludeUntrackedInLocalChanges; + get => _uiStates.IncludeUntrackedInLocalChanges; set { - if (value != _settings.IncludeUntrackedInLocalChanges) + if (value != _uiStates.IncludeUntrackedInLocalChanges) { - _settings.IncludeUntrackedInLocalChanges = value; + _uiStates.IncludeUntrackedInLocalChanges = value; OnPropertyChanged(); - Task.Run(RefreshWorkingCopyChanges); + RefreshWorkingCopyChanges(); } } } - public bool IsSearching + public bool IsSearchingCommits { - get => _isSearching; + get => _isSearchingCommits; set { - if (SetProperty(ref _isSearching, value)) + if (SetProperty(ref _isSearchingCommits, value)) { if (value) - { SelectedViewIndex = 0; - CalcWorktreeFilesForSearching(); - } else - { - SearchedCommits = new List(); - SelectedSearchedCommit = null; - SearchCommitFilter = string.Empty; - MatchedFilesForSearching = null; - _requestingWorktreeFiles = false; - _worktreeFiles = null; - } - } - } - } - - public bool IsSearchLoadingVisible - { - get => _isSearchLoadingVisible; - private set => SetProperty(ref _isSearchLoadingVisible, value); - } - - public bool OnlySearchCommitsInCurrentBranch - { - get => _onlySearchCommitsInCurrentBranch; - set - { - if (SetProperty(ref _onlySearchCommitsInCurrentBranch, value) && !string.IsNullOrEmpty(_searchCommitFilter)) - StartSearchCommits(); - } - } - - public int SearchCommitFilterType - { - get => _searchCommitFilterType; - set - { - if (SetProperty(ref _searchCommitFilterType, value)) - { - CalcWorktreeFilesForSearching(); - if (!string.IsNullOrEmpty(_searchCommitFilter)) - StartSearchCommits(); + _searchCommitContext.EndSearch(); } } } - public string SearchCommitFilter - { - get => _searchCommitFilter; - set - { - if (SetProperty(ref _searchCommitFilter, value) && IsSearchingCommitsByFilePath()) - CalcMatchedFilesForSearching(); - } - } - - public List MatchedFilesForSearching + public SearchCommitContext SearchCommitContext { - get => _matchedFilesForSearching; - private set => SetProperty(ref _matchedFilesForSearching, value); - } - - public List SearchedCommits - { - get => _searchedCommits; - set => SetProperty(ref _searchedCommits, value); - } - - public Models.Commit SelectedSearchedCommit - { - get => _selectedSearchedCommit; - set - { - if (SetProperty(ref _selectedSearchedCommit, value) && value != null) - NavigateToCommit(value.SHA); - } + get => _searchCommitContext; } public bool IsLocalBranchGroupExpanded { - get => _settings.IsLocalBranchesExpandedInSideBar; + get => _uiStates.IsLocalBranchesExpandedInSideBar; set { - if (value != _settings.IsLocalBranchesExpandedInSideBar) + if (value != _uiStates.IsLocalBranchesExpandedInSideBar) { - _settings.IsLocalBranchesExpandedInSideBar = value; + _uiStates.IsLocalBranchesExpandedInSideBar = value; OnPropertyChanged(); } } @@ -378,12 +319,12 @@ public bool IsLocalBranchGroupExpanded public bool IsRemoteGroupExpanded { - get => _settings.IsRemotesExpandedInSideBar; + get => _uiStates.IsRemotesExpandedInSideBar; set { - if (value != _settings.IsRemotesExpandedInSideBar) + if (value != _uiStates.IsRemotesExpandedInSideBar) { - _settings.IsRemotesExpandedInSideBar = value; + _uiStates.IsRemotesExpandedInSideBar = value; OnPropertyChanged(); } } @@ -391,12 +332,12 @@ public bool IsRemoteGroupExpanded public bool IsTagGroupExpanded { - get => _settings.IsTagsExpandedInSideBar; + get => _uiStates.IsTagsExpandedInSideBar; set { - if (value != _settings.IsTagsExpandedInSideBar) + if (value != _uiStates.IsTagsExpandedInSideBar) { - _settings.IsTagsExpandedInSideBar = value; + _uiStates.IsTagsExpandedInSideBar = value; OnPropertyChanged(); } } @@ -404,12 +345,12 @@ public bool IsTagGroupExpanded public bool IsSubmoduleGroupExpanded { - get => _settings.IsSubmodulesExpandedInSideBar; + get => _uiStates.IsSubmodulesExpandedInSideBar; set { - if (value != _settings.IsSubmodulesExpandedInSideBar) + if (value != _uiStates.IsSubmodulesExpandedInSideBar) { - _settings.IsSubmodulesExpandedInSideBar = value; + _uiStates.IsSubmodulesExpandedInSideBar = value; OnPropertyChanged(); } } @@ -417,12 +358,12 @@ public bool IsSubmoduleGroupExpanded public bool IsWorktreeGroupExpanded { - get => _settings.IsWorktreeExpandedInSideBar; + get => _uiStates.IsWorktreeExpandedInSideBar; set { - if (value != _settings.IsWorktreeExpandedInSideBar) + if (value != _uiStates.IsWorktreeExpandedInSideBar) { - _settings.IsWorktreeExpandedInSideBar = value; + _uiStates.IsWorktreeExpandedInSideBar = value; OnPropertyChanged(); } } @@ -430,17 +371,41 @@ public bool IsWorktreeGroupExpanded public bool IsSortingLocalBranchByName { - get => _settings.LocalBranchSortMode == Models.BranchSortMode.Name; + get => _uiStates.LocalBranchSortMode == Models.BranchSortMode.Name; + set + { + _uiStates.LocalBranchSortMode = value ? Models.BranchSortMode.Name : Models.BranchSortMode.CommitterDate; + OnPropertyChanged(); + + var builder = BuildBranchTree(_branches, _remotes); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + } } public bool IsSortingRemoteBranchByName { - get => _settings.RemoteBranchSortMode == Models.BranchSortMode.Name; + get => _uiStates.RemoteBranchSortMode == Models.BranchSortMode.Name; + set + { + _uiStates.RemoteBranchSortMode = value ? Models.BranchSortMode.Name : Models.BranchSortMode.CommitterDate; + OnPropertyChanged(); + + var builder = BuildBranchTree(_branches, _remotes); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + } } public bool IsSortingTagsByName { - get => _settings.TagSortMode == Models.TagSortMode.Name; + get => _uiStates.TagSortMode == Models.TagSortMode.Name; + set + { + _uiStates.TagSortMode = value ? Models.TagSortMode.Name : Models.TagSortMode.CreatorDate; + OnPropertyChanged(); + VisibleTags = BuildVisibleTags(); + } } public InProgressContext InProgressContext @@ -466,128 +431,94 @@ public bool IsAutoFetching private set => SetProperty(ref _isAutoFetching, value); } - public int CommitDetailActivePageIndex + public AvaloniaList IssueTrackers { get; - set; - } = 0; + } = []; public AvaloniaList Logs { get; - private set; - } = new AvaloniaList(); + } = []; public Repository(bool isBare, string path, string gitDir) { IsBare = isBare; - FullPath = path; - GitDir = gitDir; - } + FullPath = path.Replace('\\', '/').TrimEnd('/'); + GitDir = gitDir.Replace('\\', '/').TrimEnd('/'); - public void Open() - { - var settingsFile = Path.Combine(_gitDir, "sourcegit.settings"); - if (File.Exists(settingsFile)) + var commonDirFile = Path.Combine(GitDir, "commondir"); + var isWorktree = GitDir.IndexOf("/worktrees/", StringComparison.Ordinal) > 0 && + File.Exists(commonDirFile); + + if (isWorktree) { - try - { - using var stream = File.OpenRead(settingsFile); - _settings = JsonSerializer.Deserialize(stream, 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 { - // For worktrees, we need to watch the $GIT_COMMON_DIR instead of the $GIT_DIR. - var gitDirForWatcher = _gitDir; - if (_gitDir.Replace('\\', '/').IndexOf("/worktrees/", StringComparison.Ordinal) > 0) - { - var commonDir = new Commands.QueryGitCommonDir(_fullpath).GetResultAsync().Result; - if (!string.IsNullOrEmpty(commonDir)) - gitDirForWatcher = commonDir; - } - - _watcher = new Models.Watcher(this, _fullpath, gitDirForWatcher); + _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; - - _workingCopy.CommitMessage = _settings.LastCommitMessage; - _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 - Logs.Clear(); - - _settings.LastCommitMessage = _workingCopy.CommitMessage; + var commitMessage = _workingCopy.CommitMessage; + if (!string.IsNullOrEmpty(commitMessage) && _workingCopy.InProgressContext != null) + File.WriteAllText(Path.Combine(GitDir, "MERGE_MSG"), commitMessage); - try - { - using var stream = File.Create(Path.Combine(_gitDir, "sourcegit.settings")); - JsonSerializer.Serialize(stream, _settings, JsonCodeGen.Default.RepositorySettings); - } - catch - { - // Ignore - } - _autoFetchTimer.Dispose(); - _autoFetchTimer = null; + _uiStates.LastCommitMessage = commitMessage; + _uiStates.Save(); - _settings = null; - _historiesFilterMode = Models.FilterMode.None; + 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.Dispose(); - _workingCopy.Dispose(); - _stashesPage.Dispose(); - - _watcher = null; - _histories = null; - _workingCopy = null; - _stashesPage = null; - - _localChangesCount = 0; - _stashesCount = 0; - - _remotes.Clear(); - _branches.Clear(); - _localBranchTrees.Clear(); - _remoteBranchTrees.Clear(); - _tags.Clear(); - _visibleTags = null; - _submodules.Clear(); - _visibleSubmodules = null; - _searchedCommits.Clear(); - _selectedSearchedCommit = null; - - _requestingWorktreeFiles = false; - _worktreeFiles = null; - _matchedFilesForSearching = null; + _autoFetchTimer.Dispose(); + } + + public void SendNotification(string message, bool isError = false) + { + Models.Notification.Send(FullPath, message, isError); } public bool CanCreatePopup() @@ -606,16 +537,20 @@ public void ShowPopup(Popup popup) page.Popup = popup; } - public void ShowAndStartPopup(Popup popup) + public async Task ShowAndStartPopupAsync(Popup popup) { - GetOwnerPage()?.StartPopup(popup); + var page = GetOwnerPage(); + page.Popup = popup; + + if (popup.CanStartDirectly()) + await page.ProcessPopupAsync(); } public bool IsGitFlowEnabled() { return GitFlow is { IsValid: true } && - _branches.Find(x => x.IsLocal && x.Name.Equals(GitFlow.Master, StringComparison.Ordinal)) != null && - _branches.Find(x => x.IsLocal && x.Name.Equals(GitFlow.Develop, StringComparison.Ordinal)) != null; + _branches.Find(x => x.IsLocal && x.Name.Equals(GitFlow.ProductionBranch, StringComparison.Ordinal)) != null && + _branches.Find(x => x.IsLocal && x.Name.Equals(GitFlow.DevelopmentBranch, StringComparison.Ordinal)) != null; } public Models.GitFlowBranchType GetGitFlowType(Models.Branch b) @@ -635,23 +570,40 @@ public Models.GitFlowBranchType GetGitFlowType(Models.Branch b) public bool IsLFSEnabled() { - var path = Path.Combine(_fullpath, ".git", "hooks", "pre-push"); + var path = Path.Combine(FullPath, ".git", "hooks", "pre-push"); if (!File.Exists(path)) return false; - var content = File.ReadAllText(path); - return content.Contains("git lfs pre-push"); + try + { + var content = File.ReadAllText(path); + return content.Contains("git lfs pre-push"); + } + catch + { + return false; + } + } + + 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!"); + + log.Complete(); } public async Task TrackLFSFileAsync(string pattern, bool isFilenameMode) { var log = CreateLog("Track LFS"); - var succ = await new Commands.LFS(_fullpath) + var succ = await new Commands.LFS(FullPath) .Use(log) .TrackAsync(pattern, isFilenameMode); if (succ) - App.SendNotification(_fullpath, $"Tracking successfully! Pattern: {pattern}"); + SendNotification($"Tracking successfully! Pattern: {pattern}"); log.Complete(); return succ; @@ -660,12 +612,12 @@ public async Task TrackLFSFileAsync(string pattern, bool isFilenameMode) public async Task LockLFSFileAsync(string remote, string path) { var log = CreateLog("Lock LFS File"); - var succ = await new Commands.LFS(_fullpath) + var succ = await new Commands.LFS(FullPath) .Use(log) .LockAsync(remote, path); if (succ) - App.SendNotification(_fullpath, $"Lock file successfully! File: {path}"); + SendNotification($"Lock file successfully! File: {path}"); log.Complete(); return succ; @@ -674,12 +626,12 @@ public async Task LockLFSFileAsync(string remote, string path) 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) + var succ = await new Commands.LFS(FullPath) .Use(log) .UnlockAsync(remote, path, force); if (succ && notify) - App.SendNotification(_fullpath, $"Unlock file successfully! File: {path}"); + SendNotification($"Unlock file successfully! File: {path}"); log.Complete(); return succ; @@ -694,173 +646,91 @@ public CommandLog CreateLog(string name) public void RefreshAll() { - Task.Run(RefreshCommits); - Task.Run(RefreshBranches); - Task.Run(RefreshTags); - Task.Run(RefreshSubmodules); - Task.Run(RefreshWorktrees); - Task.Run(RefreshWorkingCopyChanges); - Task.Run(RefreshStashes); + RefreshCommits(); + RefreshBranches(); + RefreshTags(); + RefreshSubmodules(); + RefreshWorktrees(); + RefreshWorkingCopyChanges(); + RefreshStashes(); Task.Run(async () => { - var config = await new Commands.Config(_fullpath).ReadAllAsync().ConfigureAwait(false); - _hasAllowedSignersFile = config.TryGetValue("gpg.ssh.allowedSignersFile", out var allowedSignersFile) && !string.IsNullOrEmpty(allowedSignersFile); - - if (config.TryGetValue("gitflow.branch.master", out var masterName)) - GitFlow.Master = masterName; - if (config.TryGetValue("gitflow.branch.develop", out var developName)) - GitFlow.Develop = developName; - if (config.TryGetValue("gitflow.prefix.feature", out var featurePrefix)) - GitFlow.FeaturePrefix = featurePrefix; - if (config.TryGetValue("gitflow.prefix.release", out var releasePrefix)) - GitFlow.ReleasePrefix = releasePrefix; - if (config.TryGetValue("gitflow.prefix.hotfix", out var hotfixPrefix)) - GitFlow.HotfixPrefix = hotfixPrefix; - }); - } - - public ContextMenu CreateContextMenuForExternalTools() - { - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; - - RenderOptions.SetBitmapInterpolationMode(menu, BitmapInterpolationMode.HighQuality); - RenderOptions.SetEdgeMode(menu, EdgeMode.Antialias); - RenderOptions.SetTextRenderingMode(menu, TextRenderingMode.Antialias); - - var explore = new MenuItem(); - explore.Header = App.Text("Repository.Explore"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.Click += (_, e) => - { - Native.OS.OpenInFileManager(_fullpath); - e.Handled = true; - }; - - var terminal = new MenuItem(); - terminal.Header = App.Text("Repository.Terminal"); - terminal.Icon = App.CreateMenuIcon("Icons.Terminal"); - terminal.Click += (_, e) => - { - Native.OS.OpenTerminal(_fullpath); - e.Handled = true; - }; - - menu.Items.Add(explore); - menu.Items.Add(terminal); - - var tools = Native.OS.ExternalTools; - if (tools.Count > 0) - { - menu.Items.Add(new MenuItem() { Header = "-" }); - - foreach (var tool in tools) - { - var dupTool = tool; - - var item = new MenuItem(); - item.Header = App.Text("Repository.OpenIn", dupTool.Name); - item.Icon = new Image { Width = 16, Height = 16, Source = dupTool.IconImage }; - item.Click += (_, e) => - { - dupTool.Open(_fullpath); - e.Handled = true; - }; - - menu.Items.Add(item); - } - } - - var urls = new Dictionary(); - foreach (var r in _remotes) - { - if (r.TryGetVisitURL(out var visit)) - urls.Add(r.Name, visit); - } - - if (urls.Count > 0) - { - menu.Items.Add(new MenuItem() { Header = "-" }); - - foreach (var (name, addr) in urls) + var 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(() => { - var item = new MenuItem(); - item.Header = App.Text("Repository.Visit", name); - item.Icon = App.CreateMenuIcon("Icons.Remotes"); - item.Click += (_, e) => - { - Native.OS.OpenBrowser(addr); - e.Handled = true; - }; - - menu.Items.Add(item); - } - } + IssueTrackers.Clear(); + IssueTrackers.AddRange(issuetrackers); + }); - return menu; + 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 (!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) - ShowAndStartPopup(new Fetch(this)); + await ShowAndStartPopupAsync(new Fetch(this)); else ShowPopup(new Fetch(this)); } - public void Pull(bool autoStart) + public async Task PullAsync(bool autoStart) { - if (!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) { - App.RaiseException(_fullpath, "Can NOT find current branch!!!"); + SendNotification("Can NOT find current branch!!!", true); return; } var pull = new Pull(this, null); if (autoStart && pull.SelectedBranch != null) - ShowAndStartPopup(pull); + await ShowAndStartPopupAsync(pull); else ShowPopup(pull); } - public void Push(bool autoStart) + public async Task PushAsync(bool autoStart) { 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 find current branch!!!"); + SendNotification("Can NOT find current branch!!!", true); return; } if (autoStart) - ShowAndStartPopup(new Push(this, null)); + await ShowAndStartPopupAsync(new Push(this, null)); else ShowPopup(new Push(this, null)); } @@ -871,29 +741,22 @@ public void ApplyPatch() ShowPopup(new Apply(this)); } - public void ExecCustomAction(Models.CustomAction action, object scope) + public async Task ExecCustomActionAsync(Models.CustomAction action, object scopeTarget) { if (!CanCreatePopup()) return; - var popup = scope switch - { - Models.Branch b => new ExecuteCustomAction(this, action, b), - Models.Commit c => new ExecuteCustomAction(this, action, c), - Models.Tag t => new ExecuteCustomAction(this, action, t), - _ => new ExecuteCustomAction(this, action) - }; - + var popup = new ExecuteCustomAction(this, action, scopeTarget); if (action.Controls.Count == 0) - ShowAndStartPopup(popup); + await ShowAndStartPopupAsync(popup); else ShowPopup(popup); } - public void Cleanup() + public async Task CleanupAsync() { if (CanCreatePopup()) - ShowAndStartPopup(new Cleanup(this)); + await ShowAndStartPopupAsync(new Cleanup(this)); } public void ClearFilter() @@ -901,98 +764,177 @@ public void ClearFilter() Filter = string.Empty; } - public void ClearSearchCommitFilter() - { - SearchCommitFilter = string.Empty; - } - - public void ClearMatchedFilesForSearching() + public IDisposable LockWatcher() { - MatchedFilesForSearching = null; + return _watcher?.Lock(); } - public void StartSearchCommits() + public void RefreshAfterCreateBranch(Models.Branch created, bool checkout) { - if (_histories == null) - return; + _watcher?.MarkBranchUpdated(); + _watcher?.MarkWorkingCopyUpdated(); - IsSearchLoadingVisible = true; - SelectedSearchedCommit = null; - MatchedFilesForSearching = null; + _branches.RemoveAll(b => b.IsLocal && b.Name.Equals(created.Name, StringComparison.Ordinal)); + _branches.Add(created); - Task.Run(async () => + if (checkout) { - var visible = new List(); - var method = (Models.CommitSearchMethod)_searchCommitFilterType; - - if (method == Models.CommitSearchMethod.BySHA) + if (_currentBranch.IsDetachedHead) { - var isCommitSHA = await new Commands.IsCommitSHA(_fullpath, _searchCommitFilter) - .GetResultAsync() - .ConfigureAwait(false); - - if (isCommitSHA) - { - var commit = await new Commands.QuerySingleCommit(_fullpath, _searchCommitFilter) - .GetResultAsync() - .ConfigureAwait(false); - visible.Add(commit); - } + _branches.Remove(_currentBranch); } else { - visible = await new Commands.QueryCommits(_fullpath, _searchCommitFilter, method, _onlySearchCommitsInCurrentBranch) - .GetResultAsync() - .ConfigureAwait(false); + _currentBranch.IsCurrent = false; + _currentBranch.WorktreePath = null; } - Dispatcher.UIThread.Post(() => + 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; + } + + 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 MarkTagsDirtyManually() + public void RefreshAfterRenameBranch(Models.Branch b, string newName) { - if (_watcher == null) + _watcher?.MarkBranchUpdated(); + + var newFullName = $"refs/heads/{newName}"; + _uiStates.RenameBranchFilter(b.FullName, newFullName); + + var renamed = new Models.Branch { - Task.Run(RefreshTags); - Task.Run(RefreshCommits); - } - else + 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) { - _watcher.MarkTagDirtyManually(); + 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() { - if (_watcher == null) - Task.Run(RefreshWorkingCopyChanges); - else - _watcher.MarkWorkingCopyDirtyManually(); + _watcher?.MarkWorkingCopyUpdated(); + RefreshWorkingCopyChanges(); + } + + public void MarkStashesDirtyManually() + { + _watcher?.MarkStashUpdated(); + RefreshStashes(); + } + + public void MarkSubmodulesDirtyManually() + { + _watcher?.MarkSubmodulesUpdated(); + RefreshSubmodules(); } public void MarkFetched() @@ -1006,60 +948,71 @@ public void NavigateToCommit(string sha, bool isDelayMode = false) { _navigateToCommitDelayed = sha; } - else if (_histories != null) + else { SelectedViewIndex = 0; - _histories.NavigateTo(sha); + _histories?.NavigateTo(sha); } } + 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 void ClearHistoriesFilter() + public Models.Commit GetSelectedCommitInHistory() { - _settings.HistoriesFilters.Clear(); - HistoriesFilterMode = Models.FilterMode.None; + return (_histories?.DetailContext as CommitDetail)?.Commit; + } + + public void ClearHistoryFilters() + { + _uiStates.HistoryFilters.Clear(); + HistoryFilterMode = Models.FilterMode.None; ResetBranchTreeFilterMode(LocalBranchTrees); ResetBranchTreeFilterMode(RemoteBranchTrees); ResetTagFilterMode(); - Task.Run(RefreshCommits); + RefreshCommits(); } - public void RemoveHistoriesFilter(Models.Filter filter) + public void RemoveHistoryFilter(Models.HistoryFilter filter) { - if (_settings.HistoriesFilters.Remove(filter)) + if (_uiStates.HistoryFilters.Remove(filter)) { - HistoriesFilterMode = _settings.HistoriesFilters.Count > 0 ? _settings.HistoriesFilters[0].Mode : Models.FilterMode.None; - RefreshHistoriesFilters(true); + HistoryFilterMode = _uiStates.GetHistoryFilterMode(); + RefreshHistoryFilters(true); } } public void UpdateBranchNodeIsExpanded(BranchTreeNode node) { - if (_settings == null || !string.IsNullOrWhiteSpace(_filter)) + if (_uiStates == null || !string.IsNullOrWhiteSpace(_filter)) return; if (node.IsExpanded) { - if (!_settings.ExpandedBranchNodesInSideBar.Contains(node.Path)) - _settings.ExpandedBranchNodesInSideBar.Add(node.Path); + if (!_uiStates.ExpandedBranchNodesInSideBar.Contains(node.Path)) + _uiStates.ExpandedBranchNodesInSideBar.Add(node.Path); } else { - _settings.ExpandedBranchNodesInSideBar.Remove(node.Path); + _uiStates.ExpandedBranchNodesInSideBar.Remove(node.Path); } } public void SetTagFilterMode(Models.Tag tag, Models.FilterMode mode) { - var changed = _settings.UpdateHistoriesFilter(tag.Name, Models.FilterType.Tag, mode); + var changed = _uiStates.UpdateHistoryFilters(tag.Name, Models.FilterType.Tag, mode); if (changed) - RefreshHistoriesFilters(true); + RefreshHistoryFilters(true); } public void SetBranchFilterMode(Models.Branch branch, Models.FilterMode mode, bool clearExists, bool refresh) @@ -1076,28 +1029,28 @@ public void SetBranchFilterMode(BranchTreeNode node, Models.FilterMode mode, boo if (clearExists) { - _settings.HistoriesFilters.Clear(); - HistoriesFilterMode = Models.FilterMode.None; + _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) && !branch.IsUpstreamGone) - _settings.UpdateHistoriesFilter(branch.Upstream, Models.FilterType.RemoteBranch, mode); + _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; @@ -1113,26 +1066,35 @@ public void SetBranchFilterMode(BranchTreeNode node, Models.FilterMode mode, boo if (parent == null) break; - _settings.UpdateHistoriesFilter(parent.Path, parentType, Models.FilterMode.None); + _uiStates.UpdateHistoryFilters(parent.Path, parentType, Models.FilterMode.None); cur = parent; } while (true); - RefreshHistoriesFilters(refresh); + RefreshHistoryFilters(refresh); } - public void StashAll(bool autoStart) + public async Task StashAllAsync(bool autoStart) { - _workingCopy?.StashAll(autoStart); - } + if (!CanCreatePopup()) + return; - public void SkipMerge() - { - _workingCopy?.SkipMerge(); - } + 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 async Task AbortMergeAsync() + { + if (_workingCopy != null) + await _workingCopy.AbortMergeAsync(); } public List<(Models.CustomAction, CustomActionContextMenuLabel)> GetCustomActions(Models.CustomActionScope scope) @@ -1156,136 +1118,145 @@ public void AbortMerge() public async Task ExecBisectCommandAsync(string subcmd) { + using var lockWatcher = _watcher?.Lock(); IsBisectCommandRunning = true; - SetWatcherEnabled(false); var log = CreateLog($"Bisect({subcmd})"); - var succ = await new Commands.Bisect(_fullpath, subcmd).Use(log).ExecAsync(); + var succ = await new Commands.Bisect(FullPath, subcmd).Use(log).ExecAsync(); log.Complete(); - var head = await new Commands.QueryRevisionByRefName(_fullpath, "HEAD").GetResultAsync(); + var head = await new Commands.QueryRevisionByRefName(FullPath, "HEAD").GetResultAsync(); if (!succ) - App.RaiseException(_fullpath, log.Content.Substring(log.Content.IndexOf('\n')).Trim()); + SendNotification(log.Content.Substring(log.Content.IndexOf('\n')).Trim(), true); else if (log.Content.Contains("is the first bad commit")) - App.SendNotification(_fullpath, log.Content.Substring(log.Content.IndexOf('\n')).Trim()); + SendNotification(log.Content.Substring(log.Content.IndexOf('\n')).Trim()); MarkBranchesDirtyManually(); NavigateToCommit(head, true); - SetWatcherEnabled(true); IsBisectCommandRunning = false; } public bool MayHaveSubmodules() { - var modulesFile = Path.Combine(_fullpath, ".gitmodules"); + var modulesFile = Path.Combine(FullPath, ".gitmodules"); var info = new FileInfo(modulesFile); return info.Exists && info.Length > 20; } public void RefreshBranches() { - var branches = new Commands.QueryBranches(_fullpath).GetResultAsync().Result; - var remotes = new Commands.QueryRemotes(_fullpath).GetResultAsync().Result; - var builder = BuildBranchTree(branches, remotes); + if (_cancellationRefreshBranches is { IsCancellationRequested: false }) + _cancellationRefreshBranches.Cancel(); - Dispatcher.UIThread.Invoke(() => - { - lock (_lockRemotes) - Remotes = remotes; + _cancellationRefreshBranches = new CancellationTokenSource(); + var token = _cancellationRefreshBranches.Token; - Branches = branches; - CurrentBranch = branches.Find(x => x.IsCurrent); - LocalBranchTrees = builder.Locals; - RemoteBranchTrees = builder.Remotes; + Task.Run(async () => + { + var branches = await new Commands.QueryBranches(FullPath).GetResultAsync().ConfigureAwait(false); + var remotes = await new Commands.QueryRemotes(FullPath).GetResultAsync().ConfigureAwait(false); + var builder = BuildBranchTree(branches, remotes); - var localBranchesCount = 0; - foreach (var b in branches) + Dispatcher.UIThread.Invoke(() => { - if (b.IsLocal && !b.IsDetachedHead) - localBranchesCount++; - } - LocalBranchesCount = localBranchesCount; + if (token.IsCancellationRequested) + return; - if (_workingCopy != null) - _workingCopy.HasRemotes = remotes.Count > 0; + Remotes = remotes; + Branches = branches; + CurrentBranch = branches.Find(x => x.IsCurrent); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; - var hasPendingPullOrPush = CurrentBranch?.TrackStatus.IsVisible ?? false; - GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasPendingPullOrPush, !hasPendingPullOrPush); - }); + var localBranchesCount = 0; + foreach (var b in branches) + { + if (b.IsLocal && !b.IsDetachedHead) + localBranchesCount++; + } + LocalBranchesCount = localBranchesCount; + + if (_workingCopy != null) + _workingCopy.HasRemotes = remotes.Count > 0; + + var hasPendingPullOrPush = CurrentBranch?.IsTrackStatusVisible ?? false; + GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasPendingPullOrPush, !hasPendingPullOrPush); + }); + }, token); } public void RefreshWorktrees() { - var worktrees = new Commands.Worktree(_fullpath).ReadAllAsync().Result; - var cleaned = new List(); - - foreach (var worktree in worktrees) - { - if (worktree.IsBare || worktree.FullPath.Equals(_fullpath)) - continue; - - cleaned.Add(worktree); - } - - Dispatcher.UIThread.Invoke(() => + Task.Run(async () => { - Worktrees = cleaned; + var worktrees = await new Commands.Worktree(FullPath).ReadAllAsync().ConfigureAwait(false); + var cleaned = Worktree.Build(FullPath, worktrees); + Dispatcher.UIThread.Invoke(() => Worktrees = cleaned); }); } public void RefreshTags() { - var tags = new Commands.QueryTags(_fullpath).GetResultAsync().Result; - Dispatcher.UIThread.Invoke(() => + if (_cancellationRefreshTags is { IsCancellationRequested: false }) + _cancellationRefreshTags.Cancel(); + + _cancellationRefreshTags = new CancellationTokenSource(); + var token = _cancellationRefreshTags.Token; + + Task.Run(async () => { - Tags = tags; - VisibleTags = BuildVisibleTags(); - }); + var tags = await new Commands.QueryTags(FullPath).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Invoke(() => + { + if (token.IsCancellationRequested) + return; + + Tags = tags; + VisibleTags = BuildVisibleTags(); + }); + }, token); } public void RefreshCommits() { - Dispatcher.UIThread.Invoke(() => _histories.IsLoading = true); - - var builder = new StringBuilder(); - builder.Append($"-{Preferences.Instance.MaxHistoryCommits} "); + if (_cancellationRefreshCommits is { IsCancellationRequested: false }) + _cancellationRefreshCommits.Cancel(); - if (_settings.EnableTopoOrderInHistories) - builder.Append("--topo-order "); - else - builder.Append("--date-order "); + _cancellationRefreshCommits = new CancellationTokenSource(); + var token = _cancellationRefreshCommits.Token; - if (_settings.EnableReflog) - builder.Append("--reflog "); - if (_settings.EnableFirstParentInHistories) - builder.Append("--first-parent "); + Task.Run(async () => + { + await Dispatcher.UIThread.InvokeAsync(() => _histories.IsLoading = true); - var filters = _settings.BuildHistoriesFilter(); - if (string.IsNullOrEmpty(filters)) - builder.Append("--branches --remotes --tags HEAD"); - else - builder.Append(filters); + var builder = new StringBuilder(); + builder + .Append('-').Append(Preferences.Instance.MaxHistoryCommits).Append(' ') + .Append(_uiStates.BuildHistoryParams(GitDir)); - var commits = new Commands.QueryCommits(_fullpath, builder.ToString()).GetResultAsync().Result; - var graph = Models.CommitGraph.Parse(commits, _settings.EnableFirstParentInHistories); + var commits = await new Commands.QueryCommits(FullPath, builder.ToString()) + .GetResultAsync() + .ConfigureAwait(false); - Dispatcher.UIThread.Invoke(() => - { - if (_histories != null) + Dispatcher.UIThread.Invoke(() => { - _histories.IsLoading = false; - _histories.Commits = commits; - _histories.Graph = graph; + if (token.IsCancellationRequested) + return; - BisectState = _histories.UpdateBisectInfo(); + if (_histories != null) + { + _histories.IsLoading = false; + _histories.Commits = commits; + BisectState = _histories.UpdateBisectInfo(); - if (!string.IsNullOrEmpty(_navigateToCommitDelayed)) - NavigateToCommit(_navigateToCommitDelayed); - } + if (!string.IsNullOrEmpty(_navigateToCommitDelayed)) + NavigateToCommit(_navigateToCommitDelayed); + } - _navigateToCommitDelayed = string.Empty; - }); + _navigateToCommitDelayed = string.Empty; + }); + }, token); } public void RefreshSubmodules() @@ -1304,41 +1275,43 @@ public void RefreshSubmodules() return; } - var submodules = new Commands.QuerySubmodules(_fullpath).GetResultAsync().Result; - _watcher?.SetSubmodules(submodules); - - Dispatcher.UIThread.Invoke(() => + Task.Run(async () => { - bool hasChanged = _submodules.Count != submodules.Count; - if (!hasChanged) - { - var old = new Dictionary(); - foreach (var module in _submodules) - old.Add(module.Path, module); + var submodules = await new Commands.QuerySubmodules(FullPath).GetResultAsync().ConfigureAwait(false); - foreach (var module in submodules) + Dispatcher.UIThread.Invoke(() => + { + bool hasChanged = _submodules.Count != submodules.Count; + if (!hasChanged) { - if (!old.TryGetValue(module.Path, out var exist)) + var old = new Dictionary(); + foreach (var module in _submodules) + old.Add(module.Path, module); + + foreach (var module in submodules) { - hasChanged = true; - break; + if (!old.TryGetValue(module.Path, out var exist)) + { + hasChanged = true; + break; + } + + hasChanged = !exist.SHA.Equals(module.SHA, StringComparison.Ordinal) || + !exist.Branch.Equals(module.Branch, StringComparison.Ordinal) || + !exist.URL.Equals(module.URL, StringComparison.Ordinal) || + exist.Status != module.Status; + + if (hasChanged) + break; } - - hasChanged = !exist.SHA.Equals(module.SHA, StringComparison.Ordinal) || - !exist.Branch.Equals(module.Branch, StringComparison.Ordinal) || - !exist.URL.Equals(module.URL, StringComparison.Ordinal) || - exist.Status != module.Status; - - if (hasChanged) - break; } - } - if (hasChanged) - { - Submodules = submodules; - VisibleSubmodules = BuildVisibleSubmodules(); - } + if (hasChanged) + { + Submodules = submodules; + VisibleSubmodules = BuildVisibleSubmodules(); + } + }); }); } @@ -1347,19 +1320,32 @@ public void RefreshWorkingCopyChanges() if (IsBare) return; - var changes = new Commands.QueryLocalChanges(_fullpath, _settings.IncludeUntrackedInLocalChanges).GetResultAsync().Result; - if (_workingCopy == null) - return; + if (_cancellationRefreshWorkingCopyChanges is { IsCancellationRequested: false }) + _cancellationRefreshWorkingCopyChanges.Cancel(); - changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); - _workingCopy.SetData(changes); + _cancellationRefreshWorkingCopyChanges = new CancellationTokenSource(); + var token = _cancellationRefreshWorkingCopyChanges.Token; + var noOptionalLocks = Interlocked.Add(ref _queryLocalChangesTimes, 1) > 1; - Dispatcher.UIThread.Invoke(() => + Task.Run(async () => { - LocalChangesCount = changes.Count; - OnPropertyChanged(nameof(InProgressContext)); - GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasLocalChanges, changes.Count == 0); - }); + var changes = await new Commands.QueryLocalChanges(FullPath, _uiStates.IncludeUntrackedInLocalChanges, noOptionalLocks) + .GetResultAsync() + .ConfigureAwait(false); + + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + + Dispatcher.UIThread.Invoke(() => + { + if (token.IsCancellationRequested) + return; + + _workingCopy.SetData(changes); + LocalChangesCount = changes.Count; + OnPropertyChanged(nameof(InProgressContext)); + GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasLocalChanges, changes.Count == 0); + }); + }, token); } public void RefreshStashes() @@ -1367,21 +1353,41 @@ public void RefreshStashes() if (IsBare) return; - var stashes = new Commands.QueryStashes(_fullpath).GetResultAsync().Result; - Dispatcher.UIThread.Invoke(() => + if (_cancellationRefreshStashes is { IsCancellationRequested: false }) + _cancellationRefreshStashes.Cancel(); + + _cancellationRefreshStashes = new CancellationTokenSource(); + var token = _cancellationRefreshStashes.Token; + + Task.Run(async () => { - if (_stashesPage != null) - _stashesPage.Stashes = stashes; + var stashes = await new Commands.QueryStashes(FullPath).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Invoke(() => + { + if (token.IsCancellationRequested) + return; - StashesCount = stashes.Count; - }); + if (_stashesPage != null) + _stashesPage.Stashes = stashes; + + StashesCount = stashes.Count; + }); + }, token); + } + + public void ToggleHistoryShowFlag(Models.HistoryShowFlags flag) + { + if (_uiStates.HistoryShowFlags.HasFlag(flag)) + HistoryShowFlags -= flag; + else + HistoryShowFlags |= flag; } public void CreateNewBranch() { if (_currentBranch == null) { - App.RaiseException(_fullpath, "Git cannot create a branch before your first commit."); + SendNotification("Git cannot create a branch before your first commit.", true); return; } @@ -1389,11 +1395,11 @@ public void CreateNewBranch() ShowPopup(new CreateBranch(this, _currentBranch)); } - public void CheckoutBranch(Models.Branch branch) + public async Task CheckoutBranchAsync(Models.Branch branch) { if (branch.IsLocal) { - var worktree = _worktrees.Find(x => x.Branch.Equals(branch.FullName, StringComparison.Ordinal)); + var worktree = _worktrees.Find(x => x.IsAttachedTo(branch)); if (worktree != null) { OpenWorktree(worktree); @@ -1409,10 +1415,10 @@ public void CheckoutBranch(Models.Branch branch) if (branch.IsLocal) { - if (_localChangesCount > 0 || _submodules.Count > 0) - ShowPopup(new Checkout(this, branch.Name)); + if (_workingCopy is { CanSwitchBranchDirectly: true }) + await ShowAndStartPopupAsync(new Checkout(this, branch)); else - ShowAndStartPopup(new Checkout(this, branch.Name)); + ShowPopup(new Checkout(this, branch)); } else { @@ -1420,12 +1426,12 @@ public void CheckoutBranch(Models.Branch branch) { if (b.IsLocal && b.Upstream.Equals(branch.FullName, StringComparison.Ordinal) && - b.TrackStatus.Ahead.Count == 0) + b.Ahead.Count == 0) { - if (b.TrackStatus.Behind.Count > 0) + if (b.Behind.Count > 0) ShowPopup(new CheckoutAndFastForward(this, b, branch)); else if (!b.IsCurrent) - CheckoutBranch(b); + await CheckoutBranchAsync(b); return; } @@ -1435,6 +1441,13 @@ public void CheckoutBranch(Models.Branch branch) } } + public async Task CheckoutTagAsync(Models.Tag tag) + { + var c = await new Commands.QuerySingleCommit(FullPath, tag.SHA).GetResultAsync(); + if (c != null && _histories != null) + await _histories.CheckoutBranchByCommitAsync(c); + } + public void DeleteBranch(Models.Branch branch) { if (CanCreatePopup()) @@ -1457,7 +1470,7 @@ public void CreateNewTag() { if (_currentBranch == null) { - App.RaiseException(_fullpath, "Git cannot create a branch before your first commit."); + SendNotification("Git cannot create a tag before your first commit.", true); return; } @@ -1483,6 +1496,14 @@ public void DeleteRemote(Models.Remote remote) ShowPopup(new DeleteRemote(this, remote)); } + public async Task ToggleAutoFetchOnRemoteAsync(Models.Remote remote) + { + var val = remote.DisableAutoFetch ? "false" : "true"; + var succ = await new Commands.Config(FullPath).SetAsync($"remote.{remote.Name.Quoted()}.disableautofetch", val); + if (succ) + remote.DisableAutoFetch = !remote.DisableAutoFetch; + } + public void AddSubmodule() { if (CanCreatePopup()) @@ -1495,25 +1516,43 @@ public void UpdateSubmodules() ShowPopup(new UpdateSubmodules(this, null)); } + public async Task AutoUpdateSubmodulesAsync(Models.ICommandLog log) + { + var submodules = await new Commands.QueryUpdatableSubmodules(FullPath, false).GetResultAsync(); + if (submodules.Count == 0) + return; + + do + { + if (_settings.AskBeforeAutoUpdatingSubmodules) + { + var builder = new StringBuilder(); + builder.Append("\n\n"); + foreach (var s in submodules) + builder.Append("- ").Append(s).Append('\n'); + builder.Append("\n"); + + var msg = App.Text("Checkout.WarnUpdatingSubmodules", builder.ToString()); + var shouldContinue = await App.AskConfirmAsync(msg, Models.ConfirmButtonType.YesNo); + if (!shouldContinue) + break; + } + + await new Commands.Submodule(FullPath) + .Use(log) + .UpdateAsync(submodules, false, _settings.EnableRecursiveWhenAutoUpdatingSubmodules, false); + } while (false); + } + public void OpenSubmodule(string submodule) { var selfPage = GetOwnerPage(); if (selfPage == null) return; - var root = Path.GetFullPath(Path.Combine(_fullpath, submodule)); + var root = Path.GetFullPath(Path.Combine(FullPath, submodule)); var normalizedPath = root.Replace('\\', '/').TrimEnd('/'); - - var node = Preferences.Instance.FindNode(normalizedPath) ?? - new RepositoryNode - { - Id = normalizedPath, - Name = Path.GetFileName(normalizedPath), - Bookmark = selfPage.Node.Bookmark, - IsRepository = true, - }; - - App.GetLauncher().OpenRepositoryInTab(node, null); + App.GetLauncher().OpenRepositoryInTab(normalizedPath, null); } public void AddWorktree() @@ -1522,27 +1561,42 @@ public void AddWorktree() ShowPopup(new AddWorktree(this)); } - public void PruneWorktrees() + public async Task PruneWorktreesAsync() { if (CanCreatePopup()) - ShowAndStartPopup(new PruneWorktrees(this)); + await ShowAndStartPopupAsync(new PruneWorktrees(this)); } - public void OpenWorktree(Models.Worktree worktree) + public void OpenWorktree(Worktree worktree) { - var node = Preferences.Instance.FindNode(worktree.FullPath) ?? - new RepositoryNode - { - Id = worktree.FullPath, - Name = Path.GetFileName(worktree.FullPath), - Bookmark = 0, - IsRepository = true, - }; + if (worktree.IsCurrent) + return; + + var normalizedPath = worktree.FullPath.Replace('\\', '/').TrimEnd('/'); + App.GetLauncher().OpenRepositoryInTab(normalizedPath, null); + } + + public async Task LockWorktreeAsync(Worktree worktree) + { + using var lockWatcher = _watcher?.Lock(); + var log = CreateLog("Lock Worktree"); + var succ = await new Commands.Worktree(FullPath).Use(log).LockAsync(worktree.FullPath); + if (succ) + worktree.IsLocked = true; + log.Complete(); + } - App.GetLauncher()?.OpenRepositoryInTab(node, null); + public async Task UnlockWorktreeAsync(Worktree worktree) + { + using var lockWatcher = _watcher?.Lock(); + var log = CreateLog("Unlock Worktree"); + var succ = await new Commands.Worktree(FullPath).Use(log).UnlockAsync(worktree.FullPath); + if (succ) + worktree.IsLocked = false; + log.Complete(); } - public List GetPreferredOpenAIServices() + public List GetPreferredOpenAIServices() { var services = Preferences.Instance.OpenAIServices; if (services == null || services.Count == 0) @@ -1552,7 +1606,7 @@ public void OpenWorktree(Models.Worktree worktree) return [services[0]]; var preferred = _settings.PreferredOpenAIService; - var all = new List(); + var all = new List(); foreach (var service in services) { if (service.Name.Equals(preferred, StringComparison.Ordinal)) @@ -1564,1380 +1618,200 @@ public void OpenWorktree(Models.Worktree worktree) return all; } - public ContextMenu CreateContextMenuForGitFlow() + public void DiscardAllChanges() { - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + if (CanCreatePopup()) + ShowPopup(new Discard(this)); + } - if (IsGitFlowEnabled()) - { - var startFeature = new MenuItem(); - startFeature.Header = App.Text("GitFlow.StartFeature"); - startFeature.Icon = App.CreateMenuIcon("Icons.GitFlow.Feature"); - startFeature.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Feature)); - e.Handled = true; - }; - - var startRelease = new MenuItem(); - startRelease.Header = App.Text("GitFlow.StartRelease"); - startRelease.Icon = App.CreateMenuIcon("Icons.GitFlow.Release"); - startRelease.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Release)); - e.Handled = true; - }; - - var startHotfix = new MenuItem(); - startHotfix.Header = App.Text("GitFlow.StartHotfix"); - startHotfix.Icon = App.CreateMenuIcon("Icons.GitFlow.Hotfix"); - startHotfix.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Hotfix)); - e.Handled = true; - }; + public void ClearStashes() + { + if (CanCreatePopup()) + ShowPopup(new ClearStashes(this)); + } - menu.Items.Add(startFeature); - menu.Items.Add(startRelease); - menu.Items.Add(startHotfix); - } - else + public async Task SaveCommitAsPatchAsync(Models.Commit commit, string folder, int index = 0) + { + var ignoredChars = new HashSet { '/', '\\', ':', ',', '*', '?', '\"', '<', '>', '|', '`', '$', '^', '%', '[', ']', '+', '-' }; + var builder = new StringBuilder(); + builder.Append(index.ToString("D4")); + builder.Append('-'); + + var chars = commit.Subject.ToCharArray(); + var len = 0; + foreach (var c in chars) { - var init = new MenuItem(); - init.Header = App.Text("GitFlow.Init"); - init.Icon = App.CreateMenuIcon("Icons.Init"); - init.Click += (_, e) => + if (!ignoredChars.Contains(c)) { - if (_currentBranch == null) - App.RaiseException(_fullpath, "Git flow init failed: No branch found!!!"); - else if (CanCreatePopup()) - ShowPopup(new InitGitFlow(this)); + if (c == ' ' || c == '\t') + builder.Append('-'); + else + builder.Append(c); - e.Handled = true; - }; - menu.Items.Add(init); + len++; + + if (len >= 48) + break; + } } - return menu; + builder.Append(".patch"); + + 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; } - public ContextMenu CreateContextMenuForGitLFS() + private LauncherPage GetOwnerPage() { - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + var launcher = App.GetLauncher(); + if (launcher == null) + return null; - if (IsLFSEnabled()) + foreach (var page in launcher.Pages) { - var addPattern = new MenuItem(); - addPattern.Header = App.Text("GitLFS.AddTrackPattern"); - addPattern.Icon = App.CreateMenuIcon("Icons.File.Add"); - addPattern.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new LFSTrackCustomPattern(this)); - - e.Handled = true; - }; - menu.Items.Add(addPattern); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var fetch = new MenuItem(); - fetch.Header = App.Text("GitLFS.Fetch"); - fetch.Icon = App.CreateMenuIcon("Icons.Fetch"); - fetch.IsEnabled = _remotes.Count > 0; - fetch.Click += (_, e) => - { - if (CanCreatePopup()) - { - if (_remotes.Count == 1) - ShowAndStartPopup(new LFSFetch(this)); - else - ShowPopup(new LFSFetch(this)); - } - - e.Handled = true; - }; - menu.Items.Add(fetch); - - var pull = new MenuItem(); - pull.Header = App.Text("GitLFS.Pull"); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.IsEnabled = _remotes.Count > 0; - pull.Click += (_, e) => - { - if (CanCreatePopup()) - { - if (_remotes.Count == 1) - ShowAndStartPopup(new LFSPull(this)); - else - ShowPopup(new LFSPull(this)); - } - - e.Handled = true; - }; - menu.Items.Add(pull); + if (page.Node.Id.Equals(FullPath)) + return page; + } - var push = new MenuItem(); - push.Header = App.Text("GitLFS.Push"); - push.Icon = App.CreateMenuIcon("Icons.Push"); - push.IsEnabled = _remotes.Count > 0; - push.Click += (_, e) => - { - if (CanCreatePopup()) - { - if (_remotes.Count == 1) - ShowAndStartPopup(new LFSPush(this)); - else - ShowPopup(new LFSPush(this)); - } + return null; + } - e.Handled = true; - }; - menu.Items.Add(push); + private BranchTreeNode.Builder BuildBranchTree(List branches, List remotes, bool validateExpandedNodes = true) + { + var builder = new BranchTreeNode.Builder(_uiStates.LocalBranchSortMode, _uiStates.RemoteBranchSortMode); + if (string.IsNullOrEmpty(_filter)) + { + builder.SetExpandedNodes(_uiStates.ExpandedBranchNodesInSideBar); + builder.Run(branches, remotes, false); - var prune = new MenuItem(); - prune.Header = App.Text("GitLFS.Prune"); - prune.Icon = App.CreateMenuIcon("Icons.Clean"); - prune.Click += (_, e) => - { - if (CanCreatePopup()) - ShowAndStartPopup(new LFSPrune(this)); - - e.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(prune); - - var locks = new MenuItem(); - locks.Header = App.Text("GitLFS.Locks"); - locks.Icon = App.CreateMenuIcon("Icons.Lock"); - locks.IsEnabled = _remotes.Count > 0; - if (_remotes.Count == 1) - { - locks.Click += async (_, e) => - { - await App.ShowDialog(new LFSLocks(this, _remotes[0].Name)); - e.Handled = true; - }; - } - else + if (validateExpandedNodes) { - foreach (var remote in _remotes) - { - var remoteName = remote.Name; - var lockRemote = new MenuItem(); - lockRemote.Header = remoteName; - lockRemote.Click += async (_, e) => - { - await App.ShowDialog(new LFSLocks(this, remoteName)); - e.Handled = true; - }; - locks.Items.Add(lockRemote); - } + foreach (var invalid in builder.InvalidExpandedNodes) + _uiStates.ExpandedBranchNodesInSideBar.Remove(invalid); } - - 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 += async (_, e) => + var visibles = new List(); + foreach (var b in branches) { - var log = CreateLog("Install LFS"); - var succ = await new Commands.LFS(_fullpath).Use(log).InstallAsync(); - if (succ) - App.SendNotification(_fullpath, "LFS enabled successfully!"); + if (b.FullName.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visibles.Add(b); + } - log.Complete(); - e.Handled = true; - }; - menu.Items.Add(install); + builder.Run(visibles, remotes, true); } - return menu; + var filterMap = _uiStates.GetHistoryFiltersMap(); + UpdateBranchTreeFilterMode(builder.Locals, filterMap); + UpdateBranchTreeFilterMode(builder.Remotes, filterMap); + return builder; } - public ContextMenu CreateContextMenuForCustomAction() + private object BuildVisibleTags() { - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; - - var actions = GetCustomActions(Models.CustomActionScope.Repository); - if (actions.Count > 0) + switch (_uiStates.TagSortMode) { - foreach (var action in actions) - { - var (dup, label) = action; - var item = new MenuItem(); - item.Icon = App.CreateMenuIcon("Icons.Action"); - item.Header = label; - item.Click += (_, e) => - { - ExecCustomAction(dup, null); - e.Handled = true; - }; + case Models.TagSortMode.CreatorDate: + _tags.Sort((l, r) => r.CreatorDate.CompareTo(l.CreatorDate)); + break; + default: + _tags.Sort((l, r) => Models.NumericSort.Compare(l.Name, r.Name)); + break; + } - menu.Items.Add(item); - } + var visible = new List(); + if (string.IsNullOrEmpty(_filter)) + { + visible.AddRange(_tags); } else { - menu.Items.Add(new MenuItem() { Header = App.Text("Repository.CustomActions.Empty") }); + foreach (var t in _tags) + { + if (t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(t); + } } - return menu; - } - - public ContextMenu CreateContextMenuForHistoriesPage() - { - var layout = new MenuItem(); - layout.Header = App.Text("Repository.HistoriesLayout"); - layout.IsEnabled = false; + var filterMap = _uiStates.GetHistoryFiltersMap(); + UpdateTagFilterMode(filterMap); - var isHorizontal = Preferences.Instance.UseTwoColumnsLayoutInHistories; - var horizontal = new MenuItem(); - horizontal.Header = App.Text("Repository.HistoriesLayout.Horizontal"); - if (isHorizontal) - horizontal.Icon = App.CreateMenuIcon("Icons.Check"); - horizontal.Click += (_, ev) => + if (_uiStates.ShowTagsAsTree) { - Preferences.Instance.UseTwoColumnsLayoutInHistories = true; - ev.Handled = true; - }; - - var vertical = new MenuItem(); - vertical.Header = App.Text("Repository.HistoriesLayout.Vertical"); - if (!isHorizontal) - vertical.Icon = App.CreateMenuIcon("Icons.Check"); - vertical.Click += (_, ev) => + var tree = TagCollectionAsTree.Build(visible, _visibleTags as TagCollectionAsTree); + foreach (var node in tree.Tree) + node.UpdateFilterMode(filterMap); + return tree; + } + else { - Preferences.Instance.UseTwoColumnsLayoutInHistories = false; - ev.Handled = true; - }; - - var order = new MenuItem(); - order.Header = App.Text("Repository.HistoriesOrder"); - order.IsEnabled = false; + var list = new TagCollectionAsList(visible); + foreach (var item in list.TagItems) + item.FilterMode = filterMap.GetValueOrDefault(item.Tag.Name, Models.FilterMode.None); + return list; + } + } - var dateOrder = new MenuItem(); - dateOrder.Header = App.Text("Repository.HistoriesOrder.ByDate"); - dateOrder.Tag = "--date-order"; - if (!_settings.EnableTopoOrderInHistories) - dateOrder.Icon = App.CreateMenuIcon("Icons.Check"); - dateOrder.Click += (_, ev) => + private object BuildVisibleSubmodules() + { + var visible = new List(); + if (string.IsNullOrEmpty(_filter)) { - if (_settings.EnableTopoOrderInHistories) - { - _settings.EnableTopoOrderInHistories = false; - Task.Run(RefreshCommits); - } - - ev.Handled = true; - }; - - var topoOrder = new MenuItem(); - topoOrder.Header = App.Text("Repository.HistoriesOrder.Topo"); - topoOrder.Tag = "--topo-order"; - if (_settings.EnableTopoOrderInHistories) - topoOrder.Icon = App.CreateMenuIcon("Icons.Check"); - topoOrder.Click += (_, ev) => + visible.AddRange(_submodules); + } + else { - if (!_settings.EnableTopoOrderInHistories) + foreach (var s in _submodules) { - _settings.EnableTopoOrderInHistories = true; - Task.Run(RefreshCommits); + if (s.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(s); } + } - ev.Handled = true; - }; - - var menu = new ContextMenu(); - menu.Items.Add(layout); - menu.Items.Add(horizontal); - menu.Items.Add(vertical); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(order); - menu.Items.Add(dateOrder); - menu.Items.Add(topoOrder); - return menu; + if (_uiStates.ShowSubmodulesAsTree) + return SubmoduleCollectionAsTree.Build(visible, _visibleSubmodules as SubmoduleCollectionAsTree); + else + return new SubmoduleCollectionAsList() { Submodules = visible }; } - public void DiscardAllChanges() + private void RefreshHistoryFilters(bool refresh) { - if (CanCreatePopup()) - ShowPopup(new Discard(this)); + HistoryFilterMode = _uiStates.GetHistoryFilterMode(); + if (!refresh) + return; + + var map = _uiStates.GetHistoryFiltersMap(); + UpdateBranchTreeFilterMode(LocalBranchTrees, map); + UpdateBranchTreeFilterMode(RemoteBranchTrees, map); + UpdateTagFilterMode(map); + RefreshCommits(); } - public void ClearStashes() - { - if (CanCreatePopup()) - ShowPopup(new ClearStashes(this)); - } - - public ContextMenu CreateContextMenuForLocalBranch(Models.Branch branch) - { - var menu = new ContextMenu(); - - var push = new MenuItem(); - push.Header = App.Text("BranchCM.Push", branch.Name); - push.Icon = App.CreateMenuIcon("Icons.Push"); - push.IsEnabled = _remotes.Count > 0; - push.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Push(this, branch)); - e.Handled = true; - }; - - if (branch.IsCurrent) - { - if (!IsBare) - { - if (!string.IsNullOrEmpty(branch.Upstream)) - { - var upstream = branch.Upstream.Substring(13); - var fastForward = new MenuItem(); - fastForward.Header = App.Text("BranchCM.FastForward", upstream); - fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); - fastForward.IsEnabled = branch.TrackStatus.Ahead.Count == 0; - fastForward.Click += (_, e) => - { - var b = _branches.Find(x => x.FriendlyName == upstream); - if (b == null) - return; - - if (CanCreatePopup()) - ShowAndStartPopup(new Merge(this, b, branch.Name, true)); - - e.Handled = true; - }; - - var pull = new MenuItem(); - pull.Header = App.Text("BranchCM.Pull", upstream); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Pull(this, null)); - e.Handled = true; - }; - - menu.Items.Add(fastForward); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(pull); - } - } - - menu.Items.Add(push); - } - else - { - if (!IsBare) - { - var checkout = new MenuItem(); - checkout.Header = App.Text("BranchCM.Checkout", branch.Name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (_, e) => - { - CheckoutBranch(branch); - e.Handled = true; - }; - menu.Items.Add(checkout); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var worktree = _worktrees.Find(x => x.Branch == branch.FullName); - var upstream = _branches.Find(x => x.FullName == branch.Upstream); - if (upstream != null && worktree == null) - { - var fastForward = new MenuItem(); - fastForward.Header = App.Text("BranchCM.FastForward", upstream.FriendlyName); - fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); - fastForward.IsEnabled = branch.TrackStatus.Ahead.Count == 0; - fastForward.Click += (_, e) => - { - if (CanCreatePopup()) - ShowAndStartPopup(new ResetWithoutCheckout(this, branch, upstream)); - e.Handled = true; - }; - menu.Items.Add(fastForward); - - var fetchInto = new MenuItem(); - fetchInto.Header = App.Text("BranchCM.FetchInto", upstream.FriendlyName, branch.Name); - fetchInto.Icon = App.CreateMenuIcon("Icons.Fetch"); - fetchInto.IsEnabled = branch.TrackStatus.Ahead.Count == 0; - fetchInto.Click += (_, e) => - { - if (CanCreatePopup()) - ShowAndStartPopup(new FetchInto(this, branch, upstream)); - e.Handled = true; - }; - - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(fetchInto); - } - - menu.Items.Add(push); - - if (!IsBare) - { - var merge = new MenuItem(); - merge.Header = App.Text("BranchCM.Merge", branch.Name, _currentBranch.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Merge(this, branch, _currentBranch.Name, false)); - e.Handled = true; - }; - - var rebase = new MenuItem(); - rebase.Header = App.Text("BranchCM.Rebase", _currentBranch.Name, branch.Name); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Rebase(this, _currentBranch, branch)); - e.Handled = true; - }; - - menu.Items.Add(merge); - menu.Items.Add(rebase); - } - - if (worktree == null) - { - var selectedCommit = (_histories?.DetailContext as CommitDetail)?.Commit; - if (selectedCommit != null && !selectedCommit.SHA.Equals(branch.Head, StringComparison.Ordinal)) - { - var move = new MenuItem(); - move.Header = App.Text("BranchCM.ResetToSelectedCommit", branch.Name, selectedCommit.SHA.Substring(0, 10)); - move.Icon = App.CreateMenuIcon("Icons.Reset"); - move.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new ResetWithoutCheckout(this, branch, selectedCommit)); - e.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(move); - } - } - - var compareWithCurrent = new MenuItem(); - compareWithCurrent.Header = App.Text("BranchCM.CompareWithCurrent", _currentBranch.Name); - compareWithCurrent.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithCurrent.Click += (_, _) => - { - App.ShowWindow(new BranchCompare(_fullpath, branch, _currentBranch)); - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(compareWithCurrent); - - if (_localChangesCount > 0) - { - var compareWithWorktree = new MenuItem(); - compareWithWorktree.Header = App.Text("BranchCM.CompareWithWorktree"); - compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithWorktree.Click += async (_, _) => - { - SelectedSearchedCommit = null; - - if (_histories != null) - { - var target = await new Commands.QuerySingleCommit(_fullpath, branch.Head).GetResultAsync(); - _histories.AutoSelectedCommit = null; - _histories.DetailContext = new RevisionCompare(_fullpath, target, null); - } - }; - menu.Items.Add(compareWithWorktree); - } - } - - if (!IsBare) - { - var type = GetGitFlowType(branch); - if (type != Models.GitFlowBranchType.None) - { - var finish = new MenuItem(); - finish.Header = App.Text("BranchCM.Finish", branch.Name); - finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); - finish.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new GitFlowFinish(this, branch, type)); - e.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(finish); - } - } - - var rename = new MenuItem(); - rename.Header = App.Text("BranchCM.Rename", branch.Name); - rename.Icon = App.CreateMenuIcon("Icons.Rename"); - rename.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new RenameBranch(this, branch)); - e.Handled = true; - }; - - var delete = new MenuItem(); - delete.Header = App.Text("BranchCM.Delete", branch.Name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.IsEnabled = !branch.IsCurrent; - delete.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new DeleteBranch(this, branch)); - e.Handled = true; - }; - - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new CreateBranch(this, branch)); - e.Handled = true; - }; - - var createTag = new MenuItem(); - createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); - createTag.Header = App.Text("CreateTag"); - createTag.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new CreateTag(this, branch)); - e.Handled = true; - }; - - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(rename); - menu.Items.Add(delete); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(createBranch); - menu.Items.Add(createTag); - menu.Items.Add(new MenuItem() { Header = "-" }); - TryToAddCustomActionsToBranchContextMenu(menu, branch); - - if (!IsBare) - { - var remoteBranches = new List(); - foreach (var b in _branches) - { - if (!b.IsLocal) - remoteBranches.Add(b); - } - - if (remoteBranches.Count > 0) - { - var tracking = new MenuItem(); - tracking.Header = App.Text("BranchCM.Tracking"); - tracking.Icon = App.CreateMenuIcon("Icons.Track"); - tracking.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new SetUpstream(this, branch, remoteBranches)); - e.Handled = true; - }; - menu.Items.Add(tracking); - } - } - - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Archive(this, branch)); - e.Handled = true; - }; - menu.Items.Add(archive); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var copy = new MenuItem(); - copy.Header = App.Text("BranchCM.CopyName"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, e) => - { - await App.CopyTextAsync(branch.Name); - e.Handled = true; - }; - menu.Items.Add(copy); - - return menu; - } - - public ContextMenu CreateContextMenuForRemote(Models.Remote remote) - { - var menu = new ContextMenu(); - - if (remote.TryGetVisitURL(out string visitURL)) - { - var visit = new MenuItem(); - visit.Header = App.Text("RemoteCM.OpenInBrowser"); - visit.Icon = App.CreateMenuIcon("Icons.OpenWith"); - visit.Click += (_, e) => - { - Native.OS.OpenBrowser(visitURL); - e.Handled = true; - }; - - menu.Items.Add(visit); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var fetch = new MenuItem(); - fetch.Header = App.Text("RemoteCM.Fetch"); - fetch.Icon = App.CreateMenuIcon("Icons.Fetch"); - fetch.Click += (_, e) => - { - if (CanCreatePopup()) - ShowAndStartPopup(new Fetch(this, remote)); - e.Handled = true; - }; - - var prune = new MenuItem(); - prune.Header = App.Text("RemoteCM.Prune"); - prune.Icon = App.CreateMenuIcon("Icons.Clean"); - prune.Click += (_, e) => - { - if (CanCreatePopup()) - ShowAndStartPopup(new PruneRemote(this, remote)); - e.Handled = true; - }; - - var edit = new MenuItem(); - edit.Header = App.Text("RemoteCM.Edit"); - edit.Icon = App.CreateMenuIcon("Icons.Edit"); - edit.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new EditRemote(this, remote)); - e.Handled = true; - }; - - var delete = new MenuItem(); - delete.Header = App.Text("RemoteCM.Delete"); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new DeleteRemote(this, remote)); - e.Handled = true; - }; - - var copy = new MenuItem(); - copy.Header = App.Text("RemoteCM.CopyURL"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, e) => - { - await App.CopyTextAsync(remote.URL); - e.Handled = true; - }; - - menu.Items.Add(fetch); - menu.Items.Add(prune); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(edit); - menu.Items.Add(delete); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copy); - return menu; - } - - public ContextMenu CreateContextMenuForRemoteBranch(Models.Branch branch) - { - var menu = new ContextMenu(); - var name = branch.FriendlyName; - - var checkout = new MenuItem(); - checkout.Header = App.Text("BranchCM.Checkout", name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (_, e) => - { - CheckoutBranch(branch); - e.Handled = true; - }; - menu.Items.Add(checkout); - menu.Items.Add(new MenuItem() { Header = "-" }); - - if (_currentBranch != null) - { - var pull = new MenuItem(); - pull.Header = App.Text("BranchCM.PullInto", name, _currentBranch.Name); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Pull(this, branch)); - e.Handled = true; - }; - - var merge = new MenuItem(); - merge.Header = App.Text("BranchCM.Merge", name, _currentBranch.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Merge(this, branch, _currentBranch.Name, false)); - e.Handled = true; - }; - - var rebase = new MenuItem(); - rebase.Header = App.Text("BranchCM.Rebase", _currentBranch.Name, name); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Rebase(this, _currentBranch, branch)); - e.Handled = true; - }; - - menu.Items.Add(pull); - menu.Items.Add(merge); - menu.Items.Add(rebase); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var compareWithHead = new MenuItem(); - compareWithHead.Header = App.Text("BranchCM.CompareWithCurrent", _currentBranch.Name); - compareWithHead.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithHead.Click += (_, _) => - { - App.ShowWindow(new BranchCompare(_fullpath, branch, _currentBranch)); - }; - menu.Items.Add(compareWithHead); - - if (_localChangesCount > 0) - { - var compareWithWorktree = new MenuItem(); - compareWithWorktree.Header = App.Text("BranchCM.CompareWithWorktree"); - compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithWorktree.Click += async (_, _) => - { - SelectedSearchedCommit = null; - - if (_histories != null) - { - var target = await new Commands.QuerySingleCommit(_fullpath, branch.Head).GetResultAsync(); - _histories.AutoSelectedCommit = null; - _histories.DetailContext = new RevisionCompare(_fullpath, target, null); - } - }; - menu.Items.Add(compareWithWorktree); - } - menu.Items.Add(new MenuItem() { Header = "-" }); - - var delete = new MenuItem(); - delete.Header = App.Text("BranchCM.Delete", name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new DeleteBranch(this, branch)); - e.Handled = true; - }; - - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new CreateBranch(this, branch)); - e.Handled = true; - }; - - var createTag = new MenuItem(); - createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); - createTag.Header = App.Text("CreateTag"); - createTag.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new CreateTag(this, branch)); - e.Handled = true; - }; - - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (_, e) => - { - if (CanCreatePopup()) - ShowPopup(new Archive(this, branch)); - e.Handled = true; - }; - - var copy = new MenuItem(); - copy.Header = App.Text("BranchCM.CopyName"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, e) => - { - await App.CopyTextAsync(name); - e.Handled = true; - }; - - menu.Items.Add(delete); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(createBranch); - menu.Items.Add(createTag); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(archive); - menu.Items.Add(new MenuItem() { Header = "-" }); - TryToAddCustomActionsToBranchContextMenu(menu, branch); - menu.Items.Add(copy); - - return menu; - } - - public ContextMenu CreateContextMenuForTag(Models.Tag tag) - { - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new CreateBranch(this, tag)); - ev.Handled = true; - }; - - var pushTag = new MenuItem(); - pushTag.Header = App.Text("TagCM.Push", tag.Name); - pushTag.Icon = App.CreateMenuIcon("Icons.Push"); - pushTag.IsEnabled = _remotes.Count > 0; - pushTag.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new PushTag(this, tag)); - ev.Handled = true; - }; - - var deleteTag = new MenuItem(); - deleteTag.Header = App.Text("TagCM.Delete", tag.Name); - deleteTag.Icon = App.CreateMenuIcon("Icons.Clear"); - deleteTag.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new DeleteTag(this, tag)); - ev.Handled = true; - }; - - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new Archive(this, tag)); - ev.Handled = true; - }; - - var 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 = "-" }); - - var actions = GetCustomActions(Models.CustomActionScope.Tag); - if (actions.Count > 0) - { - var custom = new MenuItem(); - custom.Header = App.Text("TagCM.CustomAction"); - custom.Icon = App.CreateMenuIcon("Icons.Action"); - - foreach (var action in actions) - { - var (dup, label) = action; - var item = new MenuItem(); - item.Icon = App.CreateMenuIcon("Icons.Action"); - item.Header = label; - item.Click += (_, e) => - { - ExecCustomAction(dup, tag); - e.Handled = true; - }; - - custom.Items.Add(item); - } - - menu.Items.Add(custom); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var copy = new MenuItem(); - copy.Header = App.Text("TagCM.Copy"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, ev) => - { - await App.CopyTextAsync(tag.Name); - ev.Handled = true; - }; - - var copyMessage = new MenuItem(); - copyMessage.Header = App.Text("TagCM.CopyMessage"); - copyMessage.Icon = App.CreateMenuIcon("Icons.Copy"); - copyMessage.IsEnabled = !string.IsNullOrEmpty(tag.Message); - copyMessage.Click += async (_, ev) => - { - await App.CopyTextAsync(tag.Message); - ev.Handled = true; - }; - - menu.Items.Add(copy); - menu.Items.Add(copyMessage); - return menu; - } - - public ContextMenu CreateContextMenuForBranchSortMode(bool local) - { - var mode = local ? _settings.LocalBranchSortMode : _settings.RemoteBranchSortMode; - var changeMode = new Action(m => - { - if (local) - { - _settings.LocalBranchSortMode = m; - OnPropertyChanged(nameof(IsSortingLocalBranchByName)); - } - else - { - _settings.RemoteBranchSortMode = m; - OnPropertyChanged(nameof(IsSortingRemoteBranchByName)); - } - - var builder = BuildBranchTree(_branches, _remotes); - LocalBranchTrees = builder.Locals; - RemoteBranchTrees = builder.Remotes; - }); - - var byNameAsc = new MenuItem(); - byNameAsc.Header = App.Text("Repository.BranchSort.ByName"); - if (mode == Models.BranchSortMode.Name) - byNameAsc.Icon = App.CreateMenuIcon("Icons.Check"); - byNameAsc.Click += (_, ev) => - { - if (mode != Models.BranchSortMode.Name) - changeMode(Models.BranchSortMode.Name); - - ev.Handled = true; - }; - - var byCommitterDate = new MenuItem(); - byCommitterDate.Header = App.Text("Repository.BranchSort.ByCommitterDate"); - if (mode == Models.BranchSortMode.CommitterDate) - byCommitterDate.Icon = App.CreateMenuIcon("Icons.Check"); - byCommitterDate.Click += (_, ev) => - { - if (mode != Models.BranchSortMode.CommitterDate) - changeMode(Models.BranchSortMode.CommitterDate); - - ev.Handled = true; - }; - - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; - menu.Items.Add(byNameAsc); - menu.Items.Add(byCommitterDate); - return menu; - } - - public ContextMenu CreateContextMenuForTagSortMode() - { - var mode = _settings.TagSortMode; - var changeMode = new Action(m => - { - if (_settings.TagSortMode != m) - { - _settings.TagSortMode = m; - OnPropertyChanged(nameof(IsSortingTagsByName)); - VisibleTags = BuildVisibleTags(); - } - }); - - var byCreatorDate = new MenuItem(); - byCreatorDate.Header = App.Text("Repository.Tags.OrderByCreatorDate"); - if (mode == Models.TagSortMode.CreatorDate) - byCreatorDate.Icon = App.CreateMenuIcon("Icons.Check"); - byCreatorDate.Click += (_, ev) => - { - changeMode(Models.TagSortMode.CreatorDate); - ev.Handled = true; - }; - - var byName = new MenuItem(); - byName.Header = App.Text("Repository.Tags.OrderByName"); - if (mode == Models.TagSortMode.Name) - byName.Icon = App.CreateMenuIcon("Icons.Check"); - byName.Click += (_, ev) => - { - changeMode(Models.TagSortMode.Name); - ev.Handled = true; - }; - - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; - menu.Items.Add(byCreatorDate); - menu.Items.Add(byName); - return menu; - } - - public ContextMenu CreateContextMenuForSubmodule(Models.Submodule submodule) - { - var open = new MenuItem(); - open.Header = App.Text("Submodule.Open"); - open.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - open.IsEnabled = submodule.Status != Models.SubmoduleStatus.NotInited; - open.Click += (_, ev) => - { - OpenSubmodule(submodule.Path); - ev.Handled = true; - }; - - var update = new MenuItem(); - update.Header = App.Text("Submodule.Update"); - update.Icon = App.CreateMenuIcon("Icons.Loading"); - update.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new UpdateSubmodules(this, submodule)); - ev.Handled = true; - }; - - var move = new MenuItem(); - move.Header = App.Text("Submodule.Move"); - move.Icon = App.CreateMenuIcon("Icons.MoveTo"); - move.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new MoveSubmodule(this, submodule)); - ev.Handled = true; - }; - - var setURL = new MenuItem(); - setURL.Header = App.Text("Submodule.SetURL"); - setURL.Icon = App.CreateMenuIcon("Icons.Edit"); - setURL.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new ChangeSubmoduleUrl(this, submodule)); - ev.Handled = true; - }; - - var setBranch = new MenuItem(); - setBranch.Header = App.Text("Submodule.SetBranch"); - setBranch.Icon = App.CreateMenuIcon("Icons.Track"); - setBranch.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new SetSubmoduleBranch(this, submodule)); - ev.Handled = true; - }; - - var deinit = new MenuItem(); - deinit.Header = App.Text("Submodule.Deinit"); - deinit.Icon = App.CreateMenuIcon("Icons.Undo"); - deinit.IsEnabled = submodule.Status != Models.SubmoduleStatus.NotInited; - deinit.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new DeinitSubmodule(this, submodule.Path)); - ev.Handled = true; - }; - - var rm = new MenuItem(); - rm.Header = App.Text("Submodule.Remove"); - rm.Icon = App.CreateMenuIcon("Icons.Clear"); - rm.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new DeleteSubmodule(this, submodule.Path)); - ev.Handled = true; - }; - - var histories = new MenuItem(); - histories.Header = App.Text("Submodule.Histories"); - histories.Icon = App.CreateMenuIcon("Icons.Histories"); - histories.Click += (_, ev) => - { - App.ShowWindow(new FileHistories(this, submodule.Path)); - ev.Handled = true; - }; - - var copySHA = new MenuItem(); - copySHA.Header = App.Text("CommitDetail.Info.SHA"); - copySHA.Icon = App.CreateMenuIcon("Icons.Fingerprint"); - copySHA.Click += async (_, ev) => - { - await App.CopyTextAsync(submodule.SHA); - ev.Handled = true; - }; - - var copyRelativePath = new MenuItem(); - copyRelativePath.Header = App.Text("Submodule.CopyPath"); - copyRelativePath.Icon = App.CreateMenuIcon("Icons.Folder"); - copyRelativePath.Click += async (_, ev) => - { - await App.CopyTextAsync(submodule.Path); - ev.Handled = true; - }; - - var copyURL = new MenuItem(); - copyURL.Header = App.Text("Submodule.URL"); - copyURL.Icon = App.CreateMenuIcon("Icons.Link"); - copyURL.Click += async (_, ev) => - { - await App.CopyTextAsync(submodule.URL); - ev.Handled = true; - }; - - var copyBranch = new MenuItem(); - copyBranch.Header = App.Text("Submodule.Branch"); - copyBranch.Icon = App.CreateMenuIcon("Icons.Branch"); - copyBranch.Click += async (_, ev) => - { - await App.CopyTextAsync(submodule.Branch); - ev.Handled = true; - }; - - var copy = new MenuItem(); - copy.Header = App.Text("Copy"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Items.Add(copySHA); - copy.Items.Add(copyBranch); - copy.Items.Add(copyRelativePath); - copy.Items.Add(copyURL); - - var menu = new ContextMenu(); - menu.Items.Add(open); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(update); - menu.Items.Add(setURL); - menu.Items.Add(setBranch); - menu.Items.Add(move); - menu.Items.Add(deinit); - menu.Items.Add(rm); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(histories); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copy); - return menu; - } - - public ContextMenu CreateContextMenuForWorktree(Models.Worktree worktree) - { - var menu = new ContextMenu(); - - if (worktree.IsLocked) - { - var unlock = new MenuItem(); - unlock.Header = App.Text("Worktree.Unlock"); - unlock.Icon = App.CreateMenuIcon("Icons.Unlock"); - unlock.Click += async (_, ev) => - { - SetWatcherEnabled(false); - var log = CreateLog("Unlock Worktree"); - var succ = await new Commands.Worktree(_fullpath).Use(log).UnlockAsync(worktree.FullPath); - if (succ) - worktree.IsLocked = false; - log.Complete(); - SetWatcherEnabled(true); - ev.Handled = true; - }; - menu.Items.Add(unlock); - } - else - { - var loc = new MenuItem(); - loc.Header = App.Text("Worktree.Lock"); - loc.Icon = App.CreateMenuIcon("Icons.Lock"); - loc.Click += async (_, ev) => - { - SetWatcherEnabled(false); - var log = CreateLog("Lock Worktree"); - var succ = await new Commands.Worktree(_fullpath).Use(log).LockAsync(worktree.FullPath); - if (succ) - worktree.IsLocked = true; - log.Complete(); - SetWatcherEnabled(true); - ev.Handled = true; - }; - menu.Items.Add(loc); - } - - var remove = new MenuItem(); - remove.Header = App.Text("Worktree.Remove"); - remove.Icon = App.CreateMenuIcon("Icons.Clear"); - remove.Click += (_, ev) => - { - if (CanCreatePopup()) - ShowPopup(new RemoveWorktree(this, worktree)); - ev.Handled = true; - }; - menu.Items.Add(remove); - - var copy = new MenuItem(); - copy.Header = App.Text("Worktree.CopyPath"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += async (_, e) => - { - await App.CopyTextAsync(worktree.FullPath); - e.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copy); - - return menu; - } - - private LauncherPage GetOwnerPage() - { - var launcher = App.GetLauncher(); - if (launcher == null) - return null; - - foreach (var page in launcher.Pages) - { - if (page.Node.Id.Equals(_fullpath)) - return page; - } - - return null; - } - - private BranchTreeNode.Builder BuildBranchTree(List branches, List remotes) - { - var builder = new BranchTreeNode.Builder(_settings.LocalBranchSortMode, _settings.RemoteBranchSortMode); - if (string.IsNullOrEmpty(_filter)) - { - builder.SetExpandedNodes(_settings.ExpandedBranchNodesInSideBar); - builder.Run(branches, remotes, false); - - foreach (var invalid in builder.InvalidExpandedNodes) - _settings.ExpandedBranchNodesInSideBar.Remove(invalid); - } - else - { - var visibles = new List(); - foreach (var b in branches) - { - if (b.FullName.Contains(_filter, StringComparison.OrdinalIgnoreCase)) - visibles.Add(b); - } - - builder.Run(visibles, remotes, true); - } - - var historiesFilters = _settings.CollectHistoriesFilters(); - UpdateBranchTreeFilterMode(builder.Locals, historiesFilters); - UpdateBranchTreeFilterMode(builder.Remotes, historiesFilters); - return builder; - } - - private object BuildVisibleTags() - { - switch (_settings.TagSortMode) - { - case Models.TagSortMode.CreatorDate: - _tags.Sort((l, r) => r.CreatorDate.CompareTo(l.CreatorDate)); - break; - default: - _tags.Sort((l, r) => Models.NumericSort.Compare(l.Name, r.Name)); - break; - } - - var visible = new List(); - if (string.IsNullOrEmpty(_filter)) - { - visible.AddRange(_tags); - } - else - { - foreach (var t in _tags) - { - if (t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase)) - visible.Add(t); - } - } - - var historiesFilters = _settings.CollectHistoriesFilters(); - UpdateTagFilterMode(historiesFilters); - - if (Preferences.Instance.ShowTagsAsTree) - return TagCollectionAsTree.Build(visible, _visibleTags as TagCollectionAsTree); - else - return new TagCollectionAsList() { Tags = visible }; - } - - private object BuildVisibleSubmodules() - { - var visible = new List(); - if (string.IsNullOrEmpty(_filter)) - { - visible.AddRange(_submodules); - } - else - { - foreach (var s in _submodules) - { - if (s.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) - visible.Add(s); - } - } - - if (Preferences.Instance.ShowSubmodulesAsTree) - return SubmoduleCollectionAsTree.Build(visible, _visibleSubmodules as SubmoduleCollectionAsTree); - else - return new SubmoduleCollectionAsList() { Submodules = visible }; - } - - private void RefreshHistoriesFilters(bool refresh) - { - if (_settings.HistoriesFilters.Count > 0) - HistoriesFilterMode = _settings.HistoriesFilters[0].Mode; - else - HistoriesFilterMode = Models.FilterMode.None; - - if (!refresh) - return; - - var filters = _settings.CollectHistoriesFilters(); - UpdateBranchTreeFilterMode(LocalBranchTrees, filters); - UpdateBranchTreeFilterMode(RemoteBranchTrees, filters); - UpdateTagFilterMode(filters); - - Task.Run(RefreshCommits); - } - - private void UpdateBranchTreeFilterMode(List nodes, Dictionary filters) + private void UpdateBranchTreeFilterMode(List nodes, Dictionary map) { foreach (var node in nodes) { - node.FilterMode = filters.GetValueOrDefault(node.Path, 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) { - tag.FilterMode = filters.GetValueOrDefault(tag.Name, 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); } } @@ -2953,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)) @@ -2975,169 +1861,101 @@ private BranchTreeNode FindBranchNode(List nodes, string path) return null; } - private void TryToAddCustomActionsToBranchContextMenu(ContextMenu menu, Models.Branch branch) + private void AutoFetchByTimer(object sender) { - var actions = GetCustomActions(Models.CustomActionScope.Branch); - if (actions.Count == 0) - return; - - var custom = new MenuItem(); - custom.Header = App.Text("BranchCM.CustomAction"); - custom.Icon = App.CreateMenuIcon("Icons.Action"); - - foreach (var action in actions) + try { - var (dup, label) = action; - var item = new MenuItem(); - item.Icon = App.CreateMenuIcon("Icons.Action"); - item.Header = label; - item.Click += (_, e) => - { - ExecCustomAction(dup, branch); - e.Handled = true; - }; - - custom.Items.Add(item); + Dispatcher.UIThread.Invoke(AutoFetchOnUIThread); } - - menu.Items.Add(custom); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - private bool IsSearchingCommitsByFilePath() - { - return _isSearching && _searchCommitFilterType == (int)Models.CommitSearchMethod.ByPath; - } - - private void CalcWorktreeFilesForSearching() - { - if (!IsSearchingCommitsByFilePath()) + catch { - _requestingWorktreeFiles = false; - _worktreeFiles = null; - MatchedFilesForSearching = null; - GC.Collect(); - return; + // Ignore exception. } - - if (_requestingWorktreeFiles) - return; - - _requestingWorktreeFiles = true; - - Task.Run(async () => - { - _worktreeFiles = await new Commands.QueryRevisionFileNames(_fullpath, "HEAD") - .GetResultAsync() - .ConfigureAwait(false); - - Dispatcher.UIThread.Post(() => - { - if (IsSearchingCommitsByFilePath() && _requestingWorktreeFiles) - CalcMatchedFilesForSearching(); - - _requestingWorktreeFiles = false; - }); - }); } - private void CalcMatchedFilesForSearching() + private async Task AutoFetchOnUIThread() { - if (_worktreeFiles == null || _worktreeFiles.Count == 0 || _searchCommitFilter.Length < 3) - { - MatchedFilesForSearching = null; + if (IsAutoFetching) return; - } - - var matched = new List(); - foreach (var file in _worktreeFiles) - { - if (file.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) && file.Length != _searchCommitFilter.Length) - { - matched.Add(file); - if (matched.Count > 100) - break; - } - } - MatchedFilesForSearching = matched; - } + CommandLog log = null; - private async void AutoFetchImpl(object sender) - { try { - if (!_settings.EnableAutoFetch || _isAutoFetching) + if (!Preferences.Instance.EnableAutoFetch || !CanCreatePopup()) + { + _lastFetchTime = DateTime.Now; return; + } - var lockFile = Path.Combine(_gitDir, "index.lock"); + var lockFile = Path.Combine(GitDir, "index.lock"); if (File.Exists(lockFile)) return; var now = DateTime.Now; - var desire = _lastFetchTime.AddMinutes(_settings.AutoFetchInterval); + var desire = _lastFetchTime.AddMinutes(Preferences.Instance.AutoFetchInterval); if (desire > now) return; var remotes = new List(); - lock (_lockRemotes) + foreach (var r in _remotes) { - foreach (var remote in _remotes) - remotes.Add(remote.Name); + if (!r.DisableAutoFetch) + remotes.Add(r.Name); } - Dispatcher.UIThread.Invoke(() => IsAutoFetching = true); + if (remotes.Count == 0) + return; + + IsAutoFetching = true; + log = CreateLog("Auto-Fetch"); + foreach (var remote in remotes) - await new Commands.Fetch(_fullpath, remote, false, false) { RaiseError = false }.RunAsync(); + await new Commands.Fetch(FullPath, remote).Use(log).RunAsync(); + _lastFetchTime = DateTime.Now; - Dispatcher.UIThread.Invoke(() => IsAutoFetching = false); } catch { - // DO nothing, but prevent `System.AggregateException` + // Ignore all exceptions. } + + IsAutoFetching = false; + 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 int _searchCommitFilterType = (int)Models.CommitSearchMethod.ByMessage; - private bool _onlySearchCommitsInCurrentBranch = false; - private string _searchCommitFilter = string.Empty; - private List _searchedCommits = new List(); - private Models.Commit _selectedSearchedCommit = null; - private bool _requestingWorktreeFiles = false; - private List _worktreeFiles = null; - private List _matchedFilesForSearching = null; + private bool _isSearchingCommits = false; + private SearchCommitContext _searchCommitContext = null; private string _filter = string.Empty; - private readonly Lock _lockRemotes = new(); - 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 _localBranchTrees = []; + private List _remoteBranchTrees = []; + private List _worktrees = []; + private List _tags = []; private object _visibleTags = null; - private List _submodules = new List(); + private List _submodules = []; private object _visibleSubmodules = null; + private string _navigateToCommitDelayed = string.Empty; private bool _isAutoFetching = false; private Timer _autoFetchTimer = null; @@ -3146,6 +1964,10 @@ private async void AutoFetchImpl(object sender) private Models.BisectState _bisectState = Models.BisectState.None; private bool _isBisectCommandRunning = false; - private string _navigateToCommitDelayed = string.Empty; + 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 4bf5731f2..a76194716 100644 --- a/src/ViewModels/RepositoryConfigure.cs +++ b/src/ViewModels/RepositoryConfigure.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading.Tasks; using Avalonia.Collections; @@ -75,30 +76,35 @@ public string HttpProxy set => SetProperty(ref _httpProxy, value); } + public string ConventionalTypesOverride + { + get => _repo.Settings.ConventionalTypesOverride; + set + { + if (_repo.Settings.ConventionalTypesOverride != value) + { + _repo.Settings.ConventionalTypesOverride = value; + OnPropertyChanged(); + } + } + } + public bool EnablePruneOnFetch { get; set; } - public 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 @@ -112,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 @@ -161,7 +167,7 @@ public RepositoryConfigure(Repository repo) if (!AvailableOpenAIServices.Contains(PreferredOpenAIService)) PreferredOpenAIService = "---"; - _cached = new Commands.Config(repo.FullPath).ReadAllAsync().Result; + _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)) @@ -176,6 +182,17 @@ public RepositoryConfigure(Repository repo) 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() @@ -197,102 +214,37 @@ public void RemoveSelectedCommitTemplate() SelectedCommitTemplate = null; } - public void AddSampleGitHubIssueTracker() - { - var link = "https://github.com/username/repository/issues/$1"; - foreach (var remote in _repo.Remotes) - { - if (remote.URL.Contains("github.com", System.StringComparison.Ordinal) && - remote.TryGetVisitURL(out string url)) - { - link = $"{url}/issues/$1"; - break; - } - } - - SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("GitHub ISSUE", @"#(\d+)", link); - } - - public void AddSampleJiraIssueTracker() - { - SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Jira Tracker", @"PROJ-(\d+)", "https://jira.yourcompany.com/browse/PROJ-$1"); - } - - public void AddSampleAzureWorkItemTracker() - { - SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Azure DevOps Tracker", @"#(\d+)", "https://dev.azure.com/yourcompany/workspace/_workitems/edit/$1"); - } - - public void AddSampleGitLabIssueTracker() - { - var link = "https://gitlab.com/username/repository/-/issues/$1"; - foreach (var remote in _repo.Remotes) - { - if (remote.TryGetVisitURL(out string url)) - { - link = $"{url}/-/issues/$1"; - break; - } - } - - SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("GitLab ISSUE", @"#(\d+)", link); - } - - public void AddSampleGitLabMergeRequestTracker() - { - var link = "https://gitlab.com/username/repository/-/merge_requests/$1"; - foreach (var remote in _repo.Remotes) - { - if (remote.TryGetVisitURL(out string url)) - { - link = $"{url}/-/merge_requests/$1"; - break; - } - } - - SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("GitLab MR", @"!(\d+)", link); - } - - public void AddSampleGiteeIssueTracker() + public List GetRemoteVisitUrls() { - var link = "https://gitee.com/username/repository/issues/$1"; + var outs = new List(); foreach (var remote in _repo.Remotes) { - if (remote.URL.Contains("gitee.com", System.StringComparison.Ordinal) && - remote.TryGetVisitURL(out string url)) - { - link = $"{url}/issues/$1"; - break; - } + if (remote.TryGetVisitURL(out var url)) + outs.Add(url); } - - SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Gitee ISSUE", @"#([0-9A-Z]{6,10})", link); + return outs; } - public void AddSampleGiteePullRequestTracker() + public void AddIssueTracker(string name, string regex, string url) { - var link = "https://gitee.com/username/repository/pulls/$1"; - foreach (var remote in _repo.Remotes) + var rule = new Models.IssueTracker() { - if (remote.URL.Contains("gitee.com", System.StringComparison.Ordinal) && - remote.TryGetVisitURL(out string url)) - { - link = $"{url}/pulls/$1"; - } - } + IsShared = false, + Name = name, + RegexString = regex, + URLTemplate = url, + }; - SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Gitee Pull Request", @"!(\d+)", link); + IssueTrackers.Add(rule); + SelectedIssueTracker = rule; } - public void NewIssueTracker() + public void RemoveIssueTracker() { - SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("New Issue Tracker", @"#(\d+)", "https://xxx/$1"); - } + if (_selectedIssueTracker is { } rule) + IssueTrackers.Remove(rule); - public void RemoveSelectedIssueTracker() - { - _repo.Settings.RemoveIssueTracker(_selectedIssueTrackerRule); - SelectedIssueTrackerRule = null; + SelectedIssueTracker = null; } public void AddNewCustomAction() @@ -320,6 +272,8 @@ public void MoveSelectedCustomActionDown() public async Task SaveAsync() { + _repo.Settings.Save(); + await SetIfChangedAsync("user.name", UserName, ""); await SetIfChangedAsync("user.email", UserEmail, ""); await SetIfChangedAsync("commit.gpgsign", GPGCommitSigningEnabled ? "true" : "false", "false"); @@ -327,6 +281,8 @@ public async Task SaveAsync() await SetIfChangedAsync("user.signingkey", GPGUserSigningKey, ""); await SetIfChangedAsync("http.proxy", HttpProxy, ""); await SetIfChangedAsync("fetch.prune", EnablePruneOnFetch ? "true" : "false", "false"); + + await ApplyIssueTrackerChangesAsync(); } private async Task SetIfChangedAsync(string key, string value, string defValue) @@ -335,11 +291,67 @@ private async Task SetIfChangedAsync(string key, string value, string defValue) await new Commands.Config(_repo.FullPath).SetAsync(key, value); } - private readonly Repository _repo = null; - private readonly Dictionary _cached = null; + private async Task ApplyIssueTrackerChangesAsync() + { + var changed = false; + var oldRules = new Dictionary(); + foreach (var rule in _repo.IssueTrackers) + oldRules.Add(rule.Name, rule); + + foreach (var rule in IssueTrackers) + { + if (oldRules.TryGetValue(rule.Name, out var old)) + { + if (old.IsShared != rule.IsShared) + { + changed = true; + await new Commands.IssueTracker(_repo.FullPath, old.IsShared).RemoveAsync(old.Name); + await new Commands.IssueTracker(_repo.FullPath, rule.IsShared).AddAsync(rule); + } + else + { + if (!old.RegexString.Equals(rule.RegexString, StringComparison.Ordinal)) + { + changed = true; + await new Commands.IssueTracker(_repo.FullPath, old.IsShared).UpdateRegexAsync(rule); + } + + if (!old.URLTemplate.Equals(rule.URLTemplate, StringComparison.Ordinal)) + { + changed = true; + await new Commands.IssueTracker(_repo.FullPath, old.IsShared).UpdateURLTemplateAsync(rule); + } + } + + oldRules.Remove(rule.Name); + } + else + { + changed = true; + await new Commands.IssueTracker(_repo.FullPath, rule.IsShared).AddAsync(rule); + } + } + + if (oldRules.Count > 0) + { + changed = true; + + foreach (var kv in oldRules) + await new Commands.IssueTracker(_repo.FullPath, kv.Value.IsShared).RemoveAsync(kv.Key); + } + + if (changed) + { + _repo.IssueTrackers.Clear(); + _repo.IssueTrackers.AddRange(IssueTrackers); + } + } + + 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 c65d1dbd0..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 @@ -55,6 +64,13 @@ public bool IsInvalid get => _isRepository && !Directory.Exists(_id); } + [JsonIgnore] + public bool IsUnmanaged + { + get; + set; + } = false; + [JsonIgnore] public int Depth { @@ -62,12 +78,31 @@ 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() { var activePage = App.GetLauncher().ActivePage; @@ -82,18 +117,23 @@ public void AddSubFolder() 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() @@ -103,11 +143,94 @@ public void Delete() 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; private string _name = string.Empty; private bool _isRepository = false; 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 5a628b1ea..11adf1ea7 100644 --- a/src/ViewModels/Reset.cs +++ b/src/ViewModels/Reset.cs @@ -30,7 +30,7 @@ public Reset(Repository repo, Models.Branch current, Models.Commit to) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Reset current branch to {To.SHA} ..."; var log = _repo.CreateLog($"Reset HEAD to '{To.SHA}'"); @@ -40,8 +40,9 @@ public override async Task Sure() .Use(log) .ExecAsync(); + await _repo.AutoUpdateSubmodulesAsync(log); + log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/ResetWithoutCheckout.cs b/src/ViewModels/ResetWithoutCheckout.cs index f9b119f91..fb32265b3 100644 --- a/src/ViewModels/ResetWithoutCheckout.cs +++ b/src/ViewModels/ResetWithoutCheckout.cs @@ -32,7 +32,7 @@ public ResetWithoutCheckout(Repository repo, Models.Branch target, Models.Commit public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Reset {Target.Name} to {_revision} ..."; var log = _repo.CreateLog($"Reset '{Target.Name}' to '{_revision}'"); @@ -44,7 +44,6 @@ public override async Task Sure() log.Complete(); _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/Revert.cs b/src/ViewModels/Revert.cs index b39076a09..ea6f86d09 100644 --- a/src/ViewModels/Revert.cs +++ b/src/ViewModels/Revert.cs @@ -24,7 +24,7 @@ public Revert(Repository repo, Models.Commit target) public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); _repo.ClearCommitMessage(); ProgressDescription = $"Revert commit '{Target.SHA}' ..."; @@ -36,7 +36,6 @@ public override async Task Sure() .ExecAsync(); log.Complete(); - _repo.SetWatcherEnabled(true); return true; } diff --git a/src/ViewModels/RevisionCompare.cs b/src/ViewModels/RevisionCompare.cs index 18e8044ba..ef38668cc 100644 --- a/src/ViewModels/RevisionCompare.cs +++ b/src/ViewModels/RevisionCompare.cs @@ -3,14 +3,12 @@ using System.IO; using System.Threading.Tasks; -using Avalonia.Controls; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public class RevisionCompare : ObservableObject, IDisposable + public class RevisionCompare : ObservableObject { public bool IsLoading { @@ -30,7 +28,36 @@ public object EndPoint private set => SetProperty(ref _endPoint, value); } - public bool CanSaveAsPatch { get; } + 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 => _startPoint != null && _endPoint != null; + } + + public int TotalChanges + { + get => _totalChanges; + private set => SetProperty(ref _totalChanges, value); + } public List VisibleChanges { @@ -48,7 +75,7 @@ public List SelectedChanges 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 { @@ -74,42 +101,45 @@ 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; - - Task.Run(Refresh); + Refresh(); } - public void Dispose() + public RevisionCompare Clone() { - _repo = null; - _startPoint = null; - _endPoint = null; - _changes?.Clear(); - _visibleChanges?.Clear(); - _selectedChanges?.Clear(); - _searchFilter = null; - _diffContext = null; + return new RevisionCompare(_repo, _startPoint as Models.Commit, _endPoint as Models.Commit); } - public void NavigateTo(string commitSHA) + public void SetTargets(Models.Commit l, Models.Commit r) { - var launcher = App.GetLauncher(); - if (launcher == null) + 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; - foreach (var page in launcher.Pages) - { - if (page.Data is Repository repo && repo.FullPath.Equals(_repo)) - { - repo.NavigateToCommit(commitSHA); - break; - } - } + _startPoint = l; + _endPoint = r; + Refresh(); + } + + public void OpenChangeWithExternalDiffTool(Models.Change change) + { + var opt = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), change); + new Commands.DiffTool(_repo.FullPath, opt).Open(); + } + + public void NavigateTo(string commitSHA) + { + _repo?.NavigateToCommit(commitSHA); } public void Swap() @@ -118,84 +148,180 @@ public void Swap() VisibleChanges = []; SelectedChanges = []; IsLoading = true; - Task.Run(Refresh); + Refresh(); } - public void SaveAsPatch(string saveTo) + public string GetAbsPath(string path) { - Task.Run(async () => - { - var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo, _changes, GetSHA(_startPoint), GetSHA(_endPoint), saveTo); - if (succ) - 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 is not { Count: 1 }) - return null; - - var change = _selectedChanges[0]; - var menu = new ContextMenu(); + var sha = GetSHA(_endPoint); + var log = _repo.CreateLog($"Reset File to '{GetDesc(_endPoint)}'"); - var openWithMerger = new MenuItem(); - openWithMerger.Header = App.Text("OpenInExternalMergeTool"); - openWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; - openWithMerger.Click += (_, ev) => + if (change.Index == Models.ChangeState.Deleted) { - var opt = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), change); - var toolType = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - new Commands.DiffTool(_repo, toolType, toolPath, opt).Open(); - ev.Handled = true; - }; - menu.Items.Add(openWithMerger); - - 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 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 old = Native.OS.GetAbsPath(_repo.FullPath, change.OriginalPath); + if (File.Exists(old)) + await new Commands.Remove(_repo.FullPath, [change.OriginalPath]) + .Use(log) + .ExecAsync(); + + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, sha); } + else + { + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, sha); + } + + log.Complete(); + } + + public async Task ResetMultipleToLeftAsync(List changes) + { + var sha = GetSHA(_startPoint); + var checkouts = new List(); + var removes = new List(); - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, ev) => + foreach (var c in changes) { - await App.CopyTextAsync(change.Path); - ev.Handled = true; - }; - menu.Items.Add(copyPath); - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => + if (c.Index == Models.ChangeState.Added) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(old)) + removes.Add(c.Path); + + checkouts.Add(c.OriginalPath); + } + else + { + checkouts.Add(c.Path); + } + } + + var log = _repo.CreateLog($"Reset Files to '{GetDesc(_startPoint)}'"); + + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); + + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, sha); + + log.Complete(); + } + + public async Task ResetMultipleToRightAsync(List changes) + { + var sha = GetSHA(_endPoint); + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) { - await App.CopyTextAsync(Native.OS.GetAbsPath(_repo, change.Path)); - e.Handled = true; - }; - menu.Items.Add(copyFullPath); + 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)}'"); - return menu; + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); + + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, sha); + + log.Complete(); + } + + public async Task SaveChangesAsPatchAsync(List changes, string saveTo) + { + var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo.FullPath, changes ?? _changes, GetSHA(_startPoint), GetSHA(_endPoint), saveTo); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); + } + + public void ClearSearchFilter() + { + SearchFilter = string.Empty; } private void RefreshVisible() @@ -222,28 +348,34 @@ private void RefreshVisible() private void Refresh() { - _changes = new Commands.CompareRevisions(_repo, GetSHA(_startPoint), GetSHA(_endPoint)).ReadAsync().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.Post(() => - { - VisibleChanges = visible; - IsLoading = false; + Dispatcher.UIThread.Post(() => + { + TotalChanges = _changes.Count; + VisibleChanges = visible; + IsLoading = false; - if (VisibleChanges.Count > 0) - SelectedChanges = [VisibleChanges[0]]; - else - SelectedChanges = []; + if (VisibleChanges.Count > 0) + SelectedChanges = [VisibleChanges[0]]; + else + SelectedChanges = []; + }); }); } @@ -252,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 int _totalChanges = 0; private List _changes = null; private List _visibleChanges = null; private List _selectedChanges = null; diff --git a/src/ViewModels/RevisionLFSImage.cs b/src/ViewModels/RevisionLFSImage.cs index 2cdd8e665..2e51c69ce 100644 --- a/src/ViewModels/RevisionLFSImage.cs +++ b/src/ViewModels/RevisionLFSImage.cs @@ -25,7 +25,7 @@ public RevisionLFSImage(string repo, string file, Models.LFSObject lfs, Models.I { var source = await ImageSource.FromLFSObjectAsync(repo, lfs, decoder).ConfigureAwait(false); var img = new Models.RevisionImageFile(file, source.Bitmap, source.Size); - Dispatcher.UIThread.Invoke(() => Image = img); + Dispatcher.UIThread.Post(() => Image = img); }); } diff --git a/src/ViewModels/Reword.cs b/src/ViewModels/Reword.cs deleted file mode 100644 index 2ce278531..000000000 --- a/src/ViewModels/Reword.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; - -namespace SourceGit.ViewModels -{ - public class Reword : Popup - { - public Models.Commit Head - { - get; - } - - [Required(ErrorMessage = "Commit message is required!!!")] - public string Message - { - get => _message; - set => SetProperty(ref _message, value, true); - } - - public Reword(Repository repo, Models.Commit head) - { - _repo = repo; - _oldMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, head.SHA).GetResultAsync().Result; - _message = _oldMessage; - Head = head; - } - - public override async Task Sure() - { - if (string.Compare(_message, _oldMessage, StringComparison.Ordinal) == 0) - return true; - - _repo.SetWatcherEnabled(false); - ProgressDescription = "Editing HEAD message ..."; - - var log = _repo.CreateLog("Reword HEAD"); - Use(log); - - var changes = await new Commands.QueryLocalChanges(_repo.FullPath, false).GetResultAsync(); - var signOff = _repo.Settings.EnableSignOffForCommit; - var needAutoStash = false; - var succ = false; - - foreach (var c in changes) - { - if (c.Index != Models.ChangeState.None) - { - needAutoStash = true; - break; - } - } - - if (needAutoStash) - { - succ = await new Commands.Stash(_repo.FullPath) - .Use(log) - .PushAsync("REWORD_AUTO_STASH"); - if (!succ) - { - log.Complete(); - _repo.SetWatcherEnabled(true); - return false; - } - } - - succ = await new Commands.Commit(_repo.FullPath, _message, signOff, true, false) - .Use(log) - .RunAsync(); - - if (succ && needAutoStash) - await new Commands.Stash(_repo.FullPath) - .Use(log) - .PopAsync("stash@{0}"); - - log.Complete(); - _repo.SetWatcherEnabled(true); - return succ; - } - - private readonly Repository _repo; - private readonly string _oldMessage; - private string _message; - } -} diff --git a/src/ViewModels/ScanRepositories.cs b/src/ViewModels/ScanRepositories.cs index b01547707..f9ac3e41b 100644 --- a/src/ViewModels/ScanRepositories.cs +++ b/src/ViewModels/ScanRepositories.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.IO; using System.Threading.Tasks; @@ -8,12 +8,23 @@ namespace SourceGit.ViewModels { public class ScanRepositories : Popup { + public bool UseCustomDir + { + get => _useCustomDir; + set => SetProperty(ref _useCustomDir, value); + } + + public string CustomDir + { + get => _customDir; + set => SetProperty(ref _customDir, value); + } + public List ScanDirs { get; } - [Required(ErrorMessage = "Scan directory is required!!!")] public Models.ScanDir Selected { get => _selected; @@ -33,16 +44,43 @@ public ScanRepositories() if (ScanDirs.Count > 0) _selected = ScanDirs[0]; + else + _useCustomDir = true; GetManagedRepositories(Preferences.Instance.RepositoryNodes, _managed); } public override async Task Sure() { - ProgressDescription = $"Scan repositories under '{_selected.Path}' ..."; + string selectedDir; + if (_useCustomDir) + { + if (string.IsNullOrEmpty(_customDir)) + { + Models.Notification.Send(null, "Missing root directory to scan!", true); + return false; + } + + selectedDir = _customDir; + } + else + { + if (_selected == null || string.IsNullOrEmpty(_selected.Path)) + { + Models.Notification.Send(null, "Missing root directory to scan!", true); + return false; + } + + selectedDir = _selected.Path; + } + + if (!Directory.Exists(selectedDir)) + return true; + + ProgressDescription = $"Scan repositories under '{selectedDir}' ..."; var minDelay = Task.Delay(500); - var rootDir = new DirectoryInfo(_selected.Path); + var rootDir = new DirectoryInfo(selectedDir); var found = new List(); await GetUnmanagedRepositoriesAsync(rootDir, found, new EnumerationOptions() @@ -55,18 +93,22 @@ public override async Task Sure() await minDelay; var normalizedRoot = rootDir.FullName.Replace('\\', '/').TrimEnd('/'); - foreach (var f in found) + 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.Ordinal)) + if (parent.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase)) { - Preferences.Instance.FindOrAddNodeByRepositoryPath(f, null, false, false); + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(f, null, false, false); + await node.UpdateStatusAsync(false, null); } - else if (parent.StartsWith(normalizedRoot, StringComparison.Ordinal)) + else if (parent.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) { var relative = parent.Substring(normalizedRoot.Length).TrimStart('/'); var group = FindOrCreateGroupRecursive(Preferences.Instance.RepositoryNodes, relative); - Preferences.Instance.FindOrAddNodeByRepositoryPath(f, group, false, false); + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(f, group, false, false); + await node.UpdateStatusAsync(false, null); } } @@ -81,7 +123,7 @@ 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); } @@ -99,24 +141,24 @@ private async Task GetUnmanagedRepositoriesAsync(DirectoryInfo dir, List ProgressDescription = $"Scanning {subdir.FullName}..."; var normalizedSelf = subdir.FullName.Replace('\\', '/').TrimEnd('/'); - if (_managed.Contains(normalizedSelf)) + if (IsManaged(normalizedSelf)) continue; var gitDir = Path.Combine(subdir.FullName, ".git"); if (Directory.Exists(gitDir) || File.Exists(gitDir)) { - var test = await new Commands.QueryRepositoryRootPath(subdir.FullName).GetResultAsync().ConfigureAwait(false); + var test = await new Commands.QueryRepositoryRootPath(subdir.FullName).GetResultAsync(); if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) { var normalized = test.StdOut.Trim().Replace('\\', '/').TrimEnd('/'); - if (!_managed.Contains(normalized)) + if (!IsManaged(normalized)) outs.Add(normalized); } continue; } - var isBare = await new Commands.IsBareRepository(subdir.FullName).GetResultAsync().ConfigureAwait(false); + var isBare = await new Commands.IsBareRepository(subdir.FullName).GetResultAsync(); if (isBare) { outs.Add(normalizedSelf); @@ -161,7 +203,17 @@ private RepositoryNode FindOrCreateGroup(List collection, string return added; } + private bool IsManaged(string path) + { + if (OperatingSystem.IsLinux()) + return _managed.Contains(path); + + return _managed.Contains(path.ToLower(CultureInfo.CurrentCulture)); + } + private HashSet _managed = new(); + private bool _useCustomDir = false; + private string _customDir = string.Empty; private Models.ScanDir _selected = null; } } diff --git a/src/ViewModels/SearchCommitContext.cs b/src/ViewModels/SearchCommitContext.cs new file mode 100644 index 000000000..e67046a9c --- /dev/null +++ b/src/ViewModels/SearchCommitContext.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class SearchCommitContext : ObservableObject + { + public int Method + { + get => _method; + set + { + if (SetProperty(ref _method, value)) + { + UpdateSuggestions(); + StartSearch(); + } + } + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateSuggestions(); + } + } + + public bool OnlySearchCurrentBranch + { + get => _onlySearchCurrentBranch; + set + { + if (SetProperty(ref _onlySearchCurrentBranch, value)) + StartSearch(); + } + } + + public List Suggestions + { + get => _suggestions; + private set => SetProperty(ref _suggestions, value); + } + + public bool IsQuerying + { + get => _isQuerying; + private set => SetProperty(ref _isQuerying, value); + } + + public List Results + { + get => _results; + private set => SetProperty(ref _results, value); + } + + public Models.Commit Selected + { + get => _selected; + set + { + if (SetProperty(ref _selected, value) && value != null) + _repo.NavigateToCommit(value.SHA); + } + } + + public SearchCommitContext(Repository repo) + { + _repo = repo; + } + + public void ClearFilter() + { + Filter = string.Empty; + Selected = null; + Results = null; + } + + public void ClearSuggestions() + { + Suggestions = null; + } + + public void StartSearch() + { + Results = null; + Selected = null; + Suggestions = null; + + if (string.IsNullOrEmpty(_filter)) + return; + + IsQuerying = true; + + if (_cancellation is { IsCancellationRequested: false }) + _cancellation.Cancel(); + + _cancellation = new(); + var token = _cancellation.Token; + + Task.Run(async () => + { + var result = new List(); + var method = (Models.CommitSearchMethod)_method; + var repoPath = _repo.FullPath; + + if (method == Models.CommitSearchMethod.BySHA) + { + var isCommitSHA = await new Commands.IsCommitSHA(repoPath, _filter) + .GetResultAsync() + .ConfigureAwait(false); + + if (isCommitSHA) + { + var commit = await new Commands.QuerySingleCommit(repoPath, _filter) + .GetResultAsync() + .ConfigureAwait(false); + + commit.IsMerged = await new Commands.IsAncestor(repoPath, commit.SHA, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + result.Add(commit); + } + } + else if (_onlySearchCurrentBranch) + { + result = await new Commands.QueryCommits(repoPath, _filter, method, true) + .GetResultAsync() + .ConfigureAwait(false); + + foreach (var c in result) + c.IsMerged = true; + } + else + { + result = await new Commands.QueryCommits(repoPath, _filter, method, false) + .GetResultAsync() + .ConfigureAwait(false); + + if (result.Count > 0) + { + var set = await new Commands.QueryCurrentBranchCommitHashes(repoPath, result[^1].CommitterTime) + .GetResultAsync() + .ConfigureAwait(false); + + foreach (var c in result) + c.IsMerged = set.Contains(c.SHA); + } + } + + Dispatcher.UIThread.Post(() => + { + if (token.IsCancellationRequested) + return; + + IsQuerying = false; + if (_repo.IsSearchingCommits) + { + Results = result; + if (method == Models.CommitSearchMethod.BySHA && result.Count == 1) + Selected = result[0]; + } + }); + }, token); + } + + public void EndSearch() + { + if (_cancellation is { IsCancellationRequested: false }) + _cancellation.Cancel(); + + _worktreeFiles = null; + _authors = null; + + IsQuerying = false; + Suggestions = null; + Results = null; + GC.Collect(); + } + + private void UpdateSuggestions() + { + if (_method == (int)Models.CommitSearchMethod.ByAuthor) + { + if (_authors == null) + { + if (_requestingAuthors) + return; + + _requestingAuthors = true; + + Task.Run(async () => + { + var authors = await new Commands.QueryAuthors(_repo.FullPath) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + _requestingAuthors = false; + + if (_repo.IsSearchingCommits) + { + _authors = authors; + UpdateSuggestions(); + } + }); + }); + + return; + } + + if (_authors.Count == 0 || _filter.Length < 2) + { + Suggestions = null; + return; + } + + var matched = new List(); + foreach (var author in _authors) + { + if (author.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase) || + author.Email.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + matched.Add(author); + } + + Suggestions = matched; + } + else if (_method == (int)Models.CommitSearchMethod.ByPath) + { + if (_worktreeFiles == null) + { + if (_requestingWorktreeFiles) + return; + + _requestingWorktreeFiles = true; + + Task.Run(async () => + { + var files = await new Commands.QueryRevisionFileNames(_repo.FullPath, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + _requestingWorktreeFiles = false; + + if (_repo.IsSearchingCommits) + { + _worktreeFiles = files; + UpdateSuggestions(); + } + }); + }); + + return; + } + + if (_worktreeFiles.Count == 0 || _filter.Length < 3) + { + Suggestions = null; + return; + } + + var matched = new List(); + foreach (var file in _worktreeFiles) + { + if (file.Contains(_filter, StringComparison.OrdinalIgnoreCase) && file.Length != _filter.Length) + matched.Add(file); + } + + Suggestions = matched; + } + else + { + Suggestions = null; + return; + } + } + + private Repository _repo = null; + private CancellationTokenSource _cancellation = null; + private int _method = (int)Models.CommitSearchMethod.ByMessage; + private string _filter = string.Empty; + private bool _onlySearchCurrentBranch = false; + private bool _isQuerying = false; + private List _results = null; + private Models.Commit _selected = null; + private bool _requestingWorktreeFiles = false; + private List _worktreeFiles = null; + private bool _requestingAuthors = false; + private List _authors = null; + private List _suggestions = null; + } +} diff --git a/src/ViewModels/SetSubmoduleBranch.cs b/src/ViewModels/SetSubmoduleBranch.cs index 5abeec87d..e6edd674d 100644 --- a/src/ViewModels/SetSubmoduleBranch.cs +++ b/src/ViewModels/SetSubmoduleBranch.cs @@ -30,8 +30,8 @@ public override async Task Sure() if (_changeTo.Equals(Submodule.Branch, StringComparison.Ordinal)) return true; + using var lockWatcher = _repo.LockWatcher(); var log = _repo.CreateLog("Set Submodule's Branch"); - _repo.SetWatcherEnabled(false); Use(log); var succ = await new Commands.Submodule(_repo.FullPath) @@ -39,7 +39,6 @@ public override async Task Sure() .SetBranchAsync(Submodule.Path, _changeTo); log.Complete(); - _repo.SetWatcherEnabled(true); return succ; } diff --git a/src/ViewModels/SetUpstream.cs b/src/ViewModels/SetUpstream.cs index 60ceb5137..e3ad871f2 100644 --- a/src/ViewModels/SetUpstream.cs +++ b/src/ViewModels/SetUpstream.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace SourceGit.ViewModels @@ -53,17 +54,24 @@ public SetUpstream(Repository repo, Models.Branch local, List rem public override async Task Sure() { ProgressDescription = "Setting upstream..."; + Models.Branch upstream = _unset ? null : SelectedRemoteBranch; - var upstream = (_unset || SelectedRemoteBranch == null) ? string.Empty : SelectedRemoteBranch.FullName; - if (upstream == Local.Upstream) + 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.Replace("refs/remotes/", "")); + .SetUpstreamAsync(upstream); log.Complete(); if (succ) diff --git a/src/ViewModels/Squash.cs b/src/ViewModels/Squash.cs deleted file mode 100644 index a575a64c8..000000000 --- a/src/ViewModels/Squash.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; - -namespace SourceGit.ViewModels -{ - public class Squash : Popup - { - public Models.Commit Target - { - get; - } - - [Required(ErrorMessage = "Commit message is required!!!")] - public string Message - { - get => _message; - set => SetProperty(ref _message, value, true); - } - - public Squash(Repository repo, Models.Commit target, string shaToGetPreferMessage) - { - _repo = repo; - _message = new Commands.QueryCommitFullMessage(_repo.FullPath, shaToGetPreferMessage).GetResultAsync().Result; - Target = target; - } - - public override async Task Sure() - { - _repo.SetWatcherEnabled(false); - ProgressDescription = "Squashing ..."; - - var log = _repo.CreateLog("Squash"); - Use(log); - - var changes = await new Commands.QueryLocalChanges(_repo.FullPath, false).GetResultAsync(); - var signOff = _repo.Settings.EnableSignOffForCommit; - var needAutoStash = false; - var succ = false; - - foreach (var c in changes) - { - if (c.Index != Models.ChangeState.None) - { - needAutoStash = true; - break; - } - } - - if (needAutoStash) - { - succ = await new Commands.Stash(_repo.FullPath) - .Use(log) - .PushAsync("SQUASH_AUTO_STASH"); - if (!succ) - { - log.Complete(); - _repo.SetWatcherEnabled(true); - return false; - } - } - - succ = await new Commands.Reset(_repo.FullPath, Target.SHA, "--soft") - .Use(log) - .ExecAsync(); - - if (succ) - succ = await new Commands.Commit(_repo.FullPath, _message, signOff, true, false) - .Use(log) - .RunAsync(); - - if (succ && needAutoStash) - await new Commands.Stash(_repo.FullPath) - .Use(log) - .PopAsync("stash@{0}"); - - log.Complete(); - _repo.SetWatcherEnabled(true); - return succ; - } - - private readonly Repository _repo; - private string _message; - } -} diff --git a/src/ViewModels/StashChanges.cs b/src/ViewModels/StashChanges.cs index 62e942f75..bde579b8a 100644 --- a/src/ViewModels/StashChanges.cs +++ b/src/ViewModels/StashChanges.cs @@ -15,23 +15,23 @@ 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; + get => _repo.UIStates.OnlyStagedWhenStash; set { - if (_repo.Settings.OnlyStagedWhenStash != value) + if (_repo.UIStates.OnlyStagedWhenStash != value) { - _repo.Settings.OnlyStagedWhenStash = value; + _repo.UIStates.OnlyStagedWhenStash = value; OnPropertyChanged(); } } @@ -39,20 +39,19 @@ public bool OnlyStaged public int ChangesAfterStashing { - get => _repo.Settings.ChangesAfterStashing; - set => _repo.Settings.ChangesAfterStashing = 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; + _changes = selectedChanges; } public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Stash changes ..."; var log = _repo.CreateLog("Stash Local Changes"); @@ -62,7 +61,7 @@ public override async Task Sure() var keepIndex = mode == DealWithChangesAfterStashing.KeepIndex; bool succ; - if (!HasSelectedFiles) + if (_changes == null) { if (OnlyStaged) { @@ -74,8 +73,12 @@ public override async Task Sure() } else { + var all = await new Commands.QueryLocalChanges(_repo.FullPath, false) + .Use(log) + .GetResultAsync(); + var staged = new List(); - foreach (var c in _changes) + foreach (var c in all) { if (c.Index != Models.ChangeState.None && c.Index != Models.ChangeState.Untracked) staged.Add(c); @@ -103,7 +106,7 @@ public override async Task Sure() log.Complete(); _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); + _repo.MarkStashesDirtyManually(); return succ; } diff --git a/src/ViewModels/StashesPage.cs b/src/ViewModels/StashesPage.cs index 414fb055d..9f4874dba 100644 --- a/src/ViewModels/StashesPage.cs +++ b/src/ViewModels/StashesPage.cs @@ -3,15 +3,12 @@ using System.IO; using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Platform.Storage; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public class StashesPage : ObservableObject, IDisposable + public class StashesPage : ObservableObject { public List Stashes { @@ -66,7 +63,7 @@ public Models.Stash SelectedStash var untracked = new List(); if (value.Parents.Count == 3) { - untracked = await new Commands.CompareRevisions(_repo.FullPath, Models.Commit.EmptyTreeSHA1, value.Parents[2]) + untracked = await new Commands.CompareRevisions(_repo.FullPath, value.UntrackedParent, value.Parents[2]) .ReadAsync() .ConfigureAwait(false); @@ -110,7 +107,7 @@ public List SelectedChanges if (value is not { Count: 1 }) DiffContext = null; else if (_untracked.Contains(value[0])) - DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(Models.Commit.EmptyTreeSHA1, _selectedStash.Parents[2], value[0]), _diffContext); + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_selectedStash.UntrackedParent, _selectedStash.Parents[2], value[0]), _diffContext); else DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, value[0]), _diffContext); } @@ -128,196 +125,134 @@ public StashesPage(Repository repo) _repo = repo; } - public void Dispose() + public void ClearSearchFilter() { - _stashes?.Clear(); - _changes?.Clear(); - _selectedChanges?.Clear(); - _untracked.Clear(); - - _repo = null; - _selectedStash = null; - _diffContext = null; + SearchFilter = string.Empty; } - public ContextMenu MakeContextMenu(Models.Stash stash) + public string GetAbsPath(string path) { - var apply = new MenuItem(); - apply.Header = App.Text("StashCM.Apply"); - apply.Icon = App.CreateMenuIcon("Icons.CheckCircled"); - apply.Click += (_, ev) => - { - Apply(stash); - ev.Handled = true; - }; - - var drop = new MenuItem(); - drop.Header = App.Text("StashCM.Drop"); - drop.Icon = App.CreateMenuIcon("Icons.Clear"); - drop.Tag = "Back/Delete"; - drop.Click += (_, ev) => - { - Drop(stash); - ev.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("StashCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("StashCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var opts = new List(); - foreach (var c in _changes) - { - if (_untracked.Contains(c)) - opts.Add(new Models.DiffOption(Models.Commit.EmptyTreeSHA1, _selectedStash.Parents[2], c)); - else - opts.Add(new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, c)); - } + return Native.OS.GetAbsPath(_repo.FullPath, path); + } - var succ = await Commands.SaveChangesAsPatch.ProcessStashChangesAsync(_repo.FullPath, opts, storageFile.Path.LocalPath); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } + public void Apply(Models.Stash stash) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new ApplyStash(_repo, stash)); + } - e.Handled = true; - }; + public void CheckoutBranch(Models.Stash stash) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutBranchFromStash(_repo, stash)); + } - var copy = new MenuItem(); - copy.Header = App.Text("StashCM.CopyMessage"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copy.Click += async (_, ev) => - { - await App.CopyTextAsync(stash.Message); - ev.Handled = true; - }; - - var menu = new ContextMenu(); - menu.Items.Add(apply); - menu.Items.Add(drop); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(patch); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(copy); - return menu; + public void Drop(Models.Stash stash) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new DropStash(_repo, stash)); } - public ContextMenu MakeContextMenuForChange() + public async Task SaveStashAsPatchAsync(Models.Stash stash, string saveTo) { - if (_selectedChanges is not { Count: 1 }) - return null; - - var change = _selectedChanges[0]; - var openWithMerger = new MenuItem(); - openWithMerger.Header = App.Text("OpenInExternalMergeTool"); - openWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; - openWithMerger.Click += (_, ev) => - { - var toolType = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption($"{_selectedStash.SHA}^", _selectedStash.SHA, change); - new Commands.DiffTool(_repo.FullPath, toolType, toolPath, opt).Open(); - ev.Handled = true; - }; - - var fullPath = Path.Combine(_repo.FullPath, change.Path); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(fullPath); - explore.Click += (_, ev) => - { - Native.OS.OpenInFileManager(fullPath, true); - ev.Handled = true; - }; - - var resetToThisRevision = new MenuItem(); - resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); - resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToThisRevision.Click += async (_, ev) => + var opts = new List(); + var changes = await new Commands.CompareRevisions(_repo.FullPath, $"{stash.SHA}^", stash.SHA) + .ReadAsync() + .ConfigureAwait(false); + + foreach (var c in changes) + opts.Add(new Models.DiffOption(stash.Parents[0], stash.SHA, c)); + + if (stash.Parents.Count == 3) { - var log = _repo.CreateLog($"Reset File to '{_selectedStash.SHA}'"); + var untracked = await new Commands.CompareRevisions(_repo.FullPath, stash.UntrackedParent, stash.Parents[2]) + .ReadAsync() + .ConfigureAwait(false); - if (_untracked.Contains(change)) - { - await Commands.SaveRevisionFile.RunAsync(_repo.FullPath, _selectedStash.Parents[2], change.Path, fullPath); - } - else if (change.Index == Models.ChangeState.Added) - { - await Commands.SaveRevisionFile.RunAsync(_repo.FullPath, _selectedStash.SHA, change.Path, fullPath); - } - else - { - await new Commands.Checkout(_repo.FullPath) - .Use(log) - .FileWithRevisionAsync(change.Path, $"{_selectedStash.SHA}"); - } + foreach (var c in untracked) + opts.Add(new Models.DiffOption(stash.UntrackedParent, stash.Parents[2], c)); - log.Complete(); - ev.Handled = true; - }; + changes.AddRange(untracked); + } - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, ev) => - { - await App.CopyTextAsync(change.Path); - ev.Handled = true; - }; - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await App.CopyTextAsync(Native.OS.GetAbsPath(_repo.FullPath, change.Path)); - e.Handled = true; - }; - - var menu = new ContextMenu(); - menu.Items.Add(openWithMerger); - menu.Items.Add(explore); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(resetToThisRevision); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(copyPath); - menu.Items.Add(copyFullPath); - - return menu; + var succ = await Commands.SaveChangesAsPatch.ProcessStashChangesAsync(_repo.FullPath, opts, saveTo); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); } - public void ClearSearchFilter() + public void OpenChangeWithExternalDiffTool(Models.Change change) { - SearchFilter = string.Empty; + Models.DiffOption opt; + if (_untracked.Contains(change)) + opt = new Models.DiffOption(_selectedStash.UntrackedParent, _selectedStash.Parents[2], change); + else + opt = new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, change); + + new Commands.DiffTool(_repo.FullPath, opt).Open(); } - public void Apply(Models.Stash stash) + public async Task CheckoutFilesAsync(List changes) { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new ApplyStash(_repo, stash)); + var untracked = new List(); + var added = new List(); + var modified = new List(); + + foreach (var c in changes) + { + if (_untracked.Contains(c) && _selectedStash.Parents.Count == 3) + untracked.Add(c.Path); + else if (c.Index == Models.ChangeState.Added && _selectedStash.Parents.Count > 1) + added.Add(c.Path); + else + modified.Add(c.Path); + } + + var log = _repo.CreateLog($"Reset File to '{_selectedStash.Name}'"); + + if (untracked.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(untracked, _selectedStash.Parents[2]); + + if (added.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(added, _selectedStash.Parents[1]); + + if (modified.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(modified, _selectedStash.SHA); + + log.Complete(); } - public void Drop(Models.Stash stash) + public async Task ApplySelectedChanges(List changes) { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new DropStash(_repo, stash)); + 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() diff --git a/src/ViewModels/Statistics.cs b/src/ViewModels/Statistics.cs index a16c94ecc..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,50 +13,82 @@ public bool IsLoading private set => SetProperty(ref _isLoading, value); } - public int SelectedIndex + public List Branches { - get => _selectedIndex; - set - { - if (SetProperty(ref _selectedIndex, value)) - RefreshReport(); - } + get => _branches; + private set => SetProperty(ref _branches, value); } - public Models.StatisticsReport SelectedReport + public Models.Branch SelectedBranch { - get => _selectedReport; - private set + get => _selectedBranch; + set { - value?.ChangeAuthor(null); - SetProperty(ref _selectedReport, value); + if (SetProperty(ref _selectedBranch, value)) + LoadStatistics(); } } - public uint SampleColor + public Models.StatisticsMode ViewMode { - get => Preferences.Instance.StatisticsSampleColor; + get => _viewMode; set { - if (value != Preferences.Instance.StatisticsSampleColor) - { - Preferences.Instance.StatisticsSampleColor = value; - OnPropertyChanged(nameof(SampleBrush)); - _selectedReport?.ChangeColor(value); - } + if (SetProperty(ref _viewMode, value)) + RefreshReport(); } } - public IBrush SampleBrush + public Models.StatisticsReport SelectedReport + { + get => _selectedReport; + private set => SetProperty(ref _selectedReport, value); + } + + public Models.StatisticsSamples Samples { - get => new SolidColorBrush(SampleColor); + get => _samples; + private set => SetProperty(ref _samples, value); } public Statistics(string repo) { + _repo = repo; + LoadBranches(); + LoadStatistics(); + } + + public void ChangeAuthor(Models.StatisticsAuthor author) + { + if (SelectedReport == null) + return; + + Samples = SelectedReport.GetSamples(author); + } + + private void LoadBranches() + { + Task.Run(async () => + { + var branches = await new Commands.QueryBranches(_repo) + .GetResultAsync() + .ConfigureAwait(false); + + branches.Insert(0, _selectedBranch); + Dispatcher.UIThread.Post(() => Branches = branches); + }); + } + + private void LoadStatistics() + { + IsLoading = true; + Task.Run(async () => { - var result = await new Commands.Statistics(repo, Preferences.Instance.MaxHistoryCommits).ReadAsync().ConfigureAwait(false); + var result = await new Commands.Statistics(_repo, Preferences.Instance.MaxHistoryCommits, _selectedBranch) + .ReadAsync() + .ConfigureAwait(false); + Dispatcher.UIThread.Post(() => { _data = result; @@ -73,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/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 0848be1d8..05b04f5b1 100644 --- a/src/ViewModels/TagCollection.cs +++ b/src/ViewModels/TagCollection.cs @@ -1,21 +1,27 @@ +using System.Collections; using System.Collections.Generic; +using Avalonia; using Avalonia.Collections; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public class TagTreeNodeToolTip + 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 TagTreeNodeToolTip(Models.Tag t) + public TagToolTip(Models.Tag t) { Name = t.Name; IsAnnotated = t.IsAnnotated; + Creator = t.Creator; + CreatorDate = t.CreatorDate; Message = t.Message; } } @@ -25,7 +31,7 @@ public class TagTreeNode : ObservableObject public string FullPath { get; private set; } public int Depth { get; private set; } = 0; public Models.Tag Tag { get; private set; } = null; - public TagTreeNodeToolTip ToolTip { get; private set; } = null; + public TagToolTip ToolTip { get; private set; } = null; public List Children { get; private set; } = []; public int Counter { get; set; } = 0; @@ -34,6 +40,24 @@ public bool IsFolder get => Tag == null; } + public bool IsSelected + { + get; + set; + } + + public Models.FilterMode FilterMode + { + get => _filterMode; + set => SetProperty(ref _filterMode, value); + } + + public CornerRadius CornerRadius + { + get => _cornerRadius; + set => SetProperty(ref _cornerRadius, value); + } + public bool IsExpanded { get => _isExpanded; @@ -50,7 +74,7 @@ public TagTreeNode(Models.Tag t, int depth) FullPath = t.Name; Depth = depth; Tag = t; - ToolTip = new TagTreeNodeToolTip(t); + ToolTip = new TagToolTip(t); IsExpanded = false; } @@ -62,6 +86,19 @@ public TagTreeNode(string path, bool isExpanded, int depth) Counter = 1; } + public void UpdateFilterMode(Dictionary filters) + { + if (Tag == null) + { + foreach (var child in Children) + child.UpdateFilterMode(filters); + } + else + { + FilterMode = filters.GetValueOrDefault(FullPath, Models.FilterMode.None); + } + } + public static List Build(List tags, HashSet expanded) { var nodes = new List(); @@ -127,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 List 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 @@ -206,6 +330,46 @@ public void ToggleExpand(TagTreeNode node) } } + 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) @@ -218,5 +382,17 @@ private static void MakeTreeRows(List rows, List nodes 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 78bb1febd..323a60b2d 100644 --- a/src/ViewModels/UpdateSubmodules.cs +++ b/src/ViewModels/UpdateSubmodules.cs @@ -27,6 +27,12 @@ public bool UpdateAll set => SetProperty(ref _updateAll, value); } + public bool IsEnableInitVisible + { + get; + set; + } = true; + public bool EnableInit { get; @@ -53,14 +59,18 @@ public UpdateSubmodules(Repository repo, Models.Submodule selected) { _updateAll = false; SelectedSubmodule = selected; + IsEnableInitVisible = selected.Status == Models.SubmoduleStatus.NotInited; EnableInit = selected.Status == Models.SubmoduleStatus.NotInited; HasPreSelectedSubmodule = true; } else if (repo.Submodules.Count > 0) { SelectedSubmodule = repo.Submodules[0]; + IsEnableInitVisible = true; HasPreSelectedSubmodule = false; } + + EnableRecursive = _repo.Settings.EnableRecursiveWhenAutoUpdatingSubmodules; } public override async Task Sure() @@ -80,7 +90,7 @@ public override async Task Sure() return true; var log = _repo.CreateLog("Update Submodule"); - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); Use(log); await new Commands.Submodule(_repo.FullPath) @@ -88,7 +98,7 @@ public override async Task Sure() .UpdateAsync(targets, EnableInit, EnableRecursive, EnableRemote); log.Complete(); - _repo.SetWatcherEnabled(true); + _repo.MarkSubmodulesDirtyManually(); return true; } diff --git a/src/ViewModels/Welcome.cs b/src/ViewModels/Welcome.cs index 7ee2c80cd..49539292d 100644 --- a/src/ViewModels/Welcome.cs +++ b/src/ViewModels/Welcome.cs @@ -1,10 +1,10 @@ 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 @@ -53,6 +53,23 @@ public void Refresh() 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,67 +100,73 @@ public void ToggleNodeIsExpanded(RepositoryNode node) } } - public void OpenOrInitRepository(string path, RepositoryNode parent, bool bMoveExistedNode) + public async Task GetRepositoryRootAsync(string path) { - if (!Directory.Exists(path)) + if (!Preferences.Instance.IsGitConfigured()) { - if (File.Exists(path)) - path = Path.GetDirectoryName(path); - else - return; + Models.Notification.Send(null, App.Text("NotConfigured"), true); + return null; } - var isBare = new Commands.IsBareRepository(path).GetResultAsync().Result; - var repoRoot = path; - if (!isBare) + var root = path; + if (!Directory.Exists(root)) { - var test = new Commands.QueryRepositoryRootPath(path).GetResultAsync().Result; - if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) - { - InitRepository(path, parent, test.StdErr); - return; - } - - repoRoot = test.StdOut.Trim(); + if (File.Exists(root)) + root = Path.GetDirectoryName(root); + else + return null; } - var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(repoRoot, parent, bMoveExistedNode); - Refresh(); + var isBare = await new Commands.IsBareRepository(root).GetResultAsync(); + if (isBare) + return root; - var launcher = App.GetLauncher(); - launcher?.OpenRepositoryInTab(node, launcher.ActivePage); + 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 InitRepository(string path, RepositoryNode parent, string reason) + public void Clone() { if (!Preferences.Instance.IsGitConfigured()) { - App.RaiseException(string.Empty, App.Text("NotConfigured")); + Models.Notification.Send(null, App.Text("NotConfigured"), true); return; } var activePage = App.GetLauncher().ActivePage; if (activePage != null && activePage.CanCreatePopup()) - activePage.Popup = new Init(activePage.Node.Id, path, parent, reason); + activePage.Popup = new Clone(activePage.Node.Id); } - public void Clone() + public void OpenLocalRepository() { if (!Preferences.Instance.IsGitConfigured()) { - App.RaiseException(string.Empty, App.Text("NotConfigured")); + 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); + activePage.Popup = new OpenLocalRepository(activePage.Node.Id, null); } public void OpenTerminal() { if (!Preferences.Instance.IsGitConfigured()) - App.RaiseException(string.Empty, App.Text("NotConfigured")); + Models.Notification.Send(null, App.Text("NotConfigured"), true); else Native.OS.OpenTerminal(null); } @@ -152,7 +175,7 @@ public void ScanDefaultCloneDir() { if (!Preferences.Instance.IsGitConfigured()) { - App.RaiseException(string.Empty, App.Text("NotConfigured")); + Models.Notification.Send(null, App.Text("NotConfigured"), true); return; } @@ -173,6 +196,22 @@ public void AddRootNode() activePage.Popup = new CreateGroup(null); } + public RepositoryNode FindNodeById(string id, RepositoryNode root = null) + { + var collection = (root == null) ? Preferences.Instance.RepositoryNodes : root.SubNodes; + foreach (var node in collection) + { + if (node.Id.Equals(id, StringComparison.Ordinal)) + return node; + + var sub = FindNodeById(id, node); + if (sub != null) + return sub; + } + + return null; + } + public RepositoryNode FindParentGroup(RepositoryNode node, RepositoryNode group = null) { var collection = (group == null) ? Preferences.Instance.RepositoryNodes : group.SubNodes; @@ -198,111 +237,6 @@ public void MoveNode(RepositoryNode from, RepositoryNode to) Refresh(); } - public ContextMenu CreateContextMenu(RepositoryNode node) - { - var menu = new ContextMenu(); - - if (!node.IsRepository && node.SubNodes.Count > 0) - { - var openAll = new MenuItem(); - openAll.Header = App.Text("Welcome.OpenAllInNode"); - openAll.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - openAll.Click += (_, e) => - { - OpenAllInNode(App.GetLauncher(), node); - e.Handled = true; - }; - - menu.Items.Add(openAll); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - if (node.IsRepository) - { - var open = new MenuItem(); - open.Header = App.Text("Welcome.OpenOrInit"); - open.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - open.Click += (_, e) => - { - App.GetLauncher()?.OpenRepositoryInTab(node, null); - e.Handled = true; - }; - - var explore = new MenuItem(); - explore.Header = App.Text("Repository.Explore"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.Click += (_, e) => - { - node.OpenInFileManager(); - e.Handled = true; - }; - - var terminal = new MenuItem(); - terminal.Header = App.Text("Repository.Terminal"); - terminal.Icon = App.CreateMenuIcon("Icons.Terminal"); - terminal.Click += (_, e) => - { - node.OpenTerminal(); - e.Handled = true; - }; - - menu.Items.Add(open); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(explore); - menu.Items.Add(terminal); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - else - { - var addSubFolder = new MenuItem(); - addSubFolder.Header = App.Text("Welcome.AddSubFolder"); - addSubFolder.Icon = App.CreateMenuIcon("Icons.Folder.Add"); - addSubFolder.Click += (_, e) => - { - node.AddSubFolder(); - e.Handled = true; - }; - menu.Items.Add(addSubFolder); - } - - var edit = new MenuItem(); - edit.Header = App.Text("Welcome.Edit"); - edit.Icon = App.CreateMenuIcon("Icons.Edit"); - edit.Click += (_, e) => - { - node.Edit(); - e.Handled = true; - }; - - var move = new MenuItem(); - move.Header = App.Text("Welcome.Move"); - move.Icon = App.CreateMenuIcon("Icons.MoveTo"); - move.Click += (_, e) => - { - var activePage = App.GetLauncher().ActivePage; - if (activePage != null && activePage.CanCreatePopup()) - activePage.Popup = new MoveRepositoryNode(node); - - e.Handled = true; - }; - - var delete = new MenuItem(); - delete.Header = App.Text("Welcome.Delete"); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => - { - node.Delete(); - e.Handled = true; - }; - - menu.Items.Add(edit); - menu.Items.Add(move); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(delete); - - return menu; - } - private void ResetVisibility(RepositoryNode node) { node.IsVisible = true; @@ -355,17 +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 string _searchFilter = string.Empty; + private bool _isUpdatingStatus = false; } } diff --git a/src/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs index a1f4c4d87..c0f273529 100644 --- a/src/ViewModels/WorkingCopy.cs +++ b/src/ViewModels/WorkingCopy.cs @@ -2,22 +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 record CommitMessageRecord(string subject) + public class WorkingCopy : ObservableObject { - public string Subject { get; set; } = subject; - } + public Repository Repository + { + get => _repo; + } - public class WorkingCopy : ObservableObject, IDisposable - { public bool IncludeUntracked { get => _repo.IncludeUntracked; @@ -43,6 +38,12 @@ public bool HasUnsolvedConflicts set => SetProperty(ref _hasUnsolvedConflicts, value); } + public bool CanSwitchBranchDirectly + { + get; + set; + } = true; + public InProgressContext InProgressContext { get => _inProgressContext; @@ -69,8 +70,14 @@ public bool IsCommitting public bool EnableSignOff { - get => _repo.Settings.EnableSignOffForCommit; - set => _repo.Settings.EnableSignOffForCommit = value; + get => _repo.UIStates.EnableSignOffForCommit; + set => _repo.UIStates.EnableSignOffForCommit = value; + } + + public bool NoVerifyOnCommit + { + get => _repo.UIStates.NoVerifyOnCommit; + set => _repo.UIStates.NoVerifyOnCommit = value; } public bool UseAmend @@ -85,15 +92,13 @@ 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) - .GetResultAsync() - .Result; + CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, currentBranch.Head).GetResult(); } else { @@ -101,7 +106,7 @@ public bool UseAmend ResetAuthor = false; } - Staged = GetStagedChanges(); + Staged = GetStagedChanges(_cached); VisibleStaged = GetVisibleChanges(_staged); SelectedStaged = []; } @@ -224,146 +229,163 @@ public WorkingCopy(Repository repo) _repo = repo; } - public void Dispose() - { - _repo = null; - _inProgressContext = null; - - _selectedUnstaged.Clear(); - OnPropertyChanged(nameof(SelectedUnstaged)); - - _selectedStaged.Clear(); - OnPropertyChanged(nameof(SelectedStaged)); - - _visibleUnstaged.Clear(); - OnPropertyChanged(nameof(VisibleUnstaged)); - - _visibleStaged.Clear(); - OnPropertyChanged(nameof(VisibleStaged)); - - _unstaged.Clear(); - OnPropertyChanged(nameof(Unstaged)); - - _staged.Clear(); - OnPropertyChanged(nameof(Staged)); - - _detailContext = null; - _commitMessage = string.Empty; - } - public void SetData(List changes) { if (!IsChanged(_cached, changes)) { - // Just force refresh selected changes. - Dispatcher.UIThread.Invoke(() => - { - HasUnsolvedConflicts = _cached.Find(x => x.IsConflicted) != null; - - UpdateDetail(); - UpdateInProgressState(); - }); - + HasUnsolvedConflicts = _cached.Find(x => x.IsConflicted) != null; + UpdateInProgressState(); + UpdateDetail(); return; } - _cached = changes; - var lastSelectedUnstaged = new HashSet(); - var lastSelectedStaged = new HashSet(); if (_selectedUnstaged is { Count: > 0 }) { foreach (var c in _selectedUnstaged) lastSelectedUnstaged.Add(c.Path); } - else if (_selectedStaged is { 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.IsConflicted; + + if (noFilter || c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + { + visibleUnstaged.Add(c); + if (lastSelectedUnstaged.Contains(c.Path)) + selectedUnstaged.Add(c); + } } - } - var visibleUnstaged = GetVisibleChanges(unstaged); - var selectedUnstaged = new List(); - foreach (var c in visibleUnstaged) - { - if (lastSelectedUnstaged.Contains(c.Path)) - selectedUnstaged.Add(c); - } + if (!canSwitchDirectly) + continue; - var staged = GetStagedChanges(); + if (c.WorkTree == Models.ChangeState.Untracked || c.Index == Models.ChangeState.Added) + continue; + + canSwitchDirectly = false; + } + var staged = GetStagedChanges(changes); var visibleStaged = GetVisibleChanges(staged); var selectedStaged = new List(); - foreach (var c in visibleStaged) + 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); + + foreach (var c in visibleStaged) + { + if (set.Contains(c.Path)) + selectedStaged.Add(c); + } } - Dispatcher.UIThread.Invoke(() => + if (selectedUnstaged.Count == 0 && selectedStaged.Count == 0 && hasConflict) { - _isLoadingData = true; - HasUnsolvedConflicts = hasConflict; - VisibleUnstaged = visibleUnstaged; - VisibleStaged = visibleStaged; - Unstaged = unstaged; - Staged = staged; - SelectedUnstaged = selectedUnstaged; - SelectedStaged = selectedStaged; - _isLoadingData = false; + var firstConflict = visibleUnstaged.Find(x => x.IsConflicted); + selectedUnstaged.Add(firstConflict); + } - UpdateDetail(); - UpdateInProgressState(); - }); - } + _isLoadingData = true; + _cached = changes; + HasUnsolvedConflicts = hasConflict; + CanSwitchBranchDirectly = canSwitchDirectly; + VisibleUnstaged = visibleUnstaged; + VisibleStaged = visibleStaged; + Unstaged = unstaged; + Staged = staged; + SelectedUnstaged = selectedUnstaged; + SelectedStaged = selectedStaged; + _isLoadingData = false; - public void OpenWithDefaultEditor(Models.Change c) - { - var absPath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); - if (File.Exists(absPath)) - Native.OS.OpenWithDefaultEditor(absPath); + UpdateInProgressState(); + UpdateDetail(); } - public void StashAll(bool autoStart) + public async Task StageChangesAsync(List changes, Models.Change next) { - if (!_repo.CanCreatePopup()) + var canStaged = await GetCanStageChangesAsync(changes); + var count = canStaged.Count; + if (count == 0) return; - if (autoStart) - _repo.ShowAndStartPopup(new StashChanges(_repo, _cached, false)); - else - _repo.ShowPopup(new StashChanges(_repo, _cached, false)); - } + IsStaging = true; + _selectedUnstaged = next != null ? [next] : []; - public void StageSelected(Models.Change next) - { - StageChanges(_selectedUnstaged, next); - } + using var lockWatcher = _repo.LockWatcher(); - public void StageAll() - { - StageChanges(_visibleUnstaged, null); + var log = _repo.CreateLog("Stage"); + var pathSpecFile = Path.GetTempFileName(); + await using (var writer = new StreamWriter(pathSpecFile)) + { + foreach (var c in canStaged) + await writer.WriteLineAsync(c.Path); + } + + await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); + File.Delete(pathSpecFile); + log.Complete(); + + _repo.MarkWorkingCopyDirtyManually(); + IsStaging = false; } - public void UnstageSelected(Models.Change next) + public async Task UnstageChangesAsync(List changes, Models.Change next) { - UnstageChanges(_selectedStaged, next); + var count = changes.Count; + if (count == 0) + return; + + IsUnstaging = true; + _selectedStaged = next != null ? [next] : []; + + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog("Unstage"); + if (_useAmend) + { + log.AppendLine("$ git update-index --index-info "); + await new Commands.UpdateIndexInfo(_repo.FullPath, changes).ExecAsync(); + } + else + { + var pathSpecFile = Path.GetTempFileName(); + await using (var writer = new StreamWriter(pathSpecFile)) + { + foreach (var c in changes) + { + await writer.WriteLineAsync(c.Path); + if (c.Index == Models.ChangeState.Renamed) + await writer.WriteLineAsync(c.OriginalPath); + } + } + + await new Commands.Reset(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); + File.Delete(pathSpecFile); + } + log.Complete(); + + _repo.MarkWorkingCopyDirtyManually(); + IsUnstaging = false; } - public void UnstageAll() + public async Task SaveChangesToPatchAsync(List changes, bool isUnstaged, string saveTo) { - UnstageChanges(_visibleStaged, null); + var succ = await Commands.SaveChangesAsPatch.ProcessLocalChangesAsync(_repo.FullPath, changes, isUnstaged, saveTo); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); } public void Discard(List changes) @@ -377,9 +399,9 @@ public void ClearFilter() Filter = string.Empty; } - public async void UseTheirs(List changes) + public async Task UseTheirsAsync(List changes) { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); var files = new List(); var needStage = new List(); @@ -421,12 +443,11 @@ public async void UseTheirs(List changes) log.Complete(); _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); } - public async void UseMine(List changes) + public async Task UseMineAsync(List changes) { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); var files = new List(); var needStage = new List(); @@ -468,1314 +489,235 @@ public async void UseMine(List changes) log.Complete(); _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); } - public async Task UseExternalMergeTool(Models.Change change) + public async Task UseExternalMergeToolAsync(Models.Change change) { - var toolType = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - var file = change?.Path; - return await new Commands.MergeTool(_repo.FullPath, toolType, toolPath, file).OpenAsync(); + return await new Commands.MergeTool(_repo.FullPath, change?.Path).OpenAsync(); } - public void ContinueMerge() + public void UseExternalDiffTool(Models.Change change, bool isUnstaged) { - IsCommitting = true; + new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(change, isUnstaged)).Open(); + } + public async Task ContinueMergeAsync() + { if (_inProgressContext != null) { - _repo.SetWatcherEnabled(false); - Task.Run(async () => - { - var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); - if (File.Exists(mergeMsgFile) && !string.IsNullOrWhiteSpace(_commitMessage)) - await File.WriteAllTextAsync(mergeMsgFile, _commitMessage); + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; - var succ = await _inProgressContext.ContinueAsync(); - await Dispatcher.UIThread.InvokeAsync(() => - { - if (succ) - CommitMessage = string.Empty; + var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); + if (File.Exists(mergeMsgFile) && !string.IsNullOrWhiteSpace(_commitMessage)) + await File.WriteAllTextAsync(mergeMsgFile, _commitMessage); + + var log = _repo.CreateLog($"Continue {_inProgressContext.Name}"); + await _inProgressContext.ContinueAsync(log); + log.Complete(); - _repo.SetWatcherEnabled(true); - IsCommitting = false; - }); - }); + CommitMessage = string.Empty; + IsCommitting = false; } else { _repo.MarkWorkingCopyDirtyManually(); - IsCommitting = false; } } - public void SkipMerge() + public async Task SkipMergeAsync() { - IsCommitting = true; - if (_inProgressContext != null) { - _repo.SetWatcherEnabled(false); - Task.Run(async () => - { - var succ = await _inProgressContext.SkipAsync(); - await Dispatcher.UIThread.InvokeAsync(() => - { - if (succ) - CommitMessage = string.Empty; + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; + + var log = _repo.CreateLog($"Skip {_inProgressContext.Name}"); + await _inProgressContext.SkipAsync(log); + log.Complete(); - _repo.SetWatcherEnabled(true); - IsCommitting = false; - }); - }); + CommitMessage = string.Empty; + IsCommitting = false; } else { _repo.MarkWorkingCopyDirtyManually(); - IsCommitting = false; } } - public void AbortMerge() + public async Task AbortMergeAsync() { - IsCommitting = true; - if (_inProgressContext != null) { - _repo.SetWatcherEnabled(false); - Task.Run(async () => - { - var succ = await _inProgressContext.AbortAsync(); - await Dispatcher.UIThread.InvokeAsync(() => - { - if (succ) - CommitMessage = string.Empty; + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; + + var log = _repo.CreateLog($"Abort {_inProgressContext.Name}"); + await _inProgressContext.AbortAsync(log); + log.Complete(); - _repo.SetWatcherEnabled(true); - IsCommitting = false; - }); - }); + CommitMessage = string.Empty; + IsCommitting = false; } else { _repo.MarkWorkingCopyDirtyManually(); - IsCommitting = false; } } - public void Commit() + public void ApplyCommitMessageTemplate(Models.CommitTemplate tmpl) { - DoCommit(false, false); + CommitMessage = tmpl.Apply(_repo.CurrentBranch, _staged); } - public void CommitWithAutoStage() + public async Task ClearCommitMessageHistoryAsync() { - DoCommit(true, false); + var sure = await App.AskConfirmAsync(App.Text("WorkingCopy.ClearCommitHistories.Confirm")); + if (sure) + _repo.UIStates.RecentCommitMessages.Clear(); } - public void CommitWithPush() + public async Task CommitAsync(bool autoStage, bool autoPush) { - DoCommit(false, true); - } + if (string.IsNullOrWhiteSpace(_commitMessage)) + return; - public ContextMenu CreateContextMenuForUnstagedChanges(string selectedSingleFolder) - { - if (_selectedUnstaged == null || _selectedUnstaged.Count == 0) - return null; + if (!_repo.CanCreatePopup()) + { + _repo.SendNotification("Repository has an unfinished job! Please wait!", true); + return; + } - var hasSelectedFolder = !string.IsNullOrEmpty(selectedSingleFolder); - var menu = new ContextMenu(); - if (_selectedUnstaged.Count == 1) + if (autoStage && HasUnsolvedConflicts) { - var change = _selectedUnstaged[0]; - var path = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + _repo.SendNotification("Repository has unsolved conflict(s). Auto-stage and commit is disabled!", true); + return; + } - if (!change.IsConflicted || change.ConflictReason is Models.ConflictReason.BothAdded or Models.ConflictReason.BothModified) - { - var openMerger = new MenuItem(); - openMerger.Header = App.Text("OpenInExternalMergeTool"); - openMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; - openMerger.Click += async (_, e) => - { - if (change.IsConflicted) - { - await UseExternalMergeTool(change); - } - else - { - var toolType = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption(change, true); - new Commands.DiffTool(_repo.FullPath, toolType, toolPath, opt).Open(); - } + if (_repo.CurrentBranch is { IsDetachedHead: true }) + { + var msg = App.Text("WorkingCopy.ConfirmCommitWithDetachedHead"); + var sure = await App.AskConfirmAsync(msg); + if (!sure) + return; + } - e.Handled = true; - }; - menu.Items.Add(openMerger); - } + if (!string.IsNullOrEmpty(_filter) && _staged.Count > _visibleStaged.Count) + { + var msg = App.Text("WorkingCopy.ConfirmCommitWithFilter", _staged.Count, _visibleStaged.Count, _staged.Count - _visibleStaged.Count); + var sure = await App.AskConfirmAsync(msg); + if (!sure) + return; + } - var openWith = new MenuItem(); - openWith.Header = App.Text("OpenWith"); - openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWith.Tag = OperatingSystem.IsMacOS() ? "⌘+O" : "Ctrl+O"; - openWith.IsEnabled = File.Exists(path); - openWith.Click += (_, e) => - { - OpenWithDefaultEditor(change); - e.Handled = true; - }; - menu.Items.Add(openWith); - - 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) => - { - var target = hasSelectedFolder ? Native.OS.GetAbsPath(_repo.FullPath, selectedSingleFolder) : path; - Native.OS.OpenInFileManager(target, true); - e.Handled = true; - }; - menu.Items.Add(explore); - menu.Items.Add(new MenuItem() { Header = "-" }); - - if (change.IsConflicted) + if (!_useAmend) + { + if ((!autoStage && _staged.Count == 0) || (autoStage && _cached.Count == 0)) { - var useTheirs = new MenuItem(); - useTheirs.Icon = App.CreateMenuIcon("Icons.Incoming"); - useTheirs.Click += (_, e) => - { - UseTheirs(_selectedUnstaged); - e.Handled = true; - }; - - var useMine = new MenuItem(); - useMine.Icon = App.CreateMenuIcon("Icons.Local"); - useMine.Click += (_, e) => - { - UseMine(_selectedUnstaged); - e.Handled = true; - }; - - switch (_inProgressContext) - { - case CherryPickInProgress cherryPick: - useTheirs.Header = App.Text("FileCM.ResolveUsing", cherryPick.HeadName); - useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); - break; - case RebaseInProgress rebase: - useTheirs.Header = App.Text("FileCM.ResolveUsing", rebase.HeadName); - useMine.Header = App.Text("FileCM.ResolveUsing", rebase.BaseName); - break; - case RevertInProgress revert: - useTheirs.Header = App.Text("FileCM.ResolveUsing", $"{revert.Head.SHA.AsSpan(0, 10)} (revert)"); - useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); - break; - case MergeInProgress merge: - useTheirs.Header = App.Text("FileCM.ResolveUsing", merge.SourceName); - useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); - break; - default: - useTheirs.Header = App.Text("FileCM.UseTheirs"); - useMine.Header = App.Text("FileCM.UseMine"); - break; - } + var rs = await App.AskConfirmEmptyCommitAsync(_cached.Count > 0, _selectedUnstaged is { Count: > 0 }); + if (rs == Models.ConfirmEmptyCommitResult.Cancel) + return; - menu.Items.Add(useTheirs); - menu.Items.Add(useMine); - menu.Items.Add(new MenuItem() { Header = "-" }); + if (rs == Models.ConfirmEmptyCommitResult.StageAllAndCommit) + autoStage = true; + else if (rs == Models.ConfirmEmptyCommitResult.StageSelectedAndCommit) + await StageChangesAsync(_selectedUnstaged, null); } - else - { - var stage = new MenuItem(); - stage.Header = App.Text("FileCM.Stage"); - stage.Icon = App.CreateMenuIcon("Icons.File.Add"); - stage.Tag = "Enter/Space"; - 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.Tag = "Back/Delete"; - discard.Click += (_, e) => - { - Discard(_selectedUnstaged); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.Stash"); - stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); - stash.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new StashChanges(_repo, _selectedUnstaged, true)); - - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; + } - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; + _repo.UIStates.AddRecentCommitMessage(_commitMessage); - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var succ = await Commands.SaveChangesAsPatch.ProcessLocalChangesAsync(_repo.FullPath, _selectedUnstaged, true, storageFile.Path.LocalPath); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } + if (autoStage && _unstaged.Count > 0) + await StageChangesAsync(_unstaged, null); - e.Handled = true; - }; + var log = _repo.CreateLog("Commit"); + var succ = await new Commands.Commit(_repo.FullPath, _commitMessage, EnableSignOff, NoVerifyOnCommit, _useAmend, _resetAuthor) + .Use(log) + .RunAsync(); - 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 += async (_, e) => - { - var log = _repo.CreateLog("Assume File Unchanged"); - await new Commands.AssumeUnchanged(_repo.FullPath, change.Path, true).Use(log).ExecAsync(); - log.Complete(); - 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 = "-" }); - - var extension = Path.GetExtension(change.Path); - var hasExtra = false; - if (change.WorkTree == Models.ChangeState.Untracked) - { - var addToIgnore = new MenuItem(); - addToIgnore.Header = App.Text("WorkingCopy.AddToGitIgnore"); - addToIgnore.Icon = App.CreateMenuIcon("Icons.GitIgnore"); + log.Complete(); - if (hasSelectedFolder) - { - var ignoreFolder = new MenuItem(); - ignoreFolder.Header = App.Text("WorkingCopy.AddToGitIgnore.InFolder"); - ignoreFolder.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new AddToIgnore(_repo, $"{selectedSingleFolder}/")); - e.Handled = true; - }; - addToIgnore.Items.Add(ignoreFolder); - } - else - { - var isRooted = change.Path.IndexOf('/') <= 0; - var singleFile = new MenuItem(); - singleFile.Header = App.Text("WorkingCopy.AddToGitIgnore.SingleFile"); - singleFile.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new AddToIgnore(_repo, change.Path)); - e.Handled = true; - }; - addToIgnore.Items.Add(singleFile); - - if (!string.IsNullOrEmpty(extension)) - { - var byExtension = new MenuItem(); - byExtension.Header = App.Text("WorkingCopy.AddToGitIgnore.Extension", extension); - byExtension.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new AddToIgnore(_repo, $"*{extension}")); - e.Handled = true; - }; - addToIgnore.Items.Add(byExtension); - - var byExtensionInSameFolder = new MenuItem(); - byExtensionInSameFolder.Header = App.Text("WorkingCopy.AddToGitIgnore.ExtensionInSameFolder", extension); - byExtensionInSameFolder.IsVisible = !isRooted; - byExtensionInSameFolder.Click += (_, e) => - { - var dir = Path.GetDirectoryName(change.Path)!.Replace('\\', '/').TrimEnd('/'); - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new AddToIgnore(_repo, $"{dir}/*{extension}")); - e.Handled = true; - }; - addToIgnore.Items.Add(byExtensionInSameFolder); - } - } + if (succ) + { + UseAmend = false; + CommitMessage = string.Empty; - menu.Items.Add(addToIgnore); - hasExtra = true; - } - else if (hasSelectedFolder) + if (autoPush && _repo.Remotes.Count > 0) + { + Models.Branch pushBranch = null; + if (_repo.CurrentBranch == null) { - var addToIgnore = new MenuItem(); - addToIgnore.Header = App.Text("WorkingCopy.AddToGitIgnore"); - addToIgnore.Icon = App.CreateMenuIcon("Icons.GitIgnore"); - - var ignoreFolder = new MenuItem(); - ignoreFolder.Header = App.Text("WorkingCopy.AddToGitIgnore.InFolder"); - ignoreFolder.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new AddToIgnore(_repo, $"{selectedSingleFolder}/")); - e.Handled = true; - }; - addToIgnore.Items.Add(ignoreFolder); - - menu.Items.Add(addToIgnore); - hasExtra = true; + var currentBranchName = await new Commands.QueryCurrentBranch(_repo.FullPath).GetResultAsync(); + pushBranch = new Models.Branch() { Name = currentBranchName }; } - if (_repo.IsLFSEnabled()) - { - var lfs = new MenuItem(); - lfs.Header = App.Text("GitLFS"); - lfs.Icon = App.CreateMenuIcon("Icons.LFS"); - - var isLFSFiltered = new Commands.IsLFSFiltered(_repo.FullPath, change.Path).GetResultAsync().Result; - if (!isLFSFiltered) - { - var filename = Path.GetFileName(change.Path); - var lfsTrackThisFile = new MenuItem(); - lfsTrackThisFile.Header = App.Text("GitLFS.Track", filename); - lfsTrackThisFile.Click += async (_, e) => - { - await _repo.TrackLFSFileAsync(filename, true); - 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) => - { - await _repo.TrackLFSFileAsync($"*{extension}", false); - 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) => - { - 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); + if (_repo.CanCreatePopup()) + await _repo.ShowAndStartPopupAsync(new Push(_repo, pushBranch)); + } + } - 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) => - { - 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); + _repo.MarkBranchesDirtyManually(); + _repo.RefreshSubmodules(); // Committing will not change submodule's HEAD (stage already changes it), So we need refresh submodules here manually. + IsCommitting = false; + } - menu.Items.Add(lfs); - hasExtra = true; - } + private List GetVisibleChanges(List changes) + { + if (string.IsNullOrEmpty(_filter)) + return changes; - if (hasExtra) - menu.Items.Add(new MenuItem() { Header = "-" }); - } + var visible = new List(); - if (hasSelectedFolder) - { - var history = new MenuItem(); - history.Header = App.Text("DirHistories"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, e) => - { - App.ShowWindow(new DirHistories(_repo, selectedSingleFolder)); - e.Handled = true; - }; + foreach (var c in changes) + { + if (c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } - menu.Items.Add(history); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - else if (change.WorkTree is not (Models.ChangeState.Untracked or Models.ChangeState.Added)) - { - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, e) => - { - App.ShowWindow(new FileHistories(_repo, change.Path)); - e.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame") + " (HEAD-only)"; - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.Click += async (_, ev) => - { - var commit = await new Commands.QuerySingleCommit(_repo.FullPath, "HEAD").GetResultAsync(); - App.ShowWindow(new Blame(_repo.FullPath, change.Path, commit)); - ev.Handled = true; - }; - - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(new MenuItem() { Header = "-" }); - } + return visible; + } - var copy = new MenuItem(); - copy.Header = App.Text("CopyPath"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copy.Click += async (_, e) => - { - await App.CopyTextAsync(hasSelectedFolder ? selectedSingleFolder : change.Path); - e.Handled = true; - }; - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await App.CopyTextAsync(hasSelectedFolder ? Native.OS.GetAbsPath(_repo.FullPath, selectedSingleFolder) : path); - e.Handled = true; - }; + private async Task> GetCanStageChangesAsync(List changes) + { + if (!HasUnsolvedConflicts) + return changes; - menu.Items.Add(copy); - menu.Items.Add(copyFullPath); - } - else + var outs = new List(); + foreach (var c in changes) { - var hasConflicts = false; - var hasNonConflicts = false; - foreach (var change in _selectedUnstaged) - { - if (change.IsConflicted) - hasConflicts = true; - else - hasNonConflicts = true; - } - - if (hasConflicts) + if (c.IsConflicted) { - if (hasNonConflicts) + if (c.ConflictReason is Models.ConflictReason.BothAdded or Models.ConflictReason.BothModified) { - App.RaiseException(_repo.FullPath, "Selection contains both conflict and non-conflict changes!"); - return null; + var state = await new Commands.QueryConflictFileState(_repo.FullPath, c).GetResultAsync(); + if (state != Models.ConflictFileState.Resolved) + continue; } - - var useTheirs = new MenuItem(); - useTheirs.Icon = App.CreateMenuIcon("Icons.Incoming"); - useTheirs.Click += (_, e) => - { - UseTheirs(_selectedUnstaged); - e.Handled = true; - }; - - var useMine = new MenuItem(); - useMine.Icon = App.CreateMenuIcon("Icons.Local"); - useMine.Click += (_, e) => - { - UseMine(_selectedUnstaged); - e.Handled = true; - }; - - switch (_inProgressContext) + else { - case CherryPickInProgress cherryPick: - useTheirs.Header = App.Text("FileCM.ResolveUsing", cherryPick.HeadName); - useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); - break; - case RebaseInProgress rebase: - useTheirs.Header = App.Text("FileCM.ResolveUsing", rebase.HeadName); - useMine.Header = App.Text("FileCM.ResolveUsing", rebase.BaseName); - break; - case RevertInProgress revert: - useTheirs.Header = App.Text("FileCM.ResolveUsing", $"{revert.Head.SHA.AsSpan(0, 10)} (revert)"); - useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); - break; - case MergeInProgress merge: - useTheirs.Header = App.Text("FileCM.ResolveUsing", merge.SourceName); - useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); - break; - default: - useTheirs.Header = App.Text("FileCM.UseTheirs"); - useMine.Header = App.Text("FileCM.UseMine"); - break; + continue; } - - menu.Items.Add(useTheirs); - menu.Items.Add(useMine); - return menu; } - if (hasSelectedFolder) - { - var dir = Path.Combine(_repo.FullPath, selectedSingleFolder); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = Directory.Exists(dir); - explore.Click += (_, e) => - { - Native.OS.OpenInFileManager(dir, true); - e.Handled = true; - }; - menu.Items.Add(explore); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var stage = new MenuItem(); - stage.Header = App.Text("FileCM.StageMulti", _selectedUnstaged.Count); - stage.Icon = App.CreateMenuIcon("Icons.File.Add"); - stage.Tag = "Enter/Space"; - 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.Tag = "Back/Delete"; - discard.Click += (_, e) => - { - Discard(_selectedUnstaged); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.StashMulti", _selectedUnstaged.Count); - stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); - stash.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new StashChanges(_repo, _selectedUnstaged, true)); - - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var succ = await Commands.SaveChangesAsPatch.ProcessLocalChangesAsync(_repo.FullPath, _selectedUnstaged, true, storageFile.Path.LocalPath); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.Handled = true; - }; - - menu.Items.Add(stage); - menu.Items.Add(discard); - menu.Items.Add(stash); - menu.Items.Add(patch); - - if (hasSelectedFolder) - { - var ignoreFolder = new MenuItem(); - ignoreFolder.Header = App.Text("WorkingCopy.AddToGitIgnore.InFolder"); - ignoreFolder.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new AddToIgnore(_repo, $"{selectedSingleFolder}/")); - e.Handled = true; - }; - - var addToIgnore = new MenuItem(); - addToIgnore.Header = App.Text("WorkingCopy.AddToGitIgnore"); - addToIgnore.Icon = App.CreateMenuIcon("Icons.GitIgnore"); - addToIgnore.Items.Add(ignoreFolder); - - var history = new MenuItem(); - history.Header = App.Text("DirHistories"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, e) => - { - App.ShowWindow(new DirHistories(_repo, selectedSingleFolder)); - e.Handled = true; - }; - - var copy = new MenuItem(); - copy.Header = App.Text("CopyPath"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copy.Click += async (_, e) => - { - await App.CopyTextAsync(selectedSingleFolder); - e.Handled = true; - }; - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await App.CopyTextAsync(Native.OS.GetAbsPath(_repo.FullPath, selectedSingleFolder)); - e.Handled = true; - }; - - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(addToIgnore); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(history); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copy); - menu.Items.Add(copyFullPath); - } - } - - return menu; - } - - public ContextMenu CreateContextMenuForStagedChanges(string selectedSingleFolder) - { - if (_selectedStaged == null || _selectedStaged.Count == 0) - return null; - - var menu = new ContextMenu(); - - MenuItem ai = null; - var services = _repo.GetPreferredOpenAIServices(); - if (services.Count > 0) - { - ai = new MenuItem(); - ai.Icon = App.CreateMenuIcon("Icons.AIAssist"); - ai.Header = App.Text("ChangeCM.GenerateCommitMessage"); - - if (services.Count == 1) - { - ai.Click += async (_, e) => - { - await App.ShowDialog(new AIAssistant(_repo, services[0], _selectedStaged, t => CommitMessage = t)); - e.Handled = true; - }; - } - else - { - foreach (var service in services) - { - var dup = service; - - var item = new MenuItem(); - item.Header = service.Name; - item.Click += async (_, e) => - { - await App.ShowDialog(new AIAssistant(_repo, dup, _selectedStaged, t => CommitMessage = t)); - e.Handled = true; - }; - - ai.Items.Add(item); - } - } - } - - var hasSelectedFolder = !string.IsNullOrEmpty(selectedSingleFolder); - if (_selectedStaged.Count == 1) - { - var change = _selectedStaged[0]; - var path = Native.OS.GetAbsPath(_repo.FullPath, change.Path); - - var openWithMerger = new MenuItem(); - openWithMerger.Header = App.Text("OpenInExternalMergeTool"); - openWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; - openWithMerger.Click += (_, ev) => - { - var toolType = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption(change, false); - new Commands.DiffTool(_repo.FullPath, toolType, toolPath, opt).Open(); - ev.Handled = true; - }; - - var openWith = new MenuItem(); - openWith.Header = App.Text("OpenWith"); - openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWith.Tag = OperatingSystem.IsMacOS() ? "⌘+O" : "Ctrl+O"; - openWith.IsEnabled = File.Exists(path); - openWith.Click += (_, e) => - { - OpenWithDefaultEditor(change); - e.Handled = true; - }; - - 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) => - { - var target = hasSelectedFolder ? Native.OS.GetAbsPath(_repo.FullPath, selectedSingleFolder) : path; - Native.OS.OpenInFileManager(target, true); - e.Handled = true; - }; - - var unstage = new MenuItem(); - unstage.Header = App.Text("FileCM.Unstage"); - unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); - unstage.Tag = "Enter/Space"; - unstage.Click += (_, e) => - { - UnstageChanges(_selectedStaged, null); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.Stash"); - stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); - stash.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new StashChanges(_repo, _selectedStaged, true)); - - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var succ = await Commands.SaveChangesAsPatch.ProcessLocalChangesAsync(_repo.FullPath, _selectedStaged, false, storageFile.Path.LocalPath); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.Handled = true; - }; - - menu.Items.Add(openWithMerger); - menu.Items.Add(openWith); - menu.Items.Add(explore); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(unstage); - menu.Items.Add(stash); - menu.Items.Add(patch); - menu.Items.Add(new MenuItem() { Header = "-" }); - - if (_repo.IsLFSEnabled()) - { - 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) => - { - 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 = App.CreateMenuIcon("Icons.Unlock"); - lfsUnlock.IsEnabled = _repo.Remotes.Count > 0; - 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 = "-" }); - } - - if (ai != null) - { - menu.Items.Add(ai); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - if (hasSelectedFolder) - { - var history = new MenuItem(); - history.Header = App.Text("DirHistories"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, e) => - { - App.ShowWindow(new DirHistories(_repo, selectedSingleFolder)); - e.Handled = true; - }; - - menu.Items.Add(history); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - else if (change.Index is not (Models.ChangeState.Added or Models.ChangeState.Renamed)) - { - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, e) => - { - App.ShowWindow(new FileHistories(_repo, change.Path)); - e.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame") + " (HEAD-only)"; - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.Click += async (_, e) => - { - var commit = await new Commands.QuerySingleCommit(_repo.FullPath, "HEAD").GetResultAsync(); - App.ShowWindow(new Blame(_repo.FullPath, change.Path, commit)); - e.Handled = true; - }; - - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, e) => - { - await App.CopyTextAsync(hasSelectedFolder ? selectedSingleFolder : change.Path); - e.Handled = true; - }; - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - var target = hasSelectedFolder ? Native.OS.GetAbsPath(_repo.FullPath, selectedSingleFolder) : path; - await App.CopyTextAsync(target); - e.Handled = true; - }; - - menu.Items.Add(copyPath); - menu.Items.Add(copyFullPath); - } - else - { - if (hasSelectedFolder) - { - var dir = Path.Combine(_repo.FullPath, selectedSingleFolder); - var explore = new MenuItem(); - explore.IsEnabled = Directory.Exists(dir); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.Click += (_, e) => - { - Native.OS.OpenInFileManager(dir, true); - e.Handled = true; - }; - - menu.Items.Add(explore); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var unstage = new MenuItem(); - unstage.Header = App.Text("FileCM.UnstageMulti", _selectedStaged.Count); - unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); - unstage.Tag = "Enter/Space"; - unstage.Click += (_, e) => - { - UnstageChanges(_selectedStaged, null); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.StashMulti", _selectedStaged.Count); - stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); - stash.Click += (_, e) => - { - if (_repo.CanCreatePopup()) - _repo.ShowPopup(new StashChanges(_repo, _selectedStaged, true)); - - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var succ = await Commands.SaveChangesAsPatch.ProcessLocalChangesAsync(_repo.FullPath, _selectedStaged, false, storageFile.Path.LocalPath); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.Handled = true; - }; - - menu.Items.Add(unstage); - menu.Items.Add(stash); - menu.Items.Add(patch); - - if (ai != null) - { - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(ai); - } - - if (hasSelectedFolder) - { - var history = new MenuItem(); - history.Header = App.Text("DirHistories"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, e) => - { - App.ShowWindow(new DirHistories(_repo, selectedSingleFolder)); - e.Handled = true; - }; - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, e) => - { - await App.CopyTextAsync(selectedSingleFolder); - e.Handled = true; - }; - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await App.CopyTextAsync(Native.OS.GetAbsPath(_repo.FullPath, selectedSingleFolder)); - e.Handled = true; - }; - - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(history); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copyPath); - menu.Items.Add(copyFullPath); - } - } - - return menu; - } - - public ContextMenu CreateContextMenuForCommitMessages() - { - var menu = new ContextMenu(); - - var gitTemplate = new Commands.Config(_repo.FullPath).GetAsync("commit.template").Result; - var templateCount = _repo.Settings.CommitTemplates.Count; - if (templateCount == 0 && string.IsNullOrEmpty(gitTemplate)) - { - menu.Items.Add(new MenuItem() - { - Header = App.Text("WorkingCopy.NoCommitTemplates"), - Icon = App.CreateMenuIcon("Icons.Code"), - IsEnabled = false - }); - } - else - { - for (int i = 0; i < templateCount; i++) - { - var template = _repo.Settings.CommitTemplates[i]; - var item = new MenuItem(); - item.Header = App.Text("WorkingCopy.UseCommitTemplate", template.Name); - item.Icon = App.CreateMenuIcon("Icons.Code"); - item.Click += (_, e) => - { - CommitMessage = template.Apply(_repo.CurrentBranch, _staged); - e.Handled = true; - }; - menu.Items.Add(item); - } - - if (!string.IsNullOrEmpty(gitTemplate)) - { - var friendlyName = gitTemplate; - if (!OperatingSystem.IsWindows()) - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; - if (gitTemplate.StartsWith(home, StringComparison.Ordinal)) - friendlyName = $"~{gitTemplate.AsSpan(prefixLen)}"; - } - - var gitTemplateItem = new MenuItem(); - gitTemplateItem.Header = App.Text("WorkingCopy.UseCommitTemplate", friendlyName); - gitTemplateItem.Icon = App.CreateMenuIcon("Icons.Code"); - gitTemplateItem.Click += (_, e) => - { - if (File.Exists(gitTemplate)) - CommitMessage = File.ReadAllText(gitTemplate); - e.Handled = true; - }; - menu.Items.Add(gitTemplateItem); - } - } - - menu.Items.Add(new MenuItem() { Header = "-" }); - - var historiesCount = _repo.Settings.CommitMessages.Count; - if (historiesCount == 0) - { - menu.Items.Add(new MenuItem() - { - Header = App.Text("WorkingCopy.NoCommitHistories"), - Icon = App.CreateMenuIcon("Icons.Histories"), - IsEnabled = false - }); - } - else - { - for (int i = 0; i < historiesCount; i++) - { - var dup = _repo.Settings.CommitMessages[i].Trim(); - var message = dup.ReplaceLineEndings(" "); - var item = new MenuItem(); - item.Header = new CommitMessageRecord(message); - item.Icon = App.CreateMenuIcon("Icons.Histories"); - item.Click += (_, e) => - { - CommitMessage = dup; - e.Handled = true; - }; - - menu.Items.Add(item); - } - } - - return menu; - } - - public ContextMenu CreateContextForOpenAI() - { - if (_staged == null || _staged.Count == 0) - { - App.RaiseException(_repo.FullPath, "No files added to commit!"); - return null; - } - - var services = _repo.GetPreferredOpenAIServices(); - if (services.Count == 0) - { - App.RaiseException(_repo.FullPath, "Bad configuration for OpenAI"); - return null; - } - - if (services.Count == 1) - { - _ = App.ShowDialog(new AIAssistant(_repo, services[0], _staged, t => CommitMessage = t)); - return null; - } - - var menu = new ContextMenu() { Placement = PlacementMode.TopEdgeAlignedLeft }; - foreach (var service in services) - { - var dup = service; - var item = new MenuItem(); - item.Header = service.Name; - item.Click += async (_, e) => - { - await App.ShowDialog(new AIAssistant(_repo, dup, _staged, t => CommitMessage = t)); - e.Handled = true; - }; - - menu.Items.Add(item); - } - - return menu; - } - - private List GetVisibleChanges(List changes) - { - if (string.IsNullOrEmpty(_filter)) - return changes; - - var visible = new List(); - - foreach (var c in changes) - { - if (c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } - - return visible; - } - - private List GetCanStagedChanges(List changes) - { - if (!HasUnsolvedConflicts) - return changes; - - var outs = new List(); - foreach (var c in changes) - { - if (!c.IsConflicted) - outs.Add(c); + outs.Add(c); } return outs; } - private List GetStagedChanges() + private List GetStagedChanges(List cached) { if (_useAmend) { - var head = new Commands.QuerySingleCommit(_repo.FullPath, "HEAD") - .GetResultAsync() - .Result; - - return new Commands.QueryStagedChangesWithAmend(_repo.FullPath, head.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : $"{head.SHA}^") - .GetResultAsync() - .Result; + var changes = new Commands.QueryStagedChangesWithAmend(_repo.FullPath).GetResult(); + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + return changes; } var rs = new List(); - foreach (var c in _cached) + foreach (var c in cached) { if (c.Index != Models.ChangeState.None) rs.Add(c); @@ -1795,117 +737,45 @@ private void UpdateDetail() private void UpdateInProgressState() { - if (string.IsNullOrEmpty(_commitMessage)) - { - var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); - if (File.Exists(mergeMsgFile)) - CommitMessage = File.ReadAllText(mergeMsgFile); - } + 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"))) - { - var rebasing = new RebaseInProgress(_repo); - InProgressContext = rebasing; - - if (string.IsNullOrEmpty(_commitMessage)) - { - var rebaseMsgFile = Path.Combine(_repo.GitDir, "rebase-merge", "message"); - if (File.Exists(rebaseMsgFile)) - CommitMessage = File.ReadAllText(rebaseMsgFile); - else if (rebasing.StoppedAt != null) - CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, rebasing.StoppedAt.SHA).GetResultAsync().Result; - } - } + 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; - } - } - private async void StageChanges(List changes, Models.Change next) - { - var canStaged = GetCanStagedChanges(changes); - var count = canStaged.Count; - if (count == 0) + if (_inProgressContext != null && _inProgressContext.GetType() == oldType && !string.IsNullOrEmpty(_commitMessage)) return; - IsStaging = true; - _selectedUnstaged = next != null ? [next] : []; - _repo.SetWatcherEnabled(false); + if (LoadCommitMessageFromFile(Path.Combine(_repo.GitDir, "MERGE_MSG"))) + return; - var log = _repo.CreateLog("Stage"); - if (count == _unstaged.Count) - { - await new Commands.Add(_repo.FullPath, _repo.IncludeUntracked).Use(log).ExecAsync(); - } - else - { - var pathSpecFile = Path.GetTempFileName(); - await using (var writer = new StreamWriter(pathSpecFile)) - { - foreach (var c in canStaged) - await writer.WriteLineAsync(c.Path); - } + if (_inProgressContext is not RebaseInProgress { } rebasing) + return; - await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); - File.Delete(pathSpecFile); - } - log.Complete(); + if (LoadCommitMessageFromFile(Path.Combine(_repo.GitDir, "rebase-merge", "message"))) + return; - _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); - IsStaging = false; + CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, rebasing.StoppedAt.SHA).GetResult(); } - private async void UnstageChanges(List changes, Models.Change next) + private bool LoadCommitMessageFromFile(string file) { - var count = changes.Count; - if (count == 0) - return; - - IsUnstaging = true; - _selectedStaged = next != null ? [next] : []; - _repo.SetWatcherEnabled(false); - - var log = _repo.CreateLog("Unstage"); - if (_useAmend) - { - log.AppendLine("$ git update-index --index-info "); - await new Commands.UnstageChangesForAmend(_repo.FullPath, changes).ExecAsync(); - } - else - { - var pathSpecFile = Path.GetTempFileName(); - await using (var writer = new StreamWriter(pathSpecFile)) - { - foreach (var c in changes) - { - await writer.WriteLineAsync(c.Path); - if (c.Index == Models.ChangeState.Renamed) - await writer.WriteLineAsync(c.OriginalPath); - } - } + if (!File.Exists(file)) + return false; - await new Commands.Restore(_repo.FullPath, pathSpecFile, true).Use(log).ExecAsync(); - File.Delete(pathSpecFile); - } - log.Complete(); + var msg = File.ReadAllText(file).Trim(); + if (string.IsNullOrEmpty(msg)) + return false; - _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); - IsUnstaging = false; + CommitMessage = msg; + return true; } private void SetDetail(Models.Change change, bool isUnstaged) @@ -1921,92 +791,6 @@ private void SetDetail(Models.Change change, bool isUnstaged) DetailContext = new DiffContext(_repo.FullPath, new Models.DiffOption(change, isUnstaged), _detailContext as DiffContext); } - private void DoCommit(bool autoStage, bool autoPush, CommitCheckPassed checkPassed = CommitCheckPassed.None) - { - if (string.IsNullOrWhiteSpace(_commitMessage)) - return; - - if (!_repo.CanCreatePopup()) - { - App.RaiseException(_repo.FullPath, "Repository has an unfinished job! Please wait!"); - return; - } - - if (autoStage && HasUnsolvedConflicts) - { - App.RaiseException(_repo.FullPath, "Repository has unsolved conflict(s). Auto-stage and commit is disabled!"); - return; - } - - if (_repo.CurrentBranch is { IsDetachedHead: true } && checkPassed < CommitCheckPassed.DetachedHead) - { - var msg = App.Text("WorkingCopy.ConfirmCommitWithDetachedHead"); - _ = App.AskConfirmAsync(msg, () => DoCommit(autoStage, autoPush, CommitCheckPassed.DetachedHead)); - return; - } - - if (!string.IsNullOrEmpty(_filter) && _staged.Count > _visibleStaged.Count && checkPassed < CommitCheckPassed.Filter) - { - var msg = App.Text("WorkingCopy.ConfirmCommitWithFilter", _staged.Count, _visibleStaged.Count, _staged.Count - _visibleStaged.Count); - _ = App.AskConfirmAsync(msg, () => DoCommit(autoStage, autoPush, CommitCheckPassed.Filter)); - return; - } - - if (checkPassed < CommitCheckPassed.FileCount && !_useAmend) - { - if ((!autoStage && _staged.Count == 0) || (autoStage && _cached.Count == 0)) - { - _ = App.ShowDialog(new ConfirmEmptyCommit(_cached.Count > 0, stageAll => DoCommit(stageAll, autoPush, CommitCheckPassed.FileCount))); - return; - } - } - - IsCommitting = true; - _repo.Settings.PushCommitMessage(_commitMessage); - _repo.SetWatcherEnabled(false); - - var signOff = _repo.Settings.EnableSignOffForCommit; - var log = _repo.CreateLog("Commit"); - Task.Run(async () => - { - var succ = true; - if (autoStage && _unstaged.Count > 0) - succ = await new Commands.Add(_repo.FullPath, _repo.IncludeUntracked).Use(log).ExecAsync().ConfigureAwait(false); - - if (succ) - succ = await new Commands.Commit(_repo.FullPath, _commitMessage, signOff, _useAmend, _resetAuthor).Use(log).RunAsync().ConfigureAwait(false); - - log.Complete(); - - Dispatcher.UIThread.Post(async () => - { - if (succ) - { - CommitMessage = string.Empty; - UseAmend = false; - - if (autoPush && _repo.Remotes.Count > 0) - { - if (_repo.CurrentBranch == null) - { - var currentBranchName = await new Commands.QueryCurrentBranch(_repo.FullPath).GetResultAsync(); - var tmp = new Models.Branch() { Name = currentBranchName }; - _repo.ShowAndStartPopup(new Push(_repo, tmp)); - } - else - { - _repo.ShowAndStartPopup(new Push(_repo, null)); - } - } - } - - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - IsCommitting = false; - }); - }); - } - private bool IsChanged(List old, List cur) { if (old.Count != cur.Count) @@ -2016,21 +800,13 @@ private bool IsChanged(List old, List cur) { var o = old[idx]; var c = cur[idx]; - if (o.Path != c.Path || o.Index != c.Index || o.WorkTree != c.WorkTree) + if (!o.Path.Equals(c.Path, StringComparison.Ordinal) || o.Index != c.Index || o.WorkTree != c.WorkTree) return true; } return false; } - private enum CommitCheckPassed - { - None = 0, - DetachedHead, - Filter, - FileCount, - } - private Repository _repo = null; private bool _isLoadingData = false; private bool _isStaging = false; diff --git a/src/ViewModels/WorkspaceSwitcher.cs b/src/ViewModels/WorkspaceSwitcher.cs deleted file mode 100644 index 7a2da9bee..000000000 --- a/src/ViewModels/WorkspaceSwitcher.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using CommunityToolkit.Mvvm.ComponentModel; - -namespace SourceGit.ViewModels -{ - public class WorkspaceSwitcher : ObservableObject, IDisposable - { - public List VisibleWorkspaces - { - get => _visibleWorkspaces; - private set => SetProperty(ref _visibleWorkspaces, value); - } - - public string SearchFilter - { - get => _searchFilter; - set - { - if (SetProperty(ref _searchFilter, value)) - UpdateVisibleWorkspaces(); - } - } - - public Workspace SelectedWorkspace - { - get => _selectedWorkspace; - set => SetProperty(ref _selectedWorkspace, value); - } - - public WorkspaceSwitcher(Launcher launcher) - { - _launcher = launcher; - UpdateVisibleWorkspaces(); - } - - public void ClearFilter() - { - SearchFilter = string.Empty; - } - - public void Switch() - { - _launcher.SwitchWorkspace(_selectedWorkspace); - _launcher.CancelSwitcher(); - } - - public void Dispose() - { - _visibleWorkspaces.Clear(); - _selectedWorkspace = null; - _searchFilter = string.Empty; - } - - private void UpdateVisibleWorkspaces() - { - var visible = new List(); - if (string.IsNullOrEmpty(_searchFilter)) - { - visible.AddRange(Preferences.Instance.Workspaces); - } - else - { - foreach (var workspace in Preferences.Instance.Workspaces) - { - if (workspace.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(workspace); - } - } - - VisibleWorkspaces = visible; - SelectedWorkspace = visible.Count == 0 ? null : visible[0]; - } - - private Launcher _launcher = null; - private List _visibleWorkspaces = null; - private string _searchFilter = string.Empty; - private Workspace _selectedWorkspace = null; - } -} diff --git a/src/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 bef13df28..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" - 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="520" 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 e43e9aeb8..842022cf7 100644 --- a/src/Views/About.axaml.cs +++ b/src/Views/About.axaml.cs @@ -1,4 +1,6 @@ +using System; using System.Reflection; +using System.Text.RegularExpressions; using Avalonia.Interactivity; namespace SourceGit.Views @@ -11,9 +13,27 @@ public About() InitializeComponent(); var assembly = Assembly.GetExecutingAssembly(); - var ver = assembly.GetName().Version; - if (ver != null) - TxtVersion.Text = $"{ver.Major}.{ver.Minor:D2}"; + var meta = assembly.GetCustomAttributes(); + var foundFriendlyVersion = false; + foreach (var attr in meta) + { + if (attr.Key.Equals("BuildDate", StringComparison.OrdinalIgnoreCase) && DateTime.TryParse(attr.Value, out var date)) + { + TxtReleaseDate.Text = App.Text("About.ReleaseDate", Models.DateTimeFormat.Format(date, true)); + } + else if (attr.Key.Equals("FriendlyVersion", StringComparison.OrdinalIgnoreCase) && REG_FRIENDLY_VERSION().IsMatch(attr.Value)) + { + foundFriendlyVersion = true; + TxtVersion.Text = attr.Value; + } + } + + if (!foundFriendlyVersion) + { + var ver = assembly.GetName().Version; + if (ver != null) + TxtVersion.Text = $"v{ver.Major}.{ver.Minor:D2}"; + } var copyright = assembly.GetCustomAttribute(); if (copyright != null) @@ -22,7 +42,12 @@ public About() private void OnVisitReleaseNotes(object _, RoutedEventArgs e) { - Native.OS.OpenBrowser($"https://github.com/sourcegit-scm/sourcegit/releases/tag/v{TxtVersion.Text}"); + 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; } @@ -37,5 +62,8 @@ 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}"> + + + + - + + + + + + + Text="{Binding Pattern, Mode=TwoWay}"> @@ -34,22 +39,28 @@ + ItemsSource="{Binding StorageFiles, Mode=OneWay}" + SelectedItem="{Binding SelectedStorageFile, Mode=TwoWay}"> - - + - + - - + + - + diff --git a/src/Views/AddWorktree.axaml b/src/Views/AddWorktree.axaml index 1811008c6..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"> - - + + + + + + + - - - - - - - - - - - + + - - - - - - - - - - + + 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 4ac2d85f1..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; @@ -31,7 +32,7 @@ private async void SelectLocation(object _, RoutedEventArgs e) } 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 @@ + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + Text="{Binding File, Mode=OneWay}"/> - + @@ -107,11 +80,65 @@ HorizontalAlignment="Center" VerticalAlignment="Center" IsVisible="{Binding IsBinary}"> - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Blame.axaml.cs b/src/Views/Blame.axaml.cs index 734160e53..e1b5fb1ff 100644 --- a/src/Views/Blame.axaml.cs +++ b/src/Views/Blame.axaml.cs @@ -38,6 +38,8 @@ 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) { @@ -50,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( @@ -61,8 +63,10 @@ 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 author = new FormattedText( @@ -72,16 +76,19 @@ public override void Render(DrawingContext context) typeface, _editor.FontSize, _editor.Foreground); - context.DrawText(author, new Point(x, y)); + var authorTop = y - author.Height * 0.5; + context.DrawText(author, new Point(x, authorTop)); + var timeStr = Models.DateTimeFormat.Format(info.Timestamp, true); var time = new FormattedText( - info.Time, + timeStr, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, _editor.FontSize, _editor.Foreground); - context.DrawText(time, new Point(width - time.Width, y)); + var timeTop = y - time.Height * 0.5; + context.DrawText(time, new Point(width - time.Width, timeTop)); } } } @@ -124,8 +131,9 @@ protected override Size MeasureOverride(Size availableSize) _editor.Foreground); x += author.Width + 8; + var timeStr = Models.DateTimeFormat.Format(info.Timestamp, true); var time = new FormattedText( - info.Time, + timeStr, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, @@ -150,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) { @@ -161,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, @@ -170,7 +179,7 @@ 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"); @@ -223,7 +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; @@ -245,7 +254,7 @@ public VerticalSeparatorMargin(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) @@ -256,22 +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 StyledProperty TabWidthProperty = - AvaloniaProperty.Register(nameof(TabWidth), 4); + public static readonly DirectProperty TabWidthProperty = + AvaloniaProperty.RegisterDirect( + nameof(TabWidth), + static o => o.TabWidth, + static (o, v) => o.TabWidth = v); public int TabWidth { - get => GetValue(TabWidthProperty); - set => SetValue(TabWidthProperty, value); + get => _tabWidth; + set => SetAndRaise(TabWidthProperty, ref _tabWidth, value); } protected override Type StyleKeyOverride => typeof(TextEditor); @@ -282,54 +357,23 @@ public int TabWidth ShowLineNumbers = false; WordWrap = false; - Options.IndentationSize = TabWidth; + Options.IndentationSize = _tabWidth; Options.EnableHyperlinks = false; Options.EnableEmailHyperlinks = false; _textMate = Models.TextMateHelper.CreateForEditor(this); - TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); - TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); TextArea.LeftMargins.Add(new CommitInfoMargin(this) { Margin = new Thickness(8, 0) }); TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); + TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); + TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); TextArea.Caret.PositionChanged += OnTextAreaCaretPositionChanged; + TextArea.TextView.BackgroundRenderers.Add(new LineBackgroundRenderer(this)); TextArea.TextView.ContextRequested += OnTextViewContextRequested; TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged; TextArea.TextView.Margin = new Thickness(4, 0); } - public override void Render(DrawingContext context) - { - base.Render(context); - - if (string.IsNullOrEmpty(_highlight)) - return; - - var view = TextArea.TextView; - if (view is not { VisualLinesValid: true }) - return; - - var color = (Color)this.FindResource("SystemAccentColor")!; - var brush = new SolidColorBrush(color, 0.4); - foreach (var line in view.VisualLines) - { - if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) - continue; - - var lineNumber = line.FirstDocumentLine.LineNumber; - if (lineNumber > BlameData.LineInfos.Count) - break; - - var info = BlameData.LineInfos[lineNumber - 1]; - if (info.CommitSHA != _highlight) - continue; - - var startY = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - view.VerticalOffset; - var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - view.VerticalOffset; - context.FillRectangle(brush, new Rect(0, startY, Bounds.Width, endY - startY)); - } - } - protected override void OnUnloaded(RoutedEventArgs e) { base.OnUnloaded(e); @@ -350,23 +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 is { IsBinary: false } blame) - { - Models.TextMateHelper.SetGrammarByFileName(_textMate, blame.File); + if (_blameData is { IsBinary: false } blame) Text = blame.Content; - } else - { Text = string.Empty; - } } else if (change.Property == TabWidthProperty) { - Options.IndentationSize = TabWidth; + Options.IndentationSize = _tabWidth; } - else if (change.Property.Name == "ActualThemeVariant" && change.NewValue != null) + else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) { Models.TextMateHelper.SetThemeByApp(_textMate); } @@ -374,15 +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(); + _highlight = _blameData.LineInfos[caret.Line - 1].CommitSHA; } private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) @@ -393,10 +436,10 @@ private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs var copy = new MenuItem(); copy.Header = App.Text("Copy"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); copy.Click += async (_, ev) => { - await App.CopyTextAsync(selected); + await this.CopyTextAsync(selected); ev.Handled = true; }; @@ -417,10 +460,11 @@ private void OnTextViewVisualLinesChanged(object sender, EventArgs e) break; } } - - InvalidateVisual(); } + private string _file = null; + private Models.BlameData _blameData = null; + private int _tabWidth = 4; private TextMate.Installation _textMate = null; private string _highlight = string.Empty; } 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 11434b8e5..98ded57a1 100644 --- a/src/Views/BranchTree.axaml +++ b/src/Views/BranchTree.axaml @@ -1,7 +1,8 @@ - @@ -27,7 +29,7 @@ @@ -35,8 +37,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -49,18 +125,21 @@ IsChecked="{Binding IsExpanded, Mode=OneWay}" IsVisible="{Binding !IsBranch}"/> - + - + @@ -72,13 +151,12 @@ Width="12" Height="12" Margin="8,0" Background="Transparent" - ToolTip.Tip="{DynamicResource Text.BranchUpstreamInvalid}" IsVisible="{Binding ShowUpstreamGoneTip}"> - - diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 891efa4fc..1c3cfdc3c 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -50,25 +50,27 @@ private void UpdateContent() if (node.Backend is Models.Remote) { - CreateContent(new Thickness(0, 0, 0, 0), "Icons.Remote", false); + CreateContent(new Thickness(0, 0, 0, 0), "Icons.Remote"); } else if (node.Backend is Models.Branch branch) { if (branch.IsCurrent) - CreateContent(new Thickness(0, 0, 0, 0), "Icons.CheckCircled", true); + 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", false); + CreateContent(new Thickness(2, 0, 0, 0), "Icons.Branch"); } else { if (node.IsExpanded) - CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder.Open", false); + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder.Open"); else - CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder", false); + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder"); } } - private void CreateContent(Thickness margin, string iconKey, bool highlight) + private void CreateContent(Thickness margin, string iconKey, IBrush fill = null) { if (this.FindResource(iconKey) is not StreamGeometry geo) return; @@ -83,8 +85,8 @@ private void CreateContent(Thickness margin, string iconKey, bool highlight) Data = geo, }; - if (highlight) - path.Fill = Brushes.Green; + if (fill != null) + path.Fill = fill; Content = path; } @@ -145,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); @@ -168,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); @@ -181,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), @@ -200,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); @@ -235,6 +305,15 @@ 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(); @@ -246,7 +325,7 @@ public void Select(Models.Branch branch) return; var treePath = new List(); - FindTreePath(treePath, Nodes, branch.Name, 0); + FindTreePath(treePath, _nodes, branch.Name, 0); if (treePath.Count == 0) return; @@ -335,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); } @@ -352,6 +431,10 @@ 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; @@ -365,13 +448,6 @@ private void OnNodePointerPressed(object sender, PointerPressedEventArgs e) if (node.Backend is not Models.Branch branch) return; - if (BranchesPresenter.SelectedItems is { Count: > 0 }) - { - var ctrl = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; - if (e.KeyModifiers.HasFlag(ctrl) || e.KeyModifiers.HasFlag(KeyModifiers.Shift)) - return; - } - repo.NavigateToCommit(branch.Head); } @@ -435,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; } @@ -450,79 +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 mergeMulti = new MenuItem(); - mergeMulti.Header = App.Text("BranchCM.MergeMultiBranches", branches.Count); - mergeMulti.Icon = App.CreateMenuIcon("Icons.Merge"); - mergeMulti.Click += (_, ev) => + if (branches.Count == 2) { - repo.MergeMultipleBranches(branches); - ev.Handled = true; - }; - menu.Items.Add(mergeMulti); - menu.Items.Add(new MenuItem() { Header = "-" }); + 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); + } - var deleteMulti = new MenuItem(); - deleteMulti.Header = App.Text("BranchCM.DeleteMultiBranches", branches.Count); - deleteMulti.Icon = App.CreateMenuIcon("Icons.Clear"); - deleteMulti.Click += (_, ev) => + if (branches.Find(x => x.IsCurrent) == null) { - repo.DeleteMultipleBranches(branches, branches[0].IsLocal); - ev.Handled = true; - }; - menu.Items.Add(deleteMulti); + 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.Open(this); + 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 is not (Key.Delete or Key.Back)) - return; + 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 repo = DataContext as ViewModels.Repository; - if (repo?.Settings == null) - return; + var selected = BranchesPresenter.SelectedItems; + if (selected == null || selected.Count == 0) + 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); - 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) + else if (e.Key == Key.F2 && e.KeyModifiers == KeyModifiers.None) { - if (item is ViewModels.BranchTreeNode node) - CollectBranchesInNode(branches, node); - } + var repo = DataContext as ViewModels.Repository; + if (repo?.Settings == null) + return; - if (branches.Find(x => x.IsCurrent) != null) - return; + var selected = BranchesPresenter.SelectedItems; + if (selected == null || selected.Count != 1) + return; - if (branches.Count == 1) - repo.DeleteBranch(branches[0]); - else - repo.DeleteMultipleBranches(branches, branches[0].IsLocal); + 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; + e.Handled = true; + } + } } - private void OnDoubleTappedBranchNode(object sender, TappedEventArgs _) + private async void OnDoubleTappedBranchNode(object sender, TappedEventArgs _) { if (sender is Grid { DataContext: ViewModels.BranchTreeNode node }) { @@ -532,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 { @@ -585,6 +700,721 @@ private void FindTreePath(List outPath, List 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 f43230e46..948d87e9c 100644 --- a/src/Views/CaptionButtons.axaml +++ b/src/Views/CaptionButtons.axaml @@ -6,13 +6,13 @@ x:Class="SourceGit.Views.CaptionButtons" x:Name="ThisControl"> - - - diff --git a/src/Views/CaptionButtons.axaml.cs b/src/Views/CaptionButtons.axaml.cs index 806d9cb5e..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,7 +40,7 @@ private void MaximizeOrRestoreWindow(object _, RoutedEventArgs e) private void CloseWindow(object _, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); + 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 9ff1486ff..be9143d1b 100644 --- a/src/Views/ChangeCollectionView.axaml +++ b/src/Views/ChangeCollectionView.axaml @@ -33,42 +33,47 @@ - - + + + + - - - - - - - + + + + @@ -78,30 +83,32 @@ - - + - - - - + + + - - + + @@ -111,25 +118,29 @@ - - + - - - - - + + + + diff --git a/src/Views/ChangeCollectionView.axaml.cs b/src/Views/ChangeCollectionView.axaml.cs index 8b9f90586..0a271664f 100644 --- a/src/Views/ChangeCollectionView.axaml.cs +++ b/src/Views/ChangeCollectionView.axaml.cs @@ -21,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); } @@ -29,102 +33,125 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) } } - public class ChangeCollectionContainer : ListBox + public class ChangeCollectionContainer : ListBoxEx { protected override Type StyleKeyOverride => typeof(ListBox); - protected override async void OnKeyDown(KeyEventArgs e) + protected override void OnKeyDown(KeyEventArgs e) { - if (SelectedItems is [ViewModels.ChangeTreeNode node]) + if (SelectedItems is [ViewModels.ChangeTreeNode node] && e.KeyModifiers == KeyModifiers.None) { - if (((e.Key == Key.Left && node.IsExpanded) || (e.Key == Key.Right && !node.IsExpanded)) && - e.KeyModifiers == KeyModifiers.None) + if (e.Key == Key.Left) { - this.FindAncestorOfType()?.ToggleNodeIsExpanded(node); - e.Handled = true; + 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.C && - e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + else if (e.Key == Key.Right && node.IsFolder) { - var path = node.FullPath; - - if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + if (!node.IsExpanded) { - do - { - var repoView = this.FindAncestorOfType(); - if (repoView is { DataContext: ViewModels.Repository repo }) - { - path = Native.OS.GetAbsPath(repo.FullPath, path); - break; - } - - var branchCompareView = this.FindAncestorOfType(); - if (branchCompareView is { DataContext: ViewModels.BranchCompare branchCompare }) - { - path = branchCompare.GetAbsPath(path); - break; - } - - // NOTE: if there is another window uses ChangeCollectionView, add it here! - } while (false); + this.FindAncestorOfType()?.ToggleNodeIsExpanded(node); + e.Handled = true; + } + else if (node.Children.Count > 0) + { + Select(node.Children[0]); + e.Handled = true; } - - await App.CopyTextAsync(path); - e.Handled = true; } } - if (!e.Handled && e.Key != Key.Space && e.Key != Key.Enter) + 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 = @@ -177,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; @@ -254,21 +281,24 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang 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 control) + if (sender is not Control { DataContext: { } ctx } control) return; - if (control.DataContext is ViewModels.ChangeTreeNode node) + if (ctx is ViewModels.ChangeTreeNode node) { if (node.Change is { } c) UpdateRowTips(control, c); else ToolTip.SetTip(control, node.FullPath); } - else if (control.DataContext is Models.Change change) + else if (ctx is Models.Change change) { UpdateRowTips(control, change); } @@ -280,8 +310,10 @@ private void OnRowDataContextChanged(object sender, EventArgs e) 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) { @@ -296,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)); } @@ -324,7 +356,7 @@ private void OnRowSelectionChanged(object sender, SelectionChangedEventArgs _) var old = SelectedChanges ?? []; if (old.Count != selected.Count) { - SetCurrentValue(SelectedChangesProperty, selected); + SelectedChanges = selected; } else { @@ -339,7 +371,7 @@ private void OnRowSelectionChanged(object sender, SelectionChangedEventArgs _) } if (!allEquals) - SetCurrentValue(SelectedChangesProperty, selected); + SelectedChanges = selected; } _disableSelectionChangingEvent = false; @@ -362,7 +394,7 @@ private void UpdateDataSource(bool onlyViewModeChange) { _disableSelectionChangingEvent = !onlyViewModeChange; - var changes = Changes; + var changes = _changes; if (changes == null || changes.Count == 0) { Content = null; @@ -370,10 +402,10 @@ private void UpdateDataSource(bool onlyViewModeChange) return; } - var selected = SelectedChanges ?? []; + var selected = _selectedChanges ?? []; if (ViewMode == Models.ChangeViewMode.Tree) { - HashSet oldFolded = new HashSet(); + var oldFolded = new HashSet(); if (Content is ViewModels.ChangeCollectionAsTree oldTree) { foreach (var row in oldTree.Rows) @@ -384,7 +416,7 @@ private void UpdateDataSource(bool onlyViewModeChange) } 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); @@ -434,7 +466,7 @@ private void UpdateSelection() _disableSelectionChangingEvent = true; - var selected = SelectedChanges ?? []; + var selected = _selectedChanges ?? []; if (Content is ViewModels.ChangeCollectionAsTree tree) { tree.SelectedRows.Clear(); @@ -442,7 +474,6 @@ private void UpdateSelection() if (selected.Count > 0) { var sets = new HashSet(selected); - var nodes = new List(); foreach (var row in tree.Rows) { @@ -497,6 +528,11 @@ private void UpdateRowTips(Control control, Models.Change change) 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 4cbbf0a66..ee48cbc89 100644 --- a/src/Views/ChangeStatusIcon.cs +++ b/src/Views/ChangeStatusIcon.cs @@ -10,7 +10,7 @@ namespace SourceGit.Views { public class ChangeStatusIcon : Control { - private static readonly string[] INDICATOR = ["?", "±", "T", "+", "−", "➜", "❏", "★", "!"]; + private static readonly string[] INDICATOR = ["?", "M", "T", "+", "−", "➜", "❏", "?", "!"]; private static readonly Color[] COLOR = [ Colors.Transparent, @@ -20,36 +20,41 @@ public class ChangeStatusIcon : Control Colors.Tomato, Colors.Orchid, Colors.Goldenrod, - Colors.LimeGreen, + Colors.SkyBlue, Colors.OrangeRed, ]; - 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 idx = (int)(IsUnstagedChange ? Change.WorkTree : Change.Index); + 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(); @@ -82,10 +87,13 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { base.OnPropertyChanged(change); - if (change.Property == IsUnstagedChangeProperty || - change.Property == ChangeProperty || - (change.Property.Name == "ActualThemeVariant" && change.NewValue != null)) + if (change.Property == IsUnstagedChangeProperty || change.Property == ChangeProperty) + InvalidateVisual(); + else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) InvalidateVisual(); } + + private bool _isUnstagedChange = false; + private Models.Change _change = null; } } diff --git a/src/Views/ChangeSubmoduleUrl.axaml b/src/Views/ChangeSubmoduleUrl.axaml index ad458133e..bf3d7b3db 100644 --- a/src/Views/ChangeSubmoduleUrl.axaml +++ b/src/Views/ChangeSubmoduleUrl.axaml @@ -3,13 +3,21 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:SourceGit.ViewModels" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.ChangeSubmoduleUrl" x:DataType="vm:ChangeSubmoduleUrl"> - + + + + + + + Text="{Binding Url, Mode=TwoWay}"> + + + + + + + + diff --git a/src/Views/ChangeViewModeSwitcher.axaml.cs b/src/Views/ChangeViewModeSwitcher.axaml.cs index ed3066196..a51eccefd 100644 --- a/src/Views/ChangeViewModeSwitcher.axaml.cs +++ b/src/Views/ChangeViewModeSwitcher.axaml.cs @@ -6,13 +6,16 @@ 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() @@ -37,5 +40,7 @@ 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 14ee84b53..3d91c7072 100644 --- a/src/Views/Checkout.axaml +++ b/src/Views/Checkout.axaml @@ -3,49 +3,43 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:SourceGit.ViewModels" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.Checkout" x:DataType="vm:Checkout"> - + + - - - - - - + + + - + - - - - - - - + + + + diff --git a/src/Views/CheckoutAndFastForward.axaml b/src/Views/CheckoutAndFastForward.axaml index ad0422b3f..42fbf544c 100644 --- a/src/Views/CheckoutAndFastForward.axaml +++ b/src/Views/CheckoutAndFastForward.axaml @@ -3,22 +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" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.CheckoutAndFastForward" x:DataType="vm:CheckoutAndFastForward"> - + + - - - - - - - + + + + IsVisible="{Binding LocalBranch.IsTrackStatusVisible, Mode=OneWay}"> + Text="{Binding LocalBranch.TrackStatusDescription, Mode=OneWay}"/> @@ -49,25 +50,17 @@ - - - - - - - + + + + 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 11b4b5d04..000000000 --- a/src/Views/CheckoutCommit.axaml +++ /dev/null @@ -1,58 +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/CheckoutCommit.axaml.cs b/src/Views/CheckoutDetached.axaml.cs similarity index 57% rename from src/Views/CheckoutCommit.axaml.cs rename to src/Views/CheckoutDetached.axaml.cs index 375816c93..8f2221cb5 100644 --- a/src/Views/CheckoutCommit.axaml.cs +++ b/src/Views/CheckoutDetached.axaml.cs @@ -2,9 +2,9 @@ namespace SourceGit.Views { - public partial class CheckoutCommit : UserControl + public partial class CheckoutDetached : UserControl { - public CheckoutCommit() + public CheckoutDetached() { InitializeComponent(); } diff --git a/src/Views/CherryPick.axaml b/src/Views/CherryPick.axaml index bf66864c1..85cd19514 100644 --- a/src/Views/CherryPick.axaml +++ b/src/Views/CherryPick.axaml @@ -9,9 +9,16 @@ x:Class="SourceGit.Views.CherryPick" x:DataType="vm:CherryPick"> - + + + + + + - - + + @@ -66,8 +81,16 @@ - - + + diff --git a/src/Views/ChromelessWindow.cs b/src/Views/ChromelessWindow.cs index 24c652dcf..a06ab7565 100644 --- a/src/Views/ChromelessWindow.cs +++ b/src/Views/ChromelessWindow.cs @@ -1,8 +1,9 @@ using System; - +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Interactivity; namespace SourceGit.Views { @@ -74,14 +75,55 @@ 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 is { Handled: false, Key: Key.Escape, KeyModifiers: KeyModifiers.None } && CloseOnESC) + 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; + } } } 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"> - + + + + + - + + - + + + + + Text="{Binding Remote, Mode=TwoWay}"> + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/CommitMessageTextBox.axaml.cs b/src/Views/CommitMessageTextBox.axaml.cs deleted file mode 100644 index cf08eb6b0..000000000 --- a/src/Views/CommitMessageTextBox.axaml.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System; - -using Avalonia; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; - -namespace SourceGit.Views -{ - public class EnhancedTextBox : TextBox - { - public static readonly RoutedEvent PreviewKeyDownEvent = - RoutedEvent.Register(nameof(KeyEventArgs), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); - - public event EventHandler PreviewKeyDown - { - add { AddHandler(PreviewKeyDownEvent, value); } - remove { RemoveHandler(PreviewKeyDownEvent, value); } - } - - protected override Type StyleKeyOverride => typeof(TextBox); - - public void Paste(string text) - { - OnTextInput(new TextInputEventArgs() { Text = text }); - } - - protected override void OnKeyDown(KeyEventArgs e) - { - var dump = new KeyEventArgs() - { - RoutedEvent = PreviewKeyDownEvent, - Route = RoutingStrategies.Direct, - Source = e.Source, - Key = e.Key, - KeyModifiers = e.KeyModifiers, - PhysicalKey = e.PhysicalKey, - KeySymbol = e.KeySymbol, - }; - - RaiseEvent(dump); - - if (dump.Handled) - e.Handled = true; - else - base.OnKeyDown(e); - } - } - - public partial class CommitMessageTextBox : UserControl - { - public enum TextChangeWay - { - None, - FromSource, - FromEditor, - } - - public static readonly StyledProperty ShowAdvancedOptionsProperty = - AvaloniaProperty.Register(nameof(ShowAdvancedOptions)); - - public static readonly StyledProperty TextProperty = - AvaloniaProperty.Register(nameof(Text), string.Empty); - - public static readonly StyledProperty SubjectProperty = - AvaloniaProperty.Register(nameof(Subject), string.Empty); - - public static readonly StyledProperty DescriptionProperty = - AvaloniaProperty.Register(nameof(Description), string.Empty); - - public bool ShowAdvancedOptions - { - get => GetValue(ShowAdvancedOptionsProperty); - set => SetValue(ShowAdvancedOptionsProperty, value); - } - - public string Text - { - get => GetValue(TextProperty); - set => SetValue(TextProperty, value); - } - - public string Subject - { - get => GetValue(SubjectProperty); - set => SetValue(SubjectProperty, value); - } - - public string Description - { - get => GetValue(DescriptionProperty); - set => SetValue(DescriptionProperty, value); - } - - public CommitMessageTextBox() - { - InitializeComponent(); - } - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == TextProperty && _changingWay == TextChangeWay.None) - { - _changingWay = TextChangeWay.FromSource; - var normalized = Text.ReplaceLineEndings("\n"); - var parts = normalized.Split("\n\n", 2); - if (parts.Length != 2) - parts = [normalized, string.Empty]; - SetCurrentValue(SubjectProperty, parts[0].ReplaceLineEndings(" ")); - SetCurrentValue(DescriptionProperty, parts[1]); - _changingWay = TextChangeWay.None; - } - else if ((change.Property == SubjectProperty || change.Property == DescriptionProperty) && _changingWay == TextChangeWay.None) - { - _changingWay = TextChangeWay.FromEditor; - SetCurrentValue(TextProperty, $"{Subject}\n\n{Description}"); - _changingWay = TextChangeWay.None; - } - } - - private async void OnSubjectTextBoxPreviewKeyDown(object _, KeyEventArgs e) - { - if (e.Key == Key.Enter || (e.Key == Key.Right && SubjectEditor.CaretIndex == Subject.Length)) - { - DescriptionEditor.Focus(); - DescriptionEditor.CaretIndex = 0; - e.Handled = true; - } - else if (e.Key == Key.V && e.KeyModifiers == (OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) - { - e.Handled = true; - - var text = await App.GetClipboardTextAsync(); - if (!string.IsNullOrWhiteSpace(text)) - { - text = text.Trim(); - - if (SubjectEditor.CaretIndex == Subject.Length) - { - var parts = text.Split('\n', 2); - if (parts.Length != 2) - { - SubjectEditor.Paste(text); - } - else - { - SubjectEditor.Paste(parts[0]); - DescriptionEditor.Focus(); - DescriptionEditor.CaretIndex = 0; - DescriptionEditor.Paste(parts[1].Trim()); - } - } - else - { - SubjectEditor.Paste(text.ReplaceLineEndings(" ")); - } - } - } - } - - private void OnDescriptionTextBoxPreviewKeyDown(object _, KeyEventArgs e) - { - if ((e.Key == Key.Back || e.Key == Key.Left) && DescriptionEditor.CaretIndex == 0) - { - SubjectEditor.Focus(); - SubjectEditor.CaretIndex = Subject.Length; - e.Handled = true; - } - } - - private void OnOpenCommitMessagePicker(object sender, RoutedEventArgs e) - { - if (sender is Button button && DataContext is ViewModels.WorkingCopy vm) - { - var menu = vm.CreateContextMenuForCommitMessages(); - menu.Placement = PlacementMode.TopEdgeAlignedLeft; - menu.Open(button); - } - - e.Handled = true; - } - - private void OnOpenOpenAIHelper(object sender, RoutedEventArgs e) - { - if (DataContext is ViewModels.WorkingCopy vm && sender is Control control) - { - var menu = vm.CreateContextForOpenAI(); - menu?.Open(control); - } - - e.Handled = true; - } - - private void OnOpenConventionalCommitHelper(object _, RoutedEventArgs e) - { - var toplevel = TopLevel.GetTopLevel(this); - if (toplevel is Window owner) - { - var vm = new ViewModels.ConventionalCommitMessageBuilder(text => Text = text); - var builder = new ConventionalCommitMessageBuilder() { DataContext = vm }; - builder.ShowDialog(owner); - } - - e.Handled = true; - } - - private async void CopyAllText(object sender, RoutedEventArgs e) - { - await App.CopyTextAsync(Text); - e.Handled = true; - } - - private TextChangeWay _changingWay = TextChangeWay.None; - } -} diff --git a/src/Views/CommitMessageToolBox.axaml b/src/Views/CommitMessageToolBox.axaml new file mode 100644 index 000000000..8f65e5372 --- /dev/null +++ b/src/Views/CommitMessageToolBox.axaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 d221c155a..51aae1359 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -8,16 +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 Models.Decorator Decorator { get; set; } = null; + public List Remotes { get; set; } = []; } public static readonly StyledProperty FontFamilyProperty = @@ -56,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)); @@ -83,17 +148,6 @@ public bool ShowTags set => SetValue(ShowTagsProperty, value); } - static CommitRefsPresenter() - { - AffectsMeasure( - FontFamilyProperty, - FontSizeProperty, - ForegroundProperty, - UseGraphColorProperty, - BackgroundProperty, - ShowTagsProperty); - } - public Models.Decorator DecoratorAt(Point point) { var x = 0.0; @@ -116,19 +170,20 @@ public override void Render(DrawingContext context) var fg = Foreground; var bg = Background; var allowWrap = AllowWrap; - var x = 1.0; - var y = 0.0; + var x = 1.5; + var y = 0.5; + + context.FillRectangle(Brushes.Transparent, Bounds); foreach (var item in _items) { - if (allowWrap && x > 1.0 && x + item.Width > Bounds.Width) + if (allowWrap && x > 1.5 && x + item.Width > Bounds.Width) { - x = 1.0; + x = 1.5; y += 20.0; } - var entireRect = new RoundedRect(new Rect(x, y, item.Width, 16), new CornerRadius(2)); - + var entireRect = new RoundedRect(new Rect(x, y, item.Width, 16), new CornerRadius(4)); if (item.IsHead) { if (useGraphColor) @@ -139,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, y + 8.0 - item.Label.Height * 0.5)); } else { if (bg != null) context.DrawRectangle(bg, null, entireRect); - var labelRect = new RoundedRect(new Rect(x + 16, y, item.Label.Width + 8, 16), new CornerRadius(0, 2, 2, 0)); + var labelRect = new RoundedRect(new Rect(x + 16, y, item.Width - 16, 16), new CornerRadius(4, 0, 0, 4)); using (context.PushOpacity(.2)) context.DrawRectangle(item.Brush, null, labelRect); + } + + context.DrawLine(new Pen(item.Brush), new Point(x + 16, y), new Point(x + 16, y + 16)); + context.DrawText(item.Label, new Point(x + 20, y + 8.0 - item.Label.Height * 0.5)); - 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, y + 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); @@ -178,94 +260,119 @@ protected override Size MeasureOverride(Size availableSize) return new Size(0, 0); var refs = commit.Decorators; - if (refs is { Count: > 0 }) + var count = refs.Count; + if (count == 0) { - var typeface = new Typeface(FontFamily); - var typefaceBold = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); - var fg = Foreground; - var normalBG = UseGraphColor ? commit.Brush : Brushes.Gray; - var labelSize = FontSize; - var requiredHeight = 16.0; - var x = 0.0; - var allowWrap = AllowWrap; - var showTags = ShowTags; - - foreach (var decorator in refs) - { - if (!showTags && decorator.Type == Models.DecoratorType.Tag) - continue; + InvalidateVisual(); + return new Size(0, 0); + } + + var useCompactBranchNames = UseCompactBranchNames; + var typeface = new Typeface(FontFamily); + var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); + var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold); + var fg = Foreground; + var normalBG = UseGraphColor ? Models.CommitGraph.Pens[commit.Color].Brush : Brushes.Gray; + var labelSize = FontSize; + var requiredHeight = 16.0; + var x = 0.0; + var allowWrap = AllowWrap; + var showTags = ShowTags; + var skippedIdx = new HashSet(); + + for (var i = 0; i < count; i++) + { + if (skippedIdx.Contains(i)) + continue; + + var decorator = refs[i]; + if (!showTags && decorator.Type == Models.DecoratorType.Tag) + continue; - var isHead = decorator.Type is Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead; + var item = new RenderItem() + { + Decorator = decorator, + Brush = decorator.Type == Models.DecoratorType.Tag ? Brushes.Gray : normalBG, + IsHead = decorator.Type is Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead, + }; + _items.Add(item); - var label = new FormattedText( + if (item.IsHead) + { + item.Label = new FormattedText( decorator.Name, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, - isHead ? typefaceBold : typeface, - isHead ? labelSize + 1 : labelSize, + typefaceHead, + labelSize + 1, fg); + } + else + { + item.Label = new FormattedText( + decorator.Name, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + labelSize, + fg); + } - var item = new RenderItem() - { - Label = label, - Brush = normalBG, - IsHead = isHead, - Decorator = decorator, - }; - - StreamGeometry geo; - switch (decorator.Type) - { - case Models.DecoratorType.CurrentBranchHead: - case Models.DecoratorType.CurrentCommitHead: - geo = this.FindResource("Icons.Head") as StreamGeometry; - break; - case Models.DecoratorType.RemoteBranchHead: - geo = this.FindResource("Icons.Remote") as StreamGeometry; - break; - case Models.DecoratorType.Tag: - item.Brush = Brushes.Gray; - geo = this.FindResource("Icons.Tag") as StreamGeometry; - break; - default: - geo = this.FindResource("Icons.Branch") as StreamGeometry; - break; - } + item.Width = item.Label.Width + 24; - var drawGeo = geo!.Clone(); - var iconBounds = drawGeo.Bounds; - var translation = Matrix.CreateTranslation(-(Vector)iconBounds.Position); - var scale = Math.Min(10.0 / iconBounds.Width, 10.0 / iconBounds.Height); - var transform = translation * Matrix.CreateScale(scale, scale); - if (drawGeo.Transform == null || drawGeo.Transform.Value == Matrix.Identity) - drawGeo.Transform = new MatrixTransform(transform); - else - drawGeo.Transform = new MatrixTransform(drawGeo.Transform.Value * transform); - - item.Icon = drawGeo; - item.Width = 16 + (isHead ? 0 : 4) + label.Width + 4; - _items.Add(item); - - x += item.Width + 4; - if (allowWrap) + var findRemotes = useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); + if (findRemotes) + { + for (var j = i + 1; j < count; j++) { - if (x > availableSize.Width) + 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)) { - requiredHeight += 20.0; - x = item.Width; + 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 requiredWidth = allowWrap && requiredHeight > 16.0 - ? availableSize.Width - : x + 2; - InvalidateVisual(); - return new Size(requiredWidth, requiredHeight); + x += item.Width + 4; + if (allowWrap) + { + if (x > availableSize.Width) + { + requiredHeight += 20.0; + x = item.Width; + } + } + } + + double requiredWidth = 0; + if (_items.Count > 0) + { + if (allowWrap && requiredHeight > 16.0) + requiredWidth = double.IsInfinity(availableSize.Width) ? x + 2 : availableSize.Width; + else + requiredWidth = x + 2; } InvalidateVisual(); - return new Size(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 9d036e10b..53906bfe3 100644 --- a/src/Views/CommitRelationTracking.axaml +++ b/src/Views/CommitRelationTracking.axaml @@ -23,7 +23,7 @@ - + diff --git a/src/Views/CommitStatusIndicator.cs b/src/Views/CommitStatusIndicator.cs index 7073011a0..08d29572b 100644 --- a/src/Views/CommitStatusIndicator.cs +++ b/src/Views/CommitStatusIndicator.cs @@ -8,13 +8,16 @@ namespace SourceGit.Views { public class CommitStatusIndicator : Control { - public static readonly StyledProperty CurrentBranchProperty = - AvaloniaProperty.Register(nameof(CurrentBranch)); + public static readonly DirectProperty CurrentBranchProperty = + AvaloniaProperty.RegisterDirect( + nameof(CurrentBranch), + static o => o.CurrentBranch, + static (o, v) => o.CurrentBranch = v); public Models.Branch CurrentBranch { - get => GetValue(CurrentBranchProperty); - set => SetValue(CurrentBranchProperty, value); + get => _currentBranch; + set => SetAndRaise(CurrentBranchProperty, ref _currentBranch, value); } public static readonly StyledProperty AheadBrushProperty = @@ -52,14 +55,13 @@ public override void Render(DrawingContext context) protected override Size MeasureOverride(Size availableSize) { - if (DataContext is Models.Commit commit && CurrentBranch is not null) + if (DataContext is Models.Commit commit && _currentBranch != null) { var sha = commit.SHA; - var track = CurrentBranch.TrackStatus; - if (track.Ahead.Contains(sha)) + if (_currentBranch.Ahead.Contains(sha)) _status = Status.Ahead; - else if (track.Behind.Contains(sha)) + else if (_currentBranch.Behind.Contains(sha)) _status = Status.Behind; else _status = Status.Normal; @@ -85,6 +87,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang InvalidateMeasure(); } + private Models.Branch _currentBranch = null; private Status _status = Status.Normal; } } diff --git a/src/Views/CommitSubjectPresenter.cs b/src/Views/CommitSubjectPresenter.cs index 5fc4cb5d7..694907274 100644 --- a/src/Views/CommitSubjectPresenter.cs +++ b/src/Views/CommitSubjectPresenter.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Collections.Specialized; using System.Globalization; using System.Text.RegularExpressions; @@ -75,22 +77,46 @@ public IBrush LinkForeground set => SetValue(LinkForegroundProperty, value); } - public static readonly StyledProperty SubjectProperty = - AvaloniaProperty.Register(nameof(Subject)); + 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 => GetValue(SubjectProperty); - set => SetValue(SubjectProperty, value); + get => _subject; + set => SetAndRaise(SubjectProperty, ref _subject, value); } - public static readonly StyledProperty> IssueTrackerRulesProperty = - AvaloniaProperty.Register>(nameof(IssueTrackerRules)); + public static readonly DirectProperty> IssueTrackersProperty = + AvaloniaProperty.RegisterDirect>( + nameof(IssueTrackers), + static o => o.IssueTrackers, + static (o, v) => o.IssueTrackers = v); - public AvaloniaList IssueTrackerRules + public AvaloniaList IssueTrackers { - get => GetValue(IssueTrackerRulesProperty); - set => SetValue(IssueTrackerRulesProperty, value); + get => _issueTrackers; + set => SetAndRaise(IssueTrackersProperty, ref _issueTrackers, value); } public override void Render(DrawingContext context) @@ -114,10 +140,11 @@ public override void Render(DrawingContext context) { var height = Bounds.Height; var width = Bounds.Width; + var maxX = 0.0; foreach (var inline in _inlines) { if (inline.X > width) - return; + break; if (inline.Element is { Type: Models.InlineElementType.Code }) { @@ -125,12 +152,17 @@ public override void Render(DrawingContext context) 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)); } } @@ -138,45 +170,21 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { base.OnPropertyChanged(change); - if (change.Property == SubjectProperty || change.Property == IssueTrackerRulesProperty) + if (change.Property == SubjectProperty) { - _elements.Clear(); - ClearHoveredIssueLink(); - - var subject = Subject; - if (string.IsNullOrEmpty(subject)) - { - _needRebuildInlines = true; - InvalidateVisual(); - return; - } - - var rules = IssueTrackerRules ?? []; - foreach (var rule in rules) - rule.Matches(_elements, subject); - - var keywordMatch = REG_KEYWORD_FORMAT1().Match(subject); - if (!keywordMatch.Success) - keywordMatch = REG_KEYWORD_FORMAT2().Match(subject); - - if (keywordMatch.Success && _elements.Intersect(0, keywordMatch.Length) == null) - _elements.Add(new Models.InlineElement(Models.InlineElementType.Keyword, 0, keywordMatch.Length, string.Empty)); - - var codeMatches = REG_INLINECODE_FORMAT().Matches(subject); - 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(); _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 || @@ -187,7 +195,8 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang _needRebuildInlines = true; InvalidateVisual(); } - else if (change.Property == InlineCodeBackgroundProperty) + else if (change.Property == InlineCodeBackgroundProperty || + change.Property == ShowStrikethroughProperty) { InvalidateVisual(); } @@ -230,6 +239,75 @@ protected override void OnPointerExited(PointerEventArgs 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(); @@ -242,6 +320,7 @@ private void GenerateFormattedTextElements() 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); @@ -296,7 +375,7 @@ private void GenerateFormattedTextElements() FlowDirection.LeftToRight, codeTypeface, fontSize - 0.5, - foreground); + inlineCodeForeground); _inlines.Add(new Inline(x, link, elem)); x += link.WidthIncludingTrailingWhitespace + 8; } @@ -331,12 +410,6 @@ private void ClearHoveredIssueLink() [GeneratedRegex(@"`.*?`")] private static partial Regex REG_INLINECODE_FORMAT(); - [GeneratedRegex(@"^\[[\w\s]+\]")] - private static partial Regex REG_KEYWORD_FORMAT1(); - - [GeneratedRegex(@"^\S+([\<\(][\w\s_\-\*,]+[\>\)])?\!?\s?:\s")] - private static partial Regex REG_KEYWORD_FORMAT2(); - private class Inline { public double X { get; set; } = 0; @@ -351,9 +424,11 @@ public Inline(double x, FormattedText text, Models.InlineElement elem) } } + private string _subject = string.Empty; + private AvaloniaList _issueTrackers = null; private Models.InlineElementCollector _elements = new(); - private List _inlines = []; private Models.InlineElement _lastHover = null; + private List _inlines = []; private bool _needRebuildInlines = false; } } diff --git a/src/Views/CommitTimeTextBlock.cs b/src/Views/CommitTimeTextBlock.cs index f83741776..66c4090ab 100644 --- a/src/Views/CommitTimeTextBlock.cs +++ b/src/Views/CommitTimeTextBlock.cs @@ -10,31 +10,52 @@ namespace SourceGit.Views { public class CommitTimeTextBlock : TextBlock { - public static readonly StyledProperty ShowAsDateTimeProperty = - AvaloniaProperty.Register(nameof(ShowAsDateTime), true); + public static readonly DirectProperty ShowAsDateTimeProperty = + AvaloniaProperty.RegisterDirect( + nameof(ShowAsDateTime), + static o => o.ShowAsDateTime, + static (o, v) => o.ShowAsDateTime = v); public bool ShowAsDateTime { - get => GetValue(ShowAsDateTimeProperty); - set => SetValue(ShowAsDateTimeProperty, value); + get => _showAsDateTime; + set => SetAndRaise(ShowAsDateTimeProperty, ref _showAsDateTime, value); } - public static readonly StyledProperty DateTimeFormatProperty = - AvaloniaProperty.Register(nameof(DateTimeFormat)); + 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 => GetValue(DateTimeFormatProperty); - set => SetValue(DateTimeFormatProperty, value); + get => _dateTimeFormat; + set => SetAndRaise(DateTimeFormatProperty, ref _dateTimeFormat, value); } - public static readonly StyledProperty UseAuthorTimeProperty = - AvaloniaProperty.Register(nameof(UseAuthorTime), true); + public static readonly DirectProperty TimestampProperty = + AvaloniaProperty.RegisterDirect( + nameof(Timestamp), + static o => o.Timestamp, + static (o, v) => o.Timestamp = v); - public bool UseAuthorTime + public ulong Timestamp { - get => GetValue(UseAuthorTimeProperty); - set => SetValue(UseAuthorTimeProperty, value); + get => _timestamp; + set => SetAndRaise(TimestampProperty, ref _timestamp, value); } protected override Type StyleKeyOverride => typeof(TextBlock); @@ -43,7 +64,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { base.OnPropertyChanged(change); - if (change.Property == UseAuthorTimeProperty) + if (change.Property == TimestampProperty) { SetCurrentValue(TextProperty, GetDisplayText()); } @@ -53,16 +74,16 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (ShowAsDateTime) { - StopTimer(); + _refreshTimer?.Stop(); HorizontalAlignment = HorizontalAlignment.Left; } else { - StartTimer(); + _refreshTimer?.Start(); HorizontalAlignment = HorizontalAlignment.Center; } } - else if (change.Property == DateTimeFormatProperty) + else if (change.Property == DateTimeFormatProperty || change.Property == Use24HoursProperty) { if (ShowAsDateTime) SetCurrentValue(TextProperty, GetDisplayText()); @@ -73,14 +94,27 @@ protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); - if (!ShowAsDateTime) - StartTimer(); + _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); - StopTimer(); } protected override void OnDataContextChanged(EventArgs e) @@ -89,42 +123,12 @@ protected override void OnDataContextChanged(EventArgs e) SetCurrentValue(TextProperty, GetDisplayText()); } - private void StartTimer() - { - if (_refreshTimer != null) - return; - - _refreshTimer = DispatcherTimer.Run(() => - { - Dispatcher.UIThread.Invoke(() => - { - var text = GetDisplayText(); - if (!text.Equals(Text, StringComparison.Ordinal)) - Text = text; - }); - - return true; - }, TimeSpan.FromSeconds(10)); - } - - private void StopTimer() - { - if (_refreshTimer != null) - { - _refreshTimer.Dispose(); - _refreshTimer = null; - } - } - private string GetDisplayText() { - if (DataContext is not Models.Commit commit) - return string.Empty; - + var timestamp = Timestamp; if (ShowAsDateTime) - return UseAuthorTime ? commit.AuthorTimeStr : commit.CommitterTimeStr; + return Models.DateTimeFormat.Format(timestamp); - var timestamp = UseAuthorTime ? commit.AuthorTime : commit.CommitterTime; var now = DateTime.Now; var localTime = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); var span = now - localTime; @@ -167,6 +171,10 @@ private string GetDisplayText() return App.Text("Period.YearsAgo", diffYear); } - private IDisposable _refreshTimer = null; + 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 index 1e7c9fa83..520afeff2 100644 --- a/src/Views/ConfigureCustomActionControls.axaml +++ b/src/Views/ConfigureCustomActionControls.axaml @@ -129,9 +129,11 @@ - + + + @@ -160,7 +162,14 @@ + Text="{Binding StringValue, Mode=TwoWay}"> + + + + + + + @@ -187,12 +196,29 @@ + + + + + + + - + - - + - + diff --git a/src/Views/Conflict.axaml.cs b/src/Views/Conflict.axaml.cs index 6121b5c8c..0ed1fa8f1 100644 --- a/src/Views/Conflict.axaml.cs +++ b/src/Views/Conflict.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.VisualTree; namespace SourceGit.Views @@ -19,5 +20,40 @@ private void OnPressedSHA(object sender, PointerPressedEventArgs e) 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 a64037f82..394901dc9 100644 --- a/src/Views/ConventionalCommitMessageBuilder.axaml +++ b/src/Views/ConventionalCommitMessageBuilder.axaml @@ -47,8 +47,8 @@ @@ -87,7 +87,7 @@ @@ -98,7 +98,7 @@ @@ -131,7 +131,7 @@ ScrollViewer.VerticalScrollBarVisibility="Auto" VerticalAlignment="Center" VerticalContentAlignment="Top" - CornerRadius="2" + CornerRadius="3" Watermark="{DynamicResource Text.Optional}" Text="{Binding BreakingChanges, Mode=TwoWay}"/> @@ -142,7 +142,7 @@ diff --git a/src/Views/CreateBranch.axaml b/src/Views/CreateBranch.axaml index c52d86961..670a6ff9a 100644 --- a/src/Views/CreateBranch.axaml +++ b/src/Views/CreateBranch.axaml @@ -10,10 +10,22 @@ x:Class="SourceGit.Views.CreateBranch" x:DataType="vm:CreateBranch"> - - + + + + + + + + + + + + - + - - + + @@ -48,58 +69,45 @@ HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,8,0" Text="{DynamicResource Text.CreateBranch.Name}"/> - - - - - + - - - - - - + + + + + + + + + + + + + + + + - + ToolTip.Tip="{Binding OverrideTip, Mode=OneWay}"/> - - - - - - - - - - - diff --git a/src/Views/CreateGroup.axaml b/src/Views/CreateGroup.axaml index 05ec5bf25..b939224e4 100644 --- a/src/Views/CreateGroup.axaml +++ b/src/Views/CreateGroup.axaml @@ -3,16 +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" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.CreateGroup" x:DataType="vm:CreateGroup"> - + + + + + - + diff --git a/src/Views/CreateTag.axaml b/src/Views/CreateTag.axaml index 55b6052fe..eefb54f6b 100644 --- a/src/Views/CreateTag.axaml +++ b/src/Views/CreateTag.axaml @@ -10,9 +10,16 @@ x:Class="SourceGit.Views.CreateTag" x:DataType="vm:CreateTag"> - + + + + + + - - + + @@ -41,13 +57,12 @@ HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,8,0" Text="{DynamicResource Text.CreateTag.Name}"/> - + @@ -71,10 +85,11 @@ Height="64" AcceptsReturn="True" AcceptsTab="False" VerticalAlignment="Center" VerticalContentAlignment="Top" - CornerRadius="2" + CornerRadius="3" Watermark="{DynamicResource Text.CreateTag.Message.Placeholder}" Text="{Binding Message, Mode=TwoWay}" - IsVisible="{Binding Annotated}"/> + IsVisible="{Binding Annotated}" + Tag="{Binding Source={x:Static v:StealHotKey.Enter}}"/> ShowDateOnlyProperty = + AvaloniaProperty.RegisterDirect( + nameof(ShowDateOnly), + static o => o.ShowDateOnly, + static (o, v) => o.ShowDateOnly = v); + + public bool ShowDateOnly + { + get => _showDateOnly; + set => SetAndRaise(ShowDateOnlyProperty, ref _showDateOnly, 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); + + public DateTimePresenter() + { + Bind(Use24HoursProperty, new Binding() + { + Mode = BindingMode.OneWay, + Source = ViewModels.Preferences.Instance, + Path = "Use24Hours" + }); + + Bind(DateTimeFormatProperty, new Binding() + { + Mode = BindingMode.OneWay, + Source = ViewModels.Preferences.Instance, + Path = "DateTimeFormat" + }); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ShowDateOnlyProperty || + change.Property == Use24HoursProperty || + change.Property == DateTimeFormatProperty || + change.Property == TimestampProperty) + { + var text = Models.DateTimeFormat.Format(Timestamp, ShowDateOnly); + SetCurrentValue(TextProperty, text); + } + } + + private bool _showDateOnly = false; + private bool _use24Hours = true; + private int _dateTimeFormat = 0; + private ulong _timestamp = 0; + } +} diff --git a/src/Views/DealWithLocalChangesMethod.axaml b/src/Views/DealWithLocalChangesMethod.axaml new file mode 100644 index 000000000..c0663173b --- /dev/null +++ b/src/Views/DealWithLocalChangesMethod.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + diff --git a/src/Views/DealWithLocalChangesMethod.axaml.cs b/src/Views/DealWithLocalChangesMethod.axaml.cs new file mode 100644 index 000000000..a55510308 --- /dev/null +++ b/src/Views/DealWithLocalChangesMethod.axaml.cs @@ -0,0 +1,91 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class DealWithLocalChangesMethod : UserControl + { + public static readonly DirectProperty MethodProperty = + AvaloniaProperty.RegisterDirect( + nameof(Method), + static o => o.Method, + static (o, v) => o.Method = v); + + public Models.DealWithLocalChanges Method + { + get => _method; + set => SetAndRaise(MethodProperty, ref _method, value); + } + + public DealWithLocalChangesMethod() + { + Focusable = true; + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == MethodProperty) + UpdateRadioButtons(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (e.Key == Key.Up) + { + if (Method == Models.DealWithLocalChanges.StashAndReapply) + Method = Models.DealWithLocalChanges.DoNothing; + else if (Method == Models.DealWithLocalChanges.Discard) + Method = Models.DealWithLocalChanges.StashAndReapply; + e.Handled = true; + } + else if (e.Key == Key.Down) + { + if (Method == Models.DealWithLocalChanges.DoNothing) + Method = Models.DealWithLocalChanges.StashAndReapply; + else if (Method == Models.DealWithLocalChanges.StashAndReapply) + Method = Models.DealWithLocalChanges.Discard; + e.Handled = true; + } + } + + private void OnRadioButtonClicked(object sender, RoutedEventArgs e) + { + if (sender is RadioButton { Tag: Models.DealWithLocalChanges way }) + { + Method = way; + e.Handled = true; + } + } + + private void UpdateRadioButtons() + { + switch (_method) + { + case Models.DealWithLocalChanges.DoNothing: + RadioDoNothing.IsChecked = true; + RadioStashAndReapply.IsChecked = false; + RadioDiscard.IsChecked = false; + break; + case Models.DealWithLocalChanges.StashAndReapply: + RadioDoNothing.IsChecked = false; + RadioStashAndReapply.IsChecked = true; + RadioDiscard.IsChecked = false; + break; + default: + RadioDoNothing.IsChecked = false; + RadioStashAndReapply.IsChecked = false; + RadioDiscard.IsChecked = true; + break; + } + } + + private Models.DealWithLocalChanges _method = Models.DealWithLocalChanges.DoNothing; + } +} diff --git a/src/Views/DeinitSubmodule.axaml b/src/Views/DeinitSubmodule.axaml index f589d5bc1..24464ea12 100644 --- a/src/Views/DeinitSubmodule.axaml +++ b/src/Views/DeinitSubmodule.axaml @@ -7,9 +7,15 @@ x:Class="SourceGit.Views.DeinitSubmodule" x:DataType="vm:DeinitSubmodule"> - + + + + + diff --git a/src/Views/DeleteBranch.axaml b/src/Views/DeleteBranch.axaml index 3439fc9e4..3cd83e73d 100644 --- a/src/Views/DeleteBranch.axaml +++ b/src/Views/DeleteBranch.axaml @@ -8,9 +8,15 @@ x:Class="SourceGit.Views.DeleteBranch" x:DataType="vm:DeleteBranch"> - + + + + + @@ -23,27 +29,32 @@ VerticalAlignment="Center" CornerRadius="9" Background="{DynamicResource Brush.Badge}" - IsVisible="{Binding Target.TrackStatus.IsVisible}"> + IsVisible="{Binding Target.IsTrackStatusVisible}"> + Text="{Binding Target.TrackStatusDescription}"/> - - - + + - - - - - + + + + + diff --git a/src/Views/DeleteMultipleBranches.axaml b/src/Views/DeleteMultipleBranches.axaml index cf084e14c..10d026f7c 100644 --- a/src/Views/DeleteMultipleBranches.axaml +++ b/src/Views/DeleteMultipleBranches.axaml @@ -8,11 +8,23 @@ x:Class="SourceGit.Views.DeleteMultipleBranches" x:DataType="vm:DeleteMultipleBranches"> - + + - + + + + + + + + + + + @@ -44,7 +62,7 @@ - + + IsVisible="{Binding IsTrackStatusVisible}"> + Text="{Binding TrackStatusDescription, Mode=OneWay}"/> @@ -64,10 +82,9 @@ - + diff --git a/src/Views/DeleteMultipleTags.axaml b/src/Views/DeleteMultipleTags.axaml new file mode 100644 index 000000000..71791870d --- /dev/null +++ b/src/Views/DeleteMultipleTags.axaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Reword.axaml.cs b/src/Views/DeleteMultipleTags.axaml.cs similarity index 56% rename from src/Views/Reword.axaml.cs rename to src/Views/DeleteMultipleTags.axaml.cs index f05f708af..5c0e1b9dd 100644 --- a/src/Views/Reword.axaml.cs +++ b/src/Views/DeleteMultipleTags.axaml.cs @@ -2,9 +2,9 @@ namespace SourceGit.Views { - public partial class Reword : UserControl + public partial class DeleteMultipleTags : UserControl { - public Reword() + public DeleteMultipleTags() { InitializeComponent(); } diff --git a/src/Views/DeleteRemote.axaml b/src/Views/DeleteRemote.axaml index af62e99ba..f8ab1033d 100644 --- a/src/Views/DeleteRemote.axaml +++ b/src/Views/DeleteRemote.axaml @@ -7,9 +7,16 @@ x:Class="SourceGit.Views.DeleteRemote" x:DataType="vm:DeleteRemote"> - + + + + + + diff --git a/src/Views/DeleteRepositoryNode.axaml b/src/Views/DeleteRepositoryNode.axaml index 5442dd194..5971e250d 100644 --- a/src/Views/DeleteRepositoryNode.axaml +++ b/src/Views/DeleteRepositoryNode.axaml @@ -4,18 +4,24 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:c="using:SourceGit.Converters" xmlns:vm="using:SourceGit.ViewModels" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.DeleteRepositoryNode" x:DataType="vm:DeleteRepositoryNode"> - - + + + + + + + - + - + + + + + + - + + + + + + - + - - - + + + + + + + + + + + + + - + - - - - - - - - - - - - - + + + + + + + - - - - - - @@ -150,10 +147,10 @@ + ToolTip.Tip="{DynamicResource Text.Diff.VisualLines.All}" + PropertyChanged="OnToggleButtonPropertyChanged"> @@ -172,7 +169,7 @@ ToolTip.Tip="{DynamicResource Text.Diff.ToggleWordWrap}"> - + @@ -180,13 +177,6 @@ - - - - + ToolTip.Tip="{DynamicResource Text.Diff.SideBySide}" + PropertyChanged="OnToggleButtonPropertyChanged"> - - - + + + + + + + + + + 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 615e3f110..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/EnhancedSelectableTextBlock.cs b/src/Views/EnhancedSelectableTextBlock.cs deleted file mode 100644 index 183b7021e..000000000 --- a/src/Views/EnhancedSelectableTextBlock.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; - -using Avalonia; -using Avalonia.Controls; - -namespace SourceGit.Views -{ - public class EnhancedSelectableTextBlock : SelectableTextBlock - { - protected override Type StyleKeyOverride => typeof(SelectableTextBlock); - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == TextProperty) - UpdateLayout(); - } - } -} diff --git a/src/Views/ExecuteCustomAction.axaml b/src/Views/ExecuteCustomAction.axaml index 4f0c6c6d9..6769f9481 100644 --- a/src/Views/ExecuteCustomAction.axaml +++ b/src/Views/ExecuteCustomAction.axaml @@ -4,15 +4,21 @@ 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" - Loaded="OnLoaded"> + x:DataType="vm:ExecuteCustomAction"> - + + + + + - + - + - - + + @@ -50,6 +65,13 @@ + + + + + + + @@ -128,6 +150,28 @@ IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> + + + + + + + + + diff --git a/src/Views/ExecuteCustomAction.axaml.cs b/src/Views/ExecuteCustomAction.axaml.cs index fa7e97139..5589101e0 100644 --- a/src/Views/ExecuteCustomAction.axaml.cs +++ b/src/Views/ExecuteCustomAction.axaml.cs @@ -1,10 +1,8 @@ using System; using Avalonia.Controls; -using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Platform.Storage; -using Avalonia.VisualTree; namespace SourceGit.Views { @@ -15,19 +13,6 @@ public ExecuteCustomAction() InitializeComponent(); } - private void OnLoaded(object sender, RoutedEventArgs e) - { - var inputs = this.GetVisualDescendants(); - foreach (var input in inputs) - { - if (input is InputElement { Focusable: true, IsTabStop: true } focusable) - { - focusable.Focus(); - return; - } - } - } - private async void SelectPath(object sender, RoutedEventArgs e) { var topLevel = TopLevel.GetTopLevel(this); @@ -54,7 +39,7 @@ private async void SelectPath(object sender, RoutedEventArgs e) } catch (Exception exception) { - App.RaiseException(string.Empty, $"Failed to select parent folder: {exception.Message}"); + Models.Notification.Send(null, $"Failed to select parent folder: {exception.Message}", true); } } else @@ -62,7 +47,7 @@ private async void SelectPath(object sender, RoutedEventArgs e) var options = new FilePickerOpenOptions() { AllowMultiple = false, - FileTypeFilter = [new FilePickerFileType("File") { Patterns = ["*.*"] }] + FileTypeFilter = [new("SSHKey") { Patterns = ["*"] }] }; var selected = await topLevel.StorageProvider.OpenFilePickerAsync(options); 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/Fetch.axaml b/src/Views/Fetch.axaml index 676693808..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}"> + + + + + + @@ -38,8 +50,10 @@ ToolTip.Tip="--force"/> - + + + + + + diff --git a/src/Views/FileHistories.axaml b/src/Views/FileHistories.axaml index b7914c3d2..f499c2caf 100644 --- a/src/Views/FileHistories.axaml +++ b/src/Views/FileHistories.axaml @@ -38,7 +38,7 @@ - + @@ -59,9 +59,11 @@ BorderThickness="1" Margin="8,4,4,8" BorderBrush="{DynamicResource Brush.Border2}" - ItemsSource="{Binding Commits}" - SelectedItems="{Binding SelectedCommits, Mode=TwoWay}" + ItemsSource="{Binding Revisions, Mode=OneWay}" SelectionMode="Multiple" + SelectionChanged="OnRevisionsSelectionChanged" + PropertyChanged="OnRevisionsPropertyChanged" + ContextRequested="OnRevisionsContextRequested" ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.VerticalScrollBarVisibility="Auto"> @@ -79,26 +81,32 @@ - + - + - + - + @@ -111,7 +119,8 @@ HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" BorderThickness="1,0,0,0" - BorderBrush="{DynamicResource Brush.Border0}"/> + BorderBrush="{DynamicResource Brush.Border0}" + Focusable="False"/> @@ -146,7 +155,6 @@ + ToolTip.Tip="{DynamicResource Text.Open}"> @@ -197,19 +205,26 @@ - + - - - - - - - - + + + + + - + @@ -227,7 +242,7 @@ @@ -263,13 +278,5 @@ HorizontalAlignment="Center" VerticalAlignment="Center" IsVisible="{Binding IsLoading}"/> - - - - - - - - diff --git a/src/Views/FileHistories.axaml.cs b/src/Views/FileHistories.axaml.cs index 24c450861..697aee765 100644 --- a/src/Views/FileHistories.axaml.cs +++ b/src/Views/FileHistories.axaml.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; @@ -14,12 +16,66 @@ 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; @@ -30,18 +86,12 @@ private async void OnResetToSelectedRevision(object sender, RoutedEventArgs e) if (sender is Button { DataContext: ViewModels.FileHistoriesSingleRevision single }) { await single.ResetToSelectedRevisionAsync(); - NotifyDonePanel.IsVisible = true; + await new Alert().ShowAsync(this, "Reset to selected revision successfully.", false); } e.Handled = true; } - private void OnCloseNotifyPanel(object _, PointerPressedEventArgs e) - { - NotifyDonePanel.IsVisible = false; - e.Handled = true; - } - private async void OnSaveAsPatch(object sender, RoutedEventArgs e) { if (sender is Button { DataContext: ViewModels.FileHistoriesCompareRevisions compare }) @@ -51,11 +101,21 @@ private async void OnSaveAsPatch(object sender, RoutedEventArgs e) options.DefaultExtension = ".patch"; options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - var storageFile = await StorageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - await compare.SaveAsPatch(storageFile.Path.LocalPath); + 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); + } - NotifyDonePanel.IsVisible = true; e.Handled = true; } } @@ -68,12 +128,12 @@ private void OnCommitSubjectDataContextChanged(object sender, EventArgs e) private void OnCommitSubjectPointerMoved(object sender, PointerEventArgs e) { - if (sender is Border { DataContext: Models.Commit commit } border && + 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(commit)); + ToolTip.SetTip(border, vm.GetCommitFullMessage(ver)); } } 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 index b9e72b54d..d8d4b165e 100644 --- a/src/Views/FilterModeInGraph.axaml +++ b/src/Views/FilterModeInGraph.axaml @@ -8,7 +8,9 @@ x:DataType="vm:FilterModeInGraph"> + Text="{DynamicResource Text.Repository.FilterCommits}" + FontWeight="Bold" + Foreground="{DynamicResource MenuFlyoutItemKeyboardAcceleratorTextForeground}"/> diff --git a/src/Views/FilterModeSwitchButton.axaml b/src/Views/FilterModeSwitchButton.axaml index b202c4346..7e3b21d73 100644 --- a/src/Views/FilterModeSwitchButton.axaml +++ b/src/Views/FilterModeSwitchButton.axaml @@ -11,11 +11,12 @@ Padding="0" Background="Transparent" VerticalContentAlignment="Center" - Click="OnChangeFilterModeButtonClicked"> + Click="OnChangeFilterModeButtonClicked" + ToolTip.Tip="{DynamicResource Text.Repository.FilterCommits}"> ModeProperty = - AvaloniaProperty.Register(nameof(Mode)); + public static readonly DirectProperty ModeProperty = + AvaloniaProperty.RegisterDirect( + nameof(Mode), + static o => o.Mode, + static (o, v) => o.Mode = v); public Models.FilterMode Mode { - get => GetValue(ModeProperty); - set => SetValue(ModeProperty, value); + get => _mode; + set => SetAndRaise(ModeProperty, ref _mode, value); } - public static readonly StyledProperty IsNoneVisibleProperty = - AvaloniaProperty.Register(nameof(IsNoneVisible)); + public static readonly DirectProperty IsContextMenuOpeningProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsContextMenuOpening), + static o => o.IsContextMenuOpening); - public bool IsNoneVisible + public bool IsContextMenuOpening { - get => GetValue(IsNoneVisibleProperty); - set => SetValue(IsNoneVisibleProperty, value); + get => _isContextMenuOpening; + set => SetAndRaise(IsContextMenuOpeningProperty, ref _isContextMenuOpening, value); } - public static readonly StyledProperty IsContextMenuOpeningProperty = - AvaloniaProperty.Register(nameof(IsContextMenuOpening)); + public static readonly StyledProperty IsHoverParentProperty = + AvaloniaProperty.Register(nameof(IsHoverParent)); - public bool IsContextMenuOpening + public bool IsHoverParent { - get => GetValue(IsContextMenuOpeningProperty); - set => SetValue(IsContextMenuOpeningProperty, value); + get => GetValue(IsHoverParentProperty); + set => SetValue(IsHoverParentProperty, value); } public FilterModeSwitchButton() @@ -45,10 +50,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang base.OnPropertyChanged(change); if (change.Property == ModeProperty || - change.Property == IsNoneVisibleProperty || + change.Property == IsHoverParentProperty || change.Property == IsContextMenuOpeningProperty) { - var visible = (Mode != Models.FilterMode.None || IsNoneVisible || IsContextMenuOpening); + var visible = (Mode != Models.FilterMode.None || IsHoverParent || IsContextMenuOpening); SetCurrentValue(IsVisibleProperty, visible); } } @@ -64,98 +69,101 @@ private void OnChangeFilterModeButtonClicked(object sender, RoutedEventArgs e) return; var menu = new ContextMenu(); - var mode = Models.FilterMode.None; - if (DataContext is Models.Tag tag) - { - mode = tag.FilterMode; + if (DataContext is ViewModels.TagListItem tagItem) + FillContextMenuForTag(menu, repo, tagItem.Tag, tagItem.FilterMode); + else if (DataContext is ViewModels.TagTreeNode tagNode) + FillContextMenuForTag(menu, repo, tagNode.Tag, tagNode.FilterMode); + else if (DataContext is ViewModels.BranchTreeNode branchNode) + FillContextMenuForBranch(menu, repo, branchNode, branchNode.FilterMode); + + menu.Closed += (_, _) => IsContextMenuOpening = false; + menu.Open(button); - if (mode != Models.FilterMode.None) - { - var unset = new MenuItem(); - unset.Header = App.Text("Repository.FilterCommits.Default"); - unset.Click += (_, ev) => - { - repo.SetTagFilterMode(tag, Models.FilterMode.None); - ev.Handled = true; - }; - - menu.Items.Add(unset); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var include = new MenuItem(); - include.Icon = App.CreateMenuIcon("Icons.Filter"); - include.Header = App.Text("Repository.FilterCommits.Include"); - include.IsEnabled = mode != Models.FilterMode.Included; - include.Click += (_, ev) => - { - repo.SetTagFilterMode(tag, Models.FilterMode.Included); - ev.Handled = true; - }; + IsContextMenuOpening = true; + e.Handled = true; + } - var exclude = new MenuItem(); - exclude.Icon = App.CreateMenuIcon("Icons.EyeClose"); - exclude.Header = App.Text("Repository.FilterCommits.Exclude"); - exclude.IsEnabled = mode != Models.FilterMode.Excluded; - exclude.Click += (_, ev) => + private void FillContextMenuForTag(ContextMenu menu, ViewModels.Repository repo, Models.Tag tag, Models.FilterMode current) + { + if (current != Models.FilterMode.None) + { + var unset = new MenuItem(); + unset.Header = App.Text("Repository.FilterCommits.Default"); + unset.Click += (_, ev) => { - repo.SetTagFilterMode(tag, Models.FilterMode.Excluded); + repo.SetTagFilterMode(tag, Models.FilterMode.None); ev.Handled = true; }; - menu.Items.Add(include); - menu.Items.Add(exclude); + menu.Items.Add(unset); + menu.Items.Add(new MenuItem() { Header = "-" }); } - else if (DataContext is ViewModels.BranchTreeNode node) + + var include = new MenuItem(); + include.Icon = this.CreateMenuIcon("Icons.Filter"); + include.Header = App.Text("Repository.FilterCommits.Include"); + include.IsEnabled = current != Models.FilterMode.Included; + include.Click += (_, ev) => { - mode = node.FilterMode; + repo.SetTagFilterMode(tag, Models.FilterMode.Included); + ev.Handled = true; + }; + + var exclude = new MenuItem(); + exclude.Icon = this.CreateMenuIcon("Icons.EyeClose"); + exclude.Header = App.Text("Repository.FilterCommits.Exclude"); + exclude.IsEnabled = current != Models.FilterMode.Excluded; + exclude.Click += (_, ev) => + { + repo.SetTagFilterMode(tag, Models.FilterMode.Excluded); + ev.Handled = true; + }; - if (mode != Models.FilterMode.None) - { - var unset = new MenuItem(); - unset.Header = App.Text("Repository.FilterCommits.Default"); - unset.Click += (_, ev) => - { - repo.SetBranchFilterMode(node, Models.FilterMode.None, false, true); - ev.Handled = true; - }; - - menu.Items.Add(unset); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var include = new MenuItem(); - include.Icon = App.CreateMenuIcon("Icons.Filter"); - include.Header = App.Text("Repository.FilterCommits.Include"); - include.IsEnabled = mode != Models.FilterMode.Included; - include.Click += (_, ev) => - { - repo.SetBranchFilterMode(node, Models.FilterMode.Included, false, true); - ev.Handled = true; - }; + menu.Items.Add(include); + menu.Items.Add(exclude); + } - var exclude = new MenuItem(); - exclude.Icon = App.CreateMenuIcon("Icons.EyeClose"); - exclude.Header = App.Text("Repository.FilterCommits.Exclude"); - exclude.IsEnabled = mode != Models.FilterMode.Excluded; - exclude.Click += (_, ev) => + private void FillContextMenuForBranch(ContextMenu menu, ViewModels.Repository repo, ViewModels.BranchTreeNode node, Models.FilterMode current) + { + if (current != Models.FilterMode.None) + { + var unset = new MenuItem(); + unset.Header = App.Text("Repository.FilterCommits.Default"); + unset.Click += (_, ev) => { - repo.SetBranchFilterMode(node, Models.FilterMode.Excluded, false, true); + repo.SetBranchFilterMode(node, Models.FilterMode.None, false, true); ev.Handled = true; }; - menu.Items.Add(include); - menu.Items.Add(exclude); + menu.Items.Add(unset); + menu.Items.Add(new MenuItem() { Header = "-" }); } - if (mode == Models.FilterMode.None) + var include = new MenuItem(); + include.Icon = this.CreateMenuIcon("Icons.Filter"); + include.Header = App.Text("Repository.FilterCommits.Include"); + include.IsEnabled = current != Models.FilterMode.Included; + include.Click += (_, ev) => { - IsContextMenuOpening = true; - menu.Closed += (_, _) => IsContextMenuOpening = false; - } + repo.SetBranchFilterMode(node, Models.FilterMode.Included, false, true); + ev.Handled = true; + }; + + var exclude = new MenuItem(); + exclude.Icon = this.CreateMenuIcon("Icons.EyeClose"); + exclude.Header = App.Text("Repository.FilterCommits.Exclude"); + exclude.IsEnabled = current != Models.FilterMode.Excluded; + exclude.Click += (_, ev) => + { + repo.SetBranchFilterMode(node, Models.FilterMode.Excluded, false, true); + ev.Handled = true; + }; - menu.Open(button); - e.Handled = true; + menu.Items.Add(include); + menu.Items.Add(exclude); } + + private Models.FilterMode _mode = Models.FilterMode.None; + private bool _isContextMenuOpening = false; } } diff --git a/src/Views/GitFlowFinish.axaml b/src/Views/GitFlowFinish.axaml index 43fde72d2..879d88d85 100644 --- a/src/Views/GitFlowFinish.axaml +++ b/src/Views/GitFlowFinish.axaml @@ -8,18 +8,25 @@ x:Class="SourceGit.Views.GitFlowFinish" x:DataType="vm:GitFlowFinish"> - - - + + + + + + + + + + - - + ToolTip.Tip="--keep"/> diff --git a/src/Views/GitFlowStart.axaml b/src/Views/GitFlowStart.axaml index 9a22e045a..51827c6d6 100644 --- a/src/Views/GitFlowStart.axaml +++ b/src/Views/GitFlowStart.axaml @@ -9,30 +9,54 @@ x:Class="SourceGit.Views.GitFlowStart" x:DataType="vm:GitFlowStart"> - - - - - + + + + + + + + + - + Text="{DynamicResource Text.GitFlow.StartName}"/> + + + + + + + + + + diff --git a/src/Views/GotoRevisionSelector.axaml b/src/Views/GotoRevisionSelector.axaml new file mode 100644 index 000000000..d29d40090 --- /dev/null +++ b/src/Views/GotoRevisionSelector.axaml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/GotoRevisionSelector.axaml.cs b/src/Views/GotoRevisionSelector.axaml.cs new file mode 100644 index 000000000..8483cdbb2 --- /dev/null +++ b/src/Views/GotoRevisionSelector.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class GotoRevisionSelector : ChromelessWindow + { + public GotoRevisionSelector() + { + CloseOnESC = true; + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + RevisionList.Focus(); + } + + private void OnRevisionListKeyDown(object sender, KeyEventArgs e) + { + if (e is not { Key: Key.Enter, KeyModifiers: KeyModifiers.None }) + return; + + if (sender is not ListBox { SelectedItem: Models.Commit commit }) + return; + + Close(commit); + e.Handled = true; + } + + private void OnRevisionListItemTapped(object sender, TappedEventArgs e) + { + if (sender is not Control { DataContext: Models.Commit commit }) + return; + + Close(commit); + e.Handled = true; + } + } +} + diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 904f1c606..9555d7e21 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -14,7 +14,7 @@ - + @@ -24,46 +24,47 @@ - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/LauncherPageSwitcher.axaml.cs b/src/Views/LauncherPageSwitcher.axaml.cs deleted file mode 100644 index 9bc0bf2d3..000000000 --- a/src/Views/LauncherPageSwitcher.axaml.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Input; - -namespace SourceGit.Views -{ - public partial class LauncherPageSwitcher : UserControl - { - public LauncherPageSwitcher() - { - InitializeComponent(); - } - - protected override void OnKeyDown(KeyEventArgs e) - { - base.OnKeyDown(e); - - if (e.Key == Key.Enter && DataContext is ViewModels.LauncherPageSwitcher switcher) - { - switcher.Switch(); - e.Handled = true; - } - } - - private void OnItemDoubleTapped(object sender, TappedEventArgs e) - { - if (DataContext is ViewModels.LauncherPageSwitcher switcher) - { - switcher.Switch(); - e.Handled = true; - } - } - - private void OnSearchBoxKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Down && PagesListBox.ItemCount > 0) - { - PagesListBox.Focus(NavigationMethod.Directional); - - if (PagesListBox.SelectedIndex < 0) - PagesListBox.SelectedIndex = 0; - else if (PagesListBox.SelectedIndex < PagesListBox.ItemCount) - PagesListBox.SelectedIndex++; - - e.Handled = true; - } - } - } -} diff --git a/src/Views/LauncherPagesCommandPalette.axaml b/src/Views/LauncherPagesCommandPalette.axaml new file mode 100644 index 000000000..70408dce8 --- /dev/null +++ b/src/Views/LauncherPagesCommandPalette.axaml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LauncherPagesCommandPalette.axaml.cs b/src/Views/LauncherPagesCommandPalette.axaml.cs new file mode 100644 index 000000000..2ebdb4bdf --- /dev/null +++ b/src/Views/LauncherPagesCommandPalette.axaml.cs @@ -0,0 +1,110 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class LauncherPagesCommandPalette : UserControl + { + public LauncherPagesCommandPalette() + { + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + FilterTextBox.Focus(NavigationMethod.Directional); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.LauncherPagesCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + vm.OpenOrSwitchTo(); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + if (RepoListBox.IsKeyboardFocusWithin) + { + if (vm.VisiblePages.Count > 0) + { + PageListBox.Focus(NavigationMethod.Directional); + vm.SelectedPage = vm.VisiblePages[^1]; + } + else + { + FilterTextBox.Focus(NavigationMethod.Directional); + } + + e.Handled = true; + return; + } + + if (PageListBox.IsKeyboardFocusWithin) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + else if (e.Key == Key.Down || e.Key == Key.Tab) + { + if (FilterTextBox.IsKeyboardFocusWithin) + { + if (vm.VisiblePages.Count > 0) + { + PageListBox.Focus(NavigationMethod.Directional); + vm.SelectedPage = vm.VisiblePages[0]; + } + else if (vm.VisibleRepos.Count > 0) + { + RepoListBox.Focus(NavigationMethod.Directional); + vm.SelectedRepo = vm.VisibleRepos[0]; + } + + e.Handled = true; + return; + } + + if (PageListBox.IsKeyboardFocusWithin) + { + if (vm.VisibleRepos.Count > 0) + { + RepoListBox.Focus(NavigationMethod.Directional); + vm.SelectedRepo = vm.VisibleRepos[0]; + } + else if (e.Key == Key.Tab) + { + FilterTextBox.Focus(NavigationMethod.Directional); + } + + e.Handled = true; + return; + } + + if (RepoListBox.IsKeyboardFocusWithin && e.Key == Key.Tab) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + } + + private void OnItemTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.LauncherPagesCommandPalette vm) + { + vm.OpenOrSwitchTo(); + e.Handled = true; + } + } + } +} diff --git a/src/Views/LauncherTabBar.axaml b/src/Views/LauncherTabBar.axaml index ce35697fd..76b4f7ddf 100644 --- a/src/Views/LauncherTabBar.axaml +++ b/src/Views/LauncherTabBar.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" xmlns:c="using:SourceGit.Converters" xmlns:v="using:SourceGit.Views" @@ -12,9 +13,10 @@ + IsVisible="{Binding #ThisControl.IsScrollButtonVisible}"> @@ -27,12 +29,30 @@ PointerWheelChanged="ScrollTabs"> + + + + + + + @@ -41,27 +61,32 @@ - + - + + + + - - + + + IsVisible="{Binding DirtyState, Converter={x:Static ObjectConverters.NotEqual}, ConverterParameter={x:Static m:DirtyState.None}}" + Fill="{Binding DirtyState, Converter={x:Static c:DirtyStateConverters.ToBrush}}"/> @@ -116,16 +139,16 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/MergeCommandPalette.axaml.cs b/src/Views/MergeCommandPalette.axaml.cs new file mode 100644 index 000000000..4d81f48b3 --- /dev/null +++ b/src/Views/MergeCommandPalette.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class MergeCommandPalette : UserControl + { + public MergeCommandPalette() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.MergeCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + vm.Launch(); + 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 void OnItemTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.MergeCommandPalette vm) + { + vm.Launch(); + e.Handled = true; + } + } + } +} diff --git a/src/Views/MergeConflictEditor.axaml b/src/Views/MergeConflictEditor.axaml new file mode 100644 index 000000000..e5b437966 --- /dev/null +++ b/src/Views/MergeConflictEditor.axaml @@ -0,0 +1,344 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/OpenFileCommandPalette.axaml.cs b/src/Views/OpenFileCommandPalette.axaml.cs new file mode 100644 index 000000000..24c6082b3 --- /dev/null +++ b/src/Views/OpenFileCommandPalette.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class OpenFileCommandPalette : UserControl + { + public OpenFileCommandPalette() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.OpenFileCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + 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.OpenFileCommandPalette vm) + { + vm.Launch(); + e.Handled = true; + } + } + } +} diff --git a/src/Views/OpenLocalRepository.axaml b/src/Views/OpenLocalRepository.axaml new file mode 100644 index 000000000..6886caa1e --- /dev/null +++ b/src/Views/OpenLocalRepository.axaml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/OpenLocalRepository.axaml.cs b/src/Views/OpenLocalRepository.axaml.cs new file mode 100644 index 000000000..151662c2c --- /dev/null +++ b/src/Views/OpenLocalRepository.axaml.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; + +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class OpenLocalRepository : UserControl + { + public OpenLocalRepository() + { + InitializeComponent(); + } + + private async void OnSelectRepositoryFolder(object _1, RoutedEventArgs e) + { + if (DataContext is not ViewModels.OpenLocalRepository vm) + return; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) + return; + + var preference = ViewModels.Preferences.Instance; + var workspace = preference.GetActiveWorkspace(); + var initDir = workspace.DefaultCloneDir; + if (string.IsNullOrEmpty(initDir) || !Directory.Exists(initDir)) + initDir = preference.GitDefaultCloneDir; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + if (Directory.Exists(initDir)) + { + var folder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(initDir); + options.SuggestedStartLocation = folder; + } + + try + { + var selected = await topLevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + var folder = selected[0]; + vm.RepoPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder?.Path.ToString(); + } + } + catch (Exception exception) + { + Models.Notification.Send(null, $"Failed to open repository: {exception.Message}", true); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/PopupDataTemplates.cs b/src/Views/PopupDataTemplates.cs new file mode 100644 index 000000000..bfb2673aa --- /dev/null +++ b/src/Views/PopupDataTemplates.cs @@ -0,0 +1,43 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class PopupDataTemplates : IDataTemplate + { + public bool Match(object data) + { + return data is ViewModels.Popup; + } + + public Control Build(object param) + { + var control = ControlExtensions.CreateFromViewModels(param); + + control.Loaded += (o, e) => + { + if (o is not Control ctl) + return; + + var inputs = ctl.GetVisualDescendants(); + foreach (var input in inputs) + { + if (input is SelectableTextBlock) + continue; + + if (input is InputElement { Focusable: true, IsEffectivelyEnabled: true } focusable) + { + focusable.Focus(NavigationMethod.Directional); + if (input is TextBox box) + box.CaretIndex = box.CaretIndex = box.Text?.Length ?? 0; + return; + } + } + }; + + return control; + } + } +} diff --git a/src/Views/PopupRunningStatus.axaml.cs b/src/Views/PopupRunningStatus.axaml.cs index 05258799b..72caaed86 100644 --- a/src/Views/PopupRunningStatus.axaml.cs +++ b/src/Views/PopupRunningStatus.axaml.cs @@ -7,13 +7,16 @@ namespace SourceGit.Views { public partial class PopupRunningStatus : UserControl { - public static readonly StyledProperty DescriptionProperty = - AvaloniaProperty.Register(nameof(Description)); + public static readonly DirectProperty DescriptionProperty = + AvaloniaProperty.RegisterDirect( + nameof(Description), + static o => o.Description, + static (o, v) => o.Description = v); public string Description { - get => GetValue(DescriptionProperty); - set => SetValue(DescriptionProperty, value); + get => _description; + set => SetAndRaise(DescriptionProperty, ref _description, value); } public PopupRunningStatus() @@ -63,6 +66,7 @@ private void StopAnim() ProgressBar.IsIndeterminate = false; } + private string _description = string.Empty; private bool _isUnloading = false; } } diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index bf5c29023..b0a4753bb 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:s="using:SourceGit" + xmlns:ai="using:SourceGit.AI" xmlns:m="using:SourceGit.Models" xmlns:c="using:SourceGit.Converters" xmlns:vm="using:SourceGit.ViewModels" @@ -46,7 +47,7 @@ - + - - - - - - - - - - + + + + + + + + + + + + + + @@ -134,20 +143,40 @@ + Content="{DynamicResource Text.Preferences.General.ShowChangesPageByDefault}" + IsChecked="{Binding ShowLocalChangesByDefault, Mode=TwoWay}"/> + Content="{DynamicResource Text.Preferences.General.ShowChangesTabInCommitDetailByDefault}" + IsChecked="{Binding ShowChangesInCommitDetailByDefault, Mode=TwoWay}"/> + Content="{DynamicResource Text.Preferences.General.ShowTagsInGraph}" + IsChecked="{Binding ShowTagsInGraph, Mode=TwoWay}"/> + + + + + + + + - + + + + + - - + + - + + IsChecked="{Binding #ThisControl.EnablePruneOnFetch, Mode=TwoWay}" + ToolTip.Tip="fetch.prune=true"/> + IsChecked="{Binding IgnoreCRAtEOLInDiff, Mode=TwoWay}" + ToolTip.Tip="--ignore-cr-at-eol"/> + IsChecked="{Binding #ThisControl.EnableHTTPSSLVerify, Mode=TwoWay}" + ToolTip.Tip="http.sslverify=true"/> + + + + + + + + + + + + @@ -425,11 +495,13 @@ + IsChecked="{Binding #ThisControl.EnableGPGCommitSigning, Mode=TwoWay}" + ToolTip.Tip="commit.gpgsign=true"/> + IsChecked="{Binding #ThisControl.EnableGPGTagSigning, Mode=TwoWay}" + ToolTip.Tip="tag.gpgsign=true"/> @@ -444,7 +516,7 @@ - + @@ -452,14 +524,14 @@ + SelectedIndex="{Binding ShellOrTerminalType, Mode=TwoWay}"> @@ -470,11 +542,13 @@ - + + + + + + + + + + + + + + @@ -493,7 +586,7 @@ - + @@ -522,11 +615,13 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -548,10 +681,10 @@ - + - + + + + + + @@ -657,20 +803,13 @@ - - - - - - + + @@ -715,7 +854,7 @@ - + @@ -731,13 +870,9 @@ - - - - @@ -756,7 +891,7 @@ - + @@ -764,33 +899,32 @@ - - - - - - - + + + + - - + Height="28" + Text="{Binding Model, Mode=TwoWay}" + IsEnabled="{Binding !AutoFetchAvailableModels, Mode=OneWay}"/> + + + - - diff --git a/src/Views/Preferences.axaml.cs b/src/Views/Preferences.axaml.cs index a6b4b7221..99ec73a5f 100644 --- a/src/Views/Preferences.axaml.cs +++ b/src/Views/Preferences.axaml.cs @@ -11,6 +11,28 @@ namespace SourceGit.Views { public partial class Preferences : ChromelessWindow { + public static readonly DirectProperty GitVersionProperty = + AvaloniaProperty.RegisterDirect( + nameof(GitVersion), + static o => o.GitVersion); + + public string GitVersion + { + get => _gitVersion; + set => SetAndRaise(GitVersionProperty, ref _gitVersion, value); + } + + public static readonly DirectProperty ShowGitVersionWarningProperty = + AvaloniaProperty.RegisterDirect( + nameof(ShowGitVersionWarning), + static o => o.ShowGitVersionWarning); + + public bool ShowGitVersionWarning + { + get => _showGitVersionWarning; + set => SetAndRaise(ShowGitVersionWarningProperty, ref _showGitVersionWarning, value); + } + public string DefaultUser { get; @@ -35,24 +57,6 @@ public bool EnablePruneOnFetch set; } - public static readonly StyledProperty GitVersionProperty = - AvaloniaProperty.Register(nameof(GitVersion)); - - public string GitVersion - { - get => GetValue(GitVersionProperty); - set => SetValue(GitVersionProperty, value); - } - - public static readonly StyledProperty ShowGitVersionWarningProperty = - AvaloniaProperty.Register(nameof(ShowGitVersionWarning)); - - public bool ShowGitVersionWarning - { - get => GetValue(ShowGitVersionWarningProperty); - set => SetValue(ShowGitVersionWarningProperty, value); - } - public bool EnableGPGCommitSigning { get; @@ -65,22 +69,28 @@ public bool EnableGPGTagSigning set; } - public static readonly StyledProperty GPGFormatProperty = - AvaloniaProperty.Register(nameof(GPGFormat), Models.GPGFormat.Supported[0]); + public static readonly DirectProperty GPGFormatProperty = + AvaloniaProperty.RegisterDirect( + nameof(GPGFormat), + static o => o.GPGFormat, + static (o, v) => o.GPGFormat = v); public Models.GPGFormat GPGFormat { - get => GetValue(GPGFormatProperty); - set => SetValue(GPGFormatProperty, value); + get => _gpgFormat; + set => SetAndRaise(GPGFormatProperty, ref _gpgFormat, value); } - public static readonly StyledProperty GPGExecutableFileProperty = - AvaloniaProperty.Register(nameof(GPGExecutableFile)); + public static readonly DirectProperty GPGExecutableFileProperty = + AvaloniaProperty.RegisterDirect( + nameof(GPGExecutableFile), + static o => o.GPGExecutableFile, + static (o, v) => o.GPGExecutableFile = v); public string GPGExecutableFile { - get => GetValue(GPGExecutableFileProperty); - set => SetValue(GPGExecutableFileProperty, value); + get => _gpgExecutableFile; + set => SetAndRaise(GPGExecutableFileProperty, ref _gpgExecutableFile, value); } public string GPGUserKey @@ -95,22 +105,28 @@ public bool EnableHTTPSSLVerify set; } = false; - public static readonly StyledProperty SelectedOpenAIServiceProperty = - AvaloniaProperty.Register(nameof(SelectedOpenAIService)); + public static readonly DirectProperty SelectedOpenAIServiceProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectedOpenAIService), + static o => o.SelectedOpenAIService, + static (o, v) => o.SelectedOpenAIService = v); - public Models.OpenAIService SelectedOpenAIService + public AI.Service SelectedOpenAIService { - get => GetValue(SelectedOpenAIServiceProperty); - set => SetValue(SelectedOpenAIServiceProperty, value); + get => _selectedOpenAIService; + set => SetAndRaise(SelectedOpenAIServiceProperty, ref _selectedOpenAIService, value); } - public static readonly StyledProperty SelectedCustomActionProperty = - AvaloniaProperty.Register(nameof(SelectedCustomAction)); + public static readonly DirectProperty SelectedCustomActionProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectedCustomAction), + static o => o.SelectedCustomAction, + static (o, v) => o.SelectedCustomAction = v); public Models.CustomAction SelectedCustomAction { - get => GetValue(SelectedCustomActionProperty); - set => SetValue(SelectedCustomActionProperty, value); + get => _selectedCustomAction; + set => SetAndRaise(SelectedCustomActionProperty, ref _selectedCustomAction, value); } public Preferences() @@ -121,7 +137,7 @@ public Preferences() if (pref.IsGitConfigured()) { - var config = new Commands.Config(null).ReadAllAsync().Result; + var config = new Commands.Config(null).ReadAll(); if (config.TryGetValue("user.name", out var name)) DefaultUser = name; @@ -205,7 +221,9 @@ protected override async void OnClosing(WindowClosingEventArgs e) await new Commands.Config(null).SetAsync($"gpg.{GPGFormat.Value}.program", GPGExecutableFile); } - ViewModels.Preferences.Instance.Save(); + var preferences = ViewModels.Preferences.Instance; + preferences.UpdateAvailableAIModels(); + preferences.Save(); } private async void SelectThemeOverrideFile(object _, RoutedEventArgs e) @@ -224,12 +242,18 @@ private async void SelectThemeOverrideFile(object _, RoutedEventArgs e) } catch (Exception ex) { - App.RaiseException(string.Empty, $"Failed to select theme: {ex.Message}"); + await new Alert().ShowAsync(this, $"Failed to select theme override file: {ex.Message}", true); } e.Handled = true; } + private void OpenThemeRepository(object _, RoutedEventArgs e) + { + Native.OS.OpenBrowser($"https://github.com/sourcegit-scm/sourcegit-theme"); + e.Handled = true; + } + private async void SelectGitExecutable(object _, RoutedEventArgs e) { var pattern = OperatingSystem.IsWindows() ? "git.exe" : "git"; @@ -250,7 +274,7 @@ private async void SelectGitExecutable(object _, RoutedEventArgs e) } catch (Exception ex) { - App.RaiseException(string.Empty, $"Failed to select git executable: {ex.Message}"); + await new Alert().ShowAsync(this, $"Failed to select git executable: {ex.Message}", true); } e.Handled = true; @@ -271,7 +295,7 @@ private async void SelectDefaultCloneDir(object _, RoutedEventArgs e) } catch (Exception ex) { - App.RaiseException(string.Empty, $"Failed to select default clone directory: {ex.Message}"); + await new Alert().ShowAsync(this, $"Failed to select default clone directory: {ex.Message}", true); } e.Handled = true; @@ -299,7 +323,7 @@ private async void SelectGPGExecutable(object _, RoutedEventArgs e) } catch (Exception ex) { - App.RaiseException(string.Empty, $"Failed to select gpg program: {ex.Message}"); + await new Alert().ShowAsync(this, $"Failed to select gpg program: {ex.Message}", true); } e.Handled = true; @@ -307,7 +331,7 @@ private async void SelectGPGExecutable(object _, RoutedEventArgs e) private async void SelectShellOrTerminal(object _, RoutedEventArgs e) { - var type = ViewModels.Preferences.Instance.ShellOrTerminal; + var type = ViewModels.Preferences.Instance.ShellOrTerminalType; if (type == -1) return; @@ -330,7 +354,7 @@ private async void SelectShellOrTerminal(object _, RoutedEventArgs e) } catch (Exception ex) { - App.RaiseException(string.Empty, $"Failed to select shell/terminal: {ex.Message}"); + await new Alert().ShowAsync(this, $"Failed to select shell/terminal: {ex.Message}", true); } e.Handled = true; @@ -349,7 +373,7 @@ private async void SelectExternalMergeTool(object _, RoutedEventArgs e) var tool = Models.ExternalMerger.Supported[type]; var options = new FilePickerOpenOptions() { - FileTypeFilter = [new FilePickerFileType(tool.Name) { Patterns = tool.GetPatterns() }], + FileTypeFilter = [new FilePickerFileType(tool.Name) { Patterns = tool.GetPatternsToFindExecFile() }], AllowMultiple = false, }; @@ -361,7 +385,7 @@ private async void SelectExternalMergeTool(object _, RoutedEventArgs e) } catch (Exception ex) { - App.RaiseException(string.Empty, $"Failed to select merge tool: {ex.Message}"); + await new Alert().ShowAsync(this, $"Failed to select merge tool: {ex.Message}", true); } e.Handled = true; @@ -384,7 +408,7 @@ private async void OnUseNativeWindowFrameChanged(object sender, RoutedEventArgs if (sender is CheckBox box) { ViewModels.Preferences.Instance.UseSystemWindowFrame = box.IsChecked == true; - await App.ShowDialog(new ConfirmRestart()); + await this.ShowDialogAsync(new ConfirmRestart()); } e.Handled = true; @@ -397,7 +421,7 @@ private void OnGitInstallPathChanged(object sender, TextChangedEventArgs e) private void OnAddOpenAIService(object sender, RoutedEventArgs e) { - var service = new Models.OpenAIService() { Name = "Unnamed Service" }; + var service = new AI.Service() { Name = "Unnamed Service" }; ViewModels.Preferences.Instance.OpenAIServices.Add(service); SelectedOpenAIService = service; @@ -427,8 +451,8 @@ private async void SelectExecutableForCustomAction(object sender, RoutedEventArg { var options = new FilePickerOpenOptions() { - FileTypeFilter = [new FilePickerFileType("Executable file(script)") { Patterns = ["*.*"] }], AllowMultiple = false, + FileTypeFilter = [new("Executable file(script)") { Patterns = ["*"] }] }; try @@ -439,7 +463,7 @@ private async void SelectExecutableForCustomAction(object sender, RoutedEventArg } catch (Exception ex) { - App.RaiseException(string.Empty, $"Failed to select program for custom action: {ex.Message}"); + await new Alert().ShowAsync(this, $"Failed to select executable for custom action: {ex.Message}", true); } e.Handled = true; @@ -484,12 +508,7 @@ private async void EditCustomActionControls(object sender, RoutedEventArgs e) if (sender is not Button { DataContext: Models.CustomAction act }) return; - var dialog = new ConfigureCustomActionControls() - { - DataContext = new ViewModels.ConfigureCustomActionControls(act.Controls) - }; - - await dialog.ShowDialog(this); + await this.ShowDialogAsync(new ViewModels.ConfigureCustomActionControls(act.Controls)); e.Handled = true; } @@ -498,5 +517,12 @@ private void UpdateGitVersion() GitVersion = Native.OS.GitVersionString; ShowGitVersionWarning = !string.IsNullOrEmpty(GitVersion) && Native.OS.GitVersion < Models.GitVersions.MINIMAL; } + + private string _gitVersion = string.Empty; + private bool _showGitVersionWarning = false; + private Models.GPGFormat _gpgFormat = Models.GPGFormat.Supported[0]; + private string _gpgExecutableFile = string.Empty; + private AI.Service _selectedOpenAIService = null; + private Models.CustomAction _selectedCustomAction = null; } } diff --git a/src/Views/PruneRemote.axaml b/src/Views/PruneRemote.axaml index fe110bd21..10e99d3cb 100644 --- a/src/Views/PruneRemote.axaml +++ b/src/Views/PruneRemote.axaml @@ -7,9 +7,16 @@ x:Class="SourceGit.Views.PruneRemote" x:DataType="vm:PruneRemote"> - + + + + + + diff --git a/src/Views/PruneWorktrees.axaml b/src/Views/PruneWorktrees.axaml index a3a0f770e..a7f6f3f44 100644 --- a/src/Views/PruneWorktrees.axaml +++ b/src/Views/PruneWorktrees.axaml @@ -5,9 +5,16 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.PruneWorktrees"> - + + + + + + diff --git a/src/Views/Pull.axaml b/src/Views/Pull.axaml index 11f883362..d5a4eb3a7 100644 --- a/src/Views/Pull.axaml +++ b/src/Views/Pull.axaml @@ -4,23 +4,22 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.Pull" x:DataType="vm:Pull"> - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - + - - - - - + + + + - - diff --git a/src/Views/Push.axaml b/src/Views/Push.axaml index a9a408081..4f013bf83 100644 --- a/src/Views/Push.axaml +++ b/src/Views/Push.axaml @@ -4,41 +4,32 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.Push" x:DataType="vm:Push"> - + + + + + - - - - - - - - - - - - - - - - + - - - - - - - - - - - - + + - - - - - - + + (); + if (launcher == null) + return; + + var dialog = new PushToNewBranch(); + dialog.SetRemote(push.SelectedRemote.Name); + + var name = await dialog.ShowDialog(launcher); + if (!string.IsNullOrEmpty(name)) + push.PushToNewBranch(name); + } } } diff --git a/src/Views/PushRevision.axaml b/src/Views/PushRevision.axaml index 092212c15..712becc51 100644 --- a/src/Views/PushRevision.axaml +++ b/src/Views/PushRevision.axaml @@ -8,9 +8,15 @@ x:Class="SourceGit.Views.PushRevision" x:DataType="vm:PushRevision"> - + + + + + diff --git a/src/Views/PushTag.axaml b/src/Views/PushTag.axaml index 1181e4e8b..d188264c3 100644 --- a/src/Views/PushTag.axaml +++ b/src/Views/PushTag.axaml @@ -8,9 +8,16 @@ x:Class="SourceGit.Views.PushTag" x:DataType="vm:PushTag"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RemoteProtocolSwitcher.axaml.cs b/src/Views/RemoteProtocolSwitcher.axaml.cs new file mode 100644 index 000000000..ff35c1476 --- /dev/null +++ b/src/Views/RemoteProtocolSwitcher.axaml.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class RemoteProtocolSwitcher : UserControl + { + public static readonly DirectProperty UrlProperty = + AvaloniaProperty.RegisterDirect( + nameof(Url), + static o => o.Url, + static (o, v) => o.Url = v); + + public string Url + { + get => _url; + set => SetAndRaise(UrlProperty, ref _url, value); + } + + public static readonly DirectProperty ActiveProtocolProperty = + AvaloniaProperty.RegisterDirect( + nameof(ActiveProtocol), + static o => o.ActiveProtocol); + + public string ActiveProtocol + { + get => _activeProtocol; + set => SetAndRaise(ActiveProtocolProperty, ref _activeProtocol, value); + } + + public RemoteProtocolSwitcher() + { + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UrlProperty) + { + _protocols.Clear(); + + var url = _url ?? string.Empty; + if (url.StartsWith("https://", StringComparison.Ordinal) && Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + var host = uri.Host; + var route = uri.AbsolutePath.TrimStart('/'); + + _protocols.Add(url); + _protocols.Add($"git@{host}:{route}"); + + ActiveProtocol = "HTTPS"; + SetCurrentValue(IsVisibleProperty, true); + return; + } + + var match = REG_SSH_FORMAT().Match(url); + if (match.Success) + { + var host = match.Groups[1].Value; + var repo = match.Groups[2].Value; + + _protocols.Add($"https://{host}/{repo}"); + _protocols.Add(url); + + ActiveProtocol = "SSH"; + SetCurrentValue(IsVisibleProperty, true); + return; + } + + SetCurrentValue(IsVisibleProperty, false); + } + } + + private void OnOpenDropdownMenu(object sender, RoutedEventArgs e) + { + if (sender is Button btn && _protocols.Count > 0) + { + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + + foreach (var protocol in _protocols) + { + var dup = protocol; + var item = new MenuItem() { Header = dup }; + item.Click += (_, _) => Url = protocol; + menu.Items.Add(item); + } + + menu.Open(btn); + } + + e.Handled = true; + } + + [GeneratedRegex(@"^git@([\w\.\-]+):(.+)$")] + private static partial Regex REG_SSH_FORMAT(); + + private string _url = string.Empty; + private string _activeProtocol = string.Empty; + private List _protocols = []; + } +} diff --git a/src/Views/RemoveWorktree.axaml b/src/Views/RemoveWorktree.axaml index 736e6e409..6d89ef32d 100644 --- a/src/Views/RemoveWorktree.axaml +++ b/src/Views/RemoveWorktree.axaml @@ -7,9 +7,16 @@ x:Class="SourceGit.Views.RemoveWorktree" x:DataType="vm:RemoveWorktree"> - + + + + + + - + - + diff --git a/src/Views/RenameBranch.axaml b/src/Views/RenameBranch.axaml index efbbf3233..fd465afa6 100644 --- a/src/Views/RenameBranch.axaml +++ b/src/Views/RenameBranch.axaml @@ -4,15 +4,21 @@ 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.RenameBranch" x:DataType="vm:RenameBranch"> - - + + + + + + + - - - - - - + diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml index 910ec07fb..cda8c45e0 100644 --- a/src/Views/Repository.axaml +++ b/src/Views/Repository.axaml @@ -23,179 +23,158 @@ + IsChecked="{Binding !IsSearchingCommits, Mode=OneWay}"> + + + + + + + + IsChecked="{Binding IsSearchingCommits, Mode=TwoWay}"> + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - @@ -258,16 +244,17 @@ Nodes="{Binding LocalBranchTrees}" IsVisible="{Binding IsLocalBranchGroupExpanded}" SelectionChanged="OnLocalBranchTreeSelectionChanged" - RowsChanged="OnBranchTreeRowsChanged"/> + RowsChanged="OnLeftSidebarRowsChanged" + SearchRequested="OnToggleFilter"/> - - - - + + + + + + + Content="{DynamicResource Text.Preferences.GPG.CommitEnabled}" + IsChecked="{Binding GPGCommitSigningEnabled, Mode=TwoWay}" + ToolTip.Tip="commit.gpgsign=true"/> + + - - - - - - - - + IsChecked="{Binding EnablePruneOnFetch, Mode=TwoWay}" + ToolTip.Tip="fetch.prune=true"/> + + + + @@ -227,13 +232,9 @@ - - - - @@ -257,12 +258,14 @@ - - - + + + + + + + + @@ -282,8 +285,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -28,7 +28,7 @@ IsVisible="{OnPlatform True, macOS=False}"/> @@ -41,20 +41,27 @@ - - + - - - - - - + User="{Binding Base.Author}"/> + + + - + @@ -65,20 +72,27 @@ - - + - - - - - - + User="{Binding To.Author}"/> + + + - + @@ -93,10 +107,11 @@ - + + ContextRequested="OnChangeContextRequested" + KeyDown="OnChangeCollectionViewKeyDown"/> @@ -135,12 +152,23 @@ Width="48" Height="48" HorizontalAlignment="Center" VerticalAlignment="Center" IsVisible="{Binding IsLoading}"/> + + + + + + + + + Background="Transparent" + Focusable="False"/> diff --git a/src/Views/SubmoduleRevisionCompare.axaml.cs b/src/Views/SubmoduleRevisionCompare.axaml.cs new file mode 100644 index 000000000..3ee7cee64 --- /dev/null +++ b/src/Views/SubmoduleRevisionCompare.axaml.cs @@ -0,0 +1,190 @@ +using System; +using System.IO; +using System.Text; + +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class SubmoduleRevisionCompare : ChromelessWindow + { + public SubmoduleRevisionCompare() + { + InitializeComponent(); + } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.SubmoduleRevisionCompare { 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); + + 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); + + 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 async void OnChangeCollectionViewKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.SubmoduleRevisionCompare 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/SubmodulesView.axaml b/src/Views/SubmodulesView.axaml index 8095fa688..cf7a6f95a 100644 --- a/src/Views/SubmodulesView.axaml +++ b/src/Views/SubmodulesView.axaml @@ -10,7 +10,7 @@ x:Class="SourceGit.Views.SubmodulesView"> - + - @@ -22,38 +18,25 @@ - - - - - - - - - - - - - - - - - + SelectionChanged="OnSelectionChanged" + ContextRequested="OnTagsContextMenuRequested"> + + + - + - + - - - - - - - + + + @@ -86,50 +63,100 @@ + SelectionChanged="OnSelectionChanged" + ContextRequested="OnTagsContextMenuRequested"> + + + - + - - - - - - - - - - - - - + + - - + Data="{StaticResource Icons.Tag}" + Fill="Transparent" + Stroke="{DynamicResource Brush.FG1}" + StrokeThickness="1" + IsVisible="{Binding !Tag.IsAnnotated}"/> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/TagsView.axaml.cs b/src/Views/TagsView.axaml.cs index b73a5b5e8..8302a3ba2 100644 --- a/src/Views/TagsView.axaml.cs +++ b/src/Views/TagsView.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Avalonia; using Avalonia.Controls; @@ -30,13 +31,16 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) public class TagTreeNodeIcon : UserControl { - public static readonly StyledProperty IsExpandedProperty = - AvaloniaProperty.Register(nameof(IsExpanded)); + public static readonly DirectProperty IsExpandedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsExpanded), + static o => o.IsExpanded, + static (o, v) => o.IsExpanded = v); public bool IsExpanded { - get => GetValue(IsExpandedProperty); - set => SetValue(IsExpandedProperty, value); + get => _isExpanded; + set => SetAndRaise(IsExpandedProperty, ref _isExpanded, value); } protected override void OnDataContextChanged(EventArgs e) @@ -62,28 +66,39 @@ private void UpdateContent() } if (node.Tag != null) - CreateContent(new Thickness(0, 0, 0, 0), "Icons.Tag"); + CreateContent(new Thickness(0, 0, 0, 0), "Icons.Tag", node.ToolTip is { IsAnnotated: false }); else if (node.IsExpanded) - CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder.Open"); + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder.Open", false); else - CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder"); + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder", false); } - private void CreateContent(Thickness margin, string iconKey) + private void CreateContent(Thickness margin, string iconKey, bool stroke) { if (this.FindResource(iconKey) is not StreamGeometry geo) return; - Content = new Avalonia.Controls.Shapes.Path() + var path = new Avalonia.Controls.Shapes.Path() { Width = 12, Height = 12, - HorizontalAlignment = HorizontalAlignment.Left, + HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Margin = margin, Data = geo, }; + + if (stroke) + { + path.Fill = Brushes.Transparent; + path.Stroke = this.FindResource("Brush.FG1") as IBrush; + path.StrokeThickness = 1; + } + + Content = path; } + + private bool _isExpanded = false; } public partial class TagsView : UserControl @@ -106,6 +121,15 @@ 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 int Rows { get; @@ -143,7 +167,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (Content is ViewModels.TagCollectionAsTree tree) Rows = tree.Rows.Count; else if (Content is ViewModels.TagCollectionAsList list) - Rows = list.Tags.Count; + Rows = list.TagItems.Count; else Rows = 0; @@ -155,16 +179,30 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } - private void OnItemDoubleTapped(object sender, TappedEventArgs e) + private async void OnItemDoubleTapped(object sender, TappedEventArgs e) { - if (sender is Control { DataContext: ViewModels.TagTreeNode { IsFolder: true } node }) - ToggleNodeIsExpanded(node); + if (sender is Control { DataContext: ViewModels.TagTreeNode node }) + { + if (node.IsFolder) + ToggleNodeIsExpanded(node); + else if (DataContext is ViewModels.Repository repo) + await repo.CheckoutTagAsync(node.Tag); + } + else if (sender is Control { DataContext: ViewModels.TagListItem item }) + { + if (DataContext is ViewModels.Repository repo) + await repo.CheckoutTagAsync(item.Tag); + } e.Handled = true; } private void OnItemPointerPressed(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; @@ -172,29 +210,235 @@ private void OnItemPointerPressed(object sender, PointerPressedEventArgs e) if (DataContext is not ViewModels.Repository repo) return; - if (sender is Control { DataContext: Models.Tag tag }) - repo.NavigateToCommit(tag.SHA); - else if (sender is Control { DataContext: ViewModels.TagTreeNode { Tag: { } nodeTag } }) + if (sender is not Control control) + return; + + if (control.DataContext is ViewModels.TagListItem { Tag: { } itemTag }) + repo.NavigateToCommit(itemTag.SHA); + else if (control.DataContext is ViewModels.TagTreeNode { Tag: { } nodeTag }) repo.NavigateToCommit(nodeTag.SHA); } - private void OnItemContextRequested(object sender, ContextRequestedEventArgs e) + private void OnTagsContextMenuRequested(object sender, ContextRequestedEventArgs e) { - if (sender is not Control control) + if (sender is not ListBox { SelectedItems: { Count: > 0 } selectedItems } listBox) return; - Models.Tag selected; - if (control.DataContext is ViewModels.TagTreeNode node) - selected = node.Tag; - else if (control.DataContext is Models.Tag tag) - selected = tag; - else - selected = null; + if (DataContext is not ViewModels.Repository repo) + return; + + var selected = new List(); + foreach (var item in selectedItems) + { + if (item is ViewModels.TagListItem i) + selected.Add(i.Tag); + else if (item is ViewModels.TagTreeNode n) + CollectTagsInNode(n, selected); + } - if (selected != null && DataContext is ViewModels.Repository repo) + if (selected.Count == 1) + { + var tag = selected[0]; + var menu = new ContextMenu(); + + var createBranch = new MenuItem(); + createBranch.Icon = this.CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (_, ev) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreateBranch(repo, tag)); + ev.Handled = true; + }; + menu.Items.Add(createBranch); + + if (repo.CurrentBranch != null && !tag.SHA.Equals(repo.CurrentBranch.Head, StringComparison.Ordinal)) + { + var checkoutCommit = new MenuItem(); + checkoutCommit.Header = App.Text("TagCM.Checkout"); + checkoutCommit.Icon = this.CreateMenuIcon("Icons.Detached"); + checkoutCommit.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CheckoutDetached(repo, tag)); + + e.Handled = true; + }; + menu.Items.Add(checkoutCommit); + } + + var pushTag = new MenuItem(); + pushTag.Header = App.Text("TagCM.Push", tag.Name); + pushTag.Icon = this.CreateMenuIcon("Icons.Push"); + pushTag.IsEnabled = repo.Remotes.Count > 0; + pushTag.Click += (_, ev) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.PushTag(repo, tag)); + ev.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(pushTag); + + if (repo.CurrentBranch is { IsDetachedHead: false } current) + { + var mergeTag = new MenuItem(); + mergeTag.Header = App.Text("TagCM.Merge", tag.Name, current.Name); + mergeTag.Icon = this.CreateMenuIcon("Icons.Merge"); + mergeTag.Click += (_, ev) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Merge(repo, tag, current.Name)); + ev.Handled = true; + }; + menu.Items.Add(mergeTag); + } + + var deleteTag = new MenuItem(); + deleteTag.Header = App.Text("TagCM.Delete", tag.Name); + deleteTag.Icon = this.CreateMenuIcon("Icons.Clear"); + deleteTag.Click += (_, ev) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.DeleteTag(repo, tag)); + ev.Handled = true; + }; + menu.Items.Add(deleteTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var compareWithHead = new MenuItem(); + compareWithHead.Header = App.Text("TagCM.CompareWithHead"); + compareWithHead.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithHead.Click += (_, _) => + { + this.ShowWindow(new ViewModels.Compare(repo, tag, repo.CurrentBranch)); + }; + + var compareWith = new MenuItem(); + compareWith.Header = App.Text("TagCM.CompareWith"); + compareWith.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWith.Click += (_, _) => + { + new ViewModels.CompareCommandPalette(repo, tag).Open(); + }; + menu.Items.Add(compareWithHead); + menu.Items.Add(compareWith); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var archive = new MenuItem(); + archive.Icon = this.CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (_, ev) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Archive(repo, tag)); + ev.Handled = true; + }; + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var actions = repo.GetCustomActions(Models.CustomActionScope.Tag); + if (actions.Count > 0) + { + var custom = new MenuItem(); + custom.Header = App.Text("TagCM.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 (_, ev) => + { + await repo.ExecCustomActionAsync(dup, tag); + ev.Handled = true; + }; + + custom.Items.Add(item); + } + + menu.Items.Add(custom); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + + var copyName = new MenuItem(); + copyName.Header = App.Text("TagCM.Copy.Name"); + copyName.Icon = this.CreateMenuIcon("Icons.Tag"); + copyName.Click += async (_, ev) => + { + await this.CopyTextAsync(tag.Name); + ev.Handled = true; + }; + + var copyMessage = new MenuItem(); + copyMessage.Header = App.Text("TagCM.Copy.Message"); + copyMessage.Icon = this.CreateMenuIcon("Icons.Info"); + copyMessage.IsEnabled = !string.IsNullOrEmpty(tag.Message); + copyMessage.Click += async (_, ev) => + { + await this.CopyTextAsync(tag.Message); + ev.Handled = true; + }; + + copy.Items.Add(copyName); + copy.Items.Add(copyMessage); + + if (tag.Creator is { Email: { Length: > 0 } }) + { + var copyCreator = new MenuItem(); + copyCreator.Header = App.Text("TagCM.Copy.Tagger"); + copyCreator.Icon = this.CreateMenuIcon("Icons.User"); + copyCreator.Click += async (_, ev) => + { + await this.CopyTextAsync(tag.Creator.ToString()); + ev.Handled = true; + }; + copy.Items.Add(copyCreator); + } + + menu.Items.Add(copy); + menu.Open(listBox); + } + else if (selected.Count > 0) { - var menu = repo.CreateContextMenuForTag(selected); - menu?.Open(control); + var menu = new ContextMenu(); + + if (selected.Count == 2) + { + var compare = new MenuItem(); + compare.Header = App.Text("TagCM.CompareTwo"); + compare.Icon = this.CreateMenuIcon("Icons.Compare"); + compare.Click += (_, ev) => + { + var (based, to) = (selected[0], selected[1]); + if (based.CreatorDate > to.CreatorDate) + (based, to) = (to, based); + + this.ShowWindow(new ViewModels.Compare(repo, based, to)); + ev.Handled = true; + }; + menu.Items.Add(compare); + } + + var deleteMultiple = new MenuItem(); + deleteMultiple.Header = App.Text("TagCM.DeleteMultiple", selected.Count); + deleteMultiple.Icon = this.CreateMenuIcon("Icons.Clear"); + deleteMultiple.Click += (_, ev) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.DeleteMultipleTags(repo, selected)); + + ev.Handled = true; + }; + + menu.Items.Add(deleteMultiple); + menu.Open(listBox); } e.Handled = true; @@ -202,32 +446,75 @@ private void OnItemContextRequested(object sender, ContextRequestedEventArgs e) private void OnSelectionChanged(object sender, SelectionChangedEventArgs _) { - var selected = (sender as ListBox)?.SelectedItem; - Models.Tag selectedTag = null; - if (selected is ViewModels.TagTreeNode node) - selectedTag = node.Tag; - else if (selected is Models.Tag tag) - selectedTag = tag; + if (sender is not ListBox listBox) + return; + + if (listBox.SelectedItems is { Count: 0 }) + { + if (Content is ViewModels.TagCollectionAsList list) + list.ClearSelection(); + else if (Content is ViewModels.TagCollectionAsTree tree) + tree.ClearSelection(); + } + else if (listBox.SelectedItems is { Count: > 0 }) + { + if (Content is ViewModels.TagCollectionAsList list) + list.UpdateSelection(listBox.SelectedItems); + else if (Content is ViewModels.TagCollectionAsTree tree) + tree.UpdateSelection(listBox.SelectedItems); - if (selectedTag != null) RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); + } } private void OnKeyDown(object sender, KeyEventArgs e) { - if (e.Key is not (Key.Delete or Key.Back)) - return; + if (e.Key == Key.F && e.KeyModifiers == (OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + { + RaiseEvent(new RoutedEventArgs(SearchRequestedEvent)); + e.Handled = true; + } + else if (e.Key is (Key.Delete or Key.Back) && e.KeyModifiers == KeyModifiers.None) + { + if (DataContext is not ViewModels.Repository repo) + return; + + var selected = (sender as ListBox)?.SelectedItems; + var tags = new List(); + foreach (var item in selected) + { + if (item is ViewModels.TagListItem i) + tags.Add(i.Tag); + else if (item is ViewModels.TagTreeNode n) + CollectTagsInNode(n, tags); + } + + if (tags.Count == 1) + { + repo.DeleteTag(tags[0]); + } + else if (tags.Count > 1) + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.DeleteMultipleTags(repo, tags)); + } + + e.Handled = true; + } + } - if (DataContext is not ViewModels.Repository repo) - return; + private void CollectTagsInNode(ViewModels.TagTreeNode node, List outs) + { + if (node.Tag is { } tag) + { + if (!outs.Contains(tag)) + outs.Add(tag); - var selected = (sender as ListBox)?.SelectedItem; - if (selected is ViewModels.TagTreeNode { Tag: { } tagInNode }) - repo.DeleteTag(tagInNode); - else if (selected is Models.Tag tag) - repo.DeleteTag(tag); + return; + } - e.Handled = true; + foreach (var child in node.Children) + CollectTagsInNode(child, outs); } } } diff --git a/src/Views/TextDiffView.axaml b/src/Views/TextDiffView.axaml index 45745d384..f139727f2 100644 --- a/src/Views/TextDiffView.axaml +++ b/src/Views/TextDiffView.axaml @@ -2,21 +2,19 @@ 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" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.TextDiffView" - x:Name="ThisControl" + x:DataType="vm:TextDiffContext" Background="{DynamicResource Brush.Contents}"> - + - - + + + SelectedChunk="{Binding SelectedChunk, Mode=TwoWay}" + BlockNavigation="{Binding BlockNavigation, Mode=OneWay}"/> - + + + + + + + + + + SelectedChunk="{Binding SelectedChunk, Mode=TwoWay}" + BlockNavigation="{Binding BlockNavigation, Mode=OneWay}"/> - + + + SelectedChunk="{Binding SelectedChunk, Mode=TwoWay}" + BlockNavigation="{Binding BlockNavigation, Mode=OneWay}"/> @@ -103,7 +115,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/WorkspaceSwitcher.axaml.cs b/src/Views/WorkspaceSwitcher.axaml.cs deleted file mode 100644 index 87743d9f6..000000000 --- a/src/Views/WorkspaceSwitcher.axaml.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Input; - -namespace SourceGit.Views -{ - public partial class WorkspaceSwitcher : UserControl - { - public WorkspaceSwitcher() - { - InitializeComponent(); - } - - protected override void OnKeyDown(KeyEventArgs e) - { - base.OnKeyDown(e); - - if (e.Key == Key.Enter && DataContext is ViewModels.WorkspaceSwitcher switcher) - { - switcher.Switch(); - e.Handled = true; - } - } - - private void OnItemDoubleTapped(object sender, TappedEventArgs e) - { - if (DataContext is ViewModels.WorkspaceSwitcher switcher) - { - switcher.Switch(); - e.Handled = true; - } - } - - private void OnSearchBoxKeyDown(object sender, KeyEventArgs e) - { - if (e.Key == Key.Down && WorkspaceListBox.ItemCount > 0) - { - WorkspaceListBox.Focus(NavigationMethod.Directional); - - if (WorkspaceListBox.SelectedIndex < 0) - WorkspaceListBox.SelectedIndex = 0; - else if (WorkspaceListBox.SelectedIndex < WorkspaceListBox.ItemCount) - WorkspaceListBox.SelectedIndex++; - - e.Handled = true; - } - } - } -} diff --git a/src/Views/WorktreeDepthIcon.cs b/src/Views/WorktreeDepthIcon.cs new file mode 100644 index 000000000..30759ebbd --- /dev/null +++ b/src/Views/WorktreeDepthIcon.cs @@ -0,0 +1,59 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class WorktreeDepthIcon : Control + { + public static readonly StyledProperty BrushProperty = + AvaloniaProperty.Register(nameof(Brush), Brushes.Transparent); + + public IBrush Brush + { + get => GetValue(BrushProperty); + set => SetValue(BrushProperty, value); + } + + public override void Render(DrawingContext context) + { + if (DataContext is ViewModels.Worktree wt) + { + if (wt.IsMain) + return; + + var pen = new Pen(Brush); + var h = Bounds.Height; + var halfH = h * 0.5; + + if (wt.IsLast) + context.DrawLine(pen, new Point(12.5, 0), new Point(12.5, halfH)); + else + context.DrawLine(pen, new Point(12.5, 0), new Point(12.5, h)); + + context.DrawLine(pen, new Point(12.5, halfH), new Point(18, halfH)); + } + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (DataContext is ViewModels.Worktree wt) + { + if (wt.IsMain) + return new Size(0, 0); + + return new Size(18, availableSize.Height); + } + + return new Size(0, 0); + } + } +} diff --git a/src/Views/WorktreeIcon.cs b/src/Views/WorktreeIcon.cs new file mode 100644 index 000000000..c4dc2cb12 --- /dev/null +++ b/src/Views/WorktreeIcon.cs @@ -0,0 +1,32 @@ +using System; + +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class WorktreeIcon : Path + { + protected override Type StyleKeyOverride => typeof(Path); + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + if (DataContext is ViewModels.Worktree wt) + { + if (wt.IsCurrent) + Data = this.FindResource("Icons.CheckCircled") as StreamGeometry; + else if (wt.IsMain) + Data = this.FindResource("Icons.Repositories") as StreamGeometry; + else + Data = this.FindResource("Icons.Worktree") as StreamGeometry; + + return; + } + + Data = null; + } + } +} diff --git a/translate_helper.py b/translate_helper.py new file mode 100644 index 000000000..77759e5e1 --- /dev/null +++ b/translate_helper.py @@ -0,0 +1,215 @@ +import sys +import io +import os +import xml.etree.ElementTree as ET +import re + +# Define namespaces URIs +XAML_NS = 'https://github.com/avaloniaui' +X_NS = 'http://schemas.microsoft.com/winfx/2006/xaml' + +def register_namespaces(): + """Registers namespaces for ElementTree to use when writing the XML file.""" + ET.register_namespace('', XAML_NS) + ET.register_namespace('x', X_NS) + +def get_locale_dir(): + """Constructs the absolute path for the locales files""" + try: + script_dir = os.path.dirname(os.path.realpath(__file__)) + project_root = os.path.abspath(os.path.join(script_dir, '.')) + locales_dir = os.path.join(project_root, 'src', 'Resources', 'Locales') + except NameError: + project_root = os.path.abspath(os.getcwd()) + locales_dir = os.path.join(project_root, 'src', 'Resources', 'Locales') + + return locales_dir + +def get_locale_files(lang_id): + """Constructs the absolute paths for the target and reference locale files.""" + + # get the locales dir + locales_dir = get_locale_dir() + + # get the target file absolute path + target_file = os.path.join(locales_dir, f"{lang_id}.axaml") + + if not os.path.exists(target_file): + print(f"Error: Target language file not found at {target_file}") + sys.exit(1) + + try: + tree = ET.parse(target_file) + root = tree.getroot() + merged_dict = root.find(f"{{{XAML_NS}}}ResourceDictionary.MergedDictionaries") + if merged_dict is None: + raise ValueError("Could not find MergedDictionaries tag.") + + resource_include = merged_dict.find(f"{{{XAML_NS}}}ResourceInclude") + if resource_include is None: + raise ValueError("Could not find ResourceInclude tag.") + + include_source = resource_include.get('Source') + ref_filename_match = re.search(r'([a-zA-Z]{2}_[a-zA-Z]{2}).axaml', include_source) + if not ref_filename_match: + raise ValueError("Could not parse reference filename from Source attribute.") + + ref_filename = f"{ref_filename_match.group(1)}.axaml" + ref_file = os.path.join(locales_dir, ref_filename) + except Exception as e: + print(f"Error parsing {target_file} to find reference file: {e}") + sys.exit(1) + + if not os.path.exists(ref_file): + print(f"Error: Reference language file '{ref_file}' not found.") + sys.exit(1) + + return target_file, ref_file + +def get_strings(root): + """Extracts all translation keys and their text values from an XML root.""" + strings = {} + for string_tag in root.findall(f"{{{X_NS}}}String"): + key = string_tag.get(f"{{{X_NS}}}Key") + if key: + strings[key] = string_tag.text if string_tag.text is not None else "" + return strings + +def add_new_string_tag(root, key, value): + """Adds a new tag to the XML root, maintaining some formatting.""" + + # create a new tag with Key<>Value + new_tag = ET.Element(f"{{{X_NS}}}String") + new_tag.set(f"{{{X_NS}}}Key", key) + new_tag.set("xml:space", "preserve") + new_tag.text = value + + # try to find the last tag in the + last_element_index = -1 + children = list(root) + + start = 0 + end = len(children) - 1 + middle = -1 + + # find the first String tag + while (children[start].tag != f"{{{X_NS}}}String"): + start = start + 1 + + # find where the insert the new tag + # so it always keeps the alphabetical order + while (end - start) > 1: + middle = int((start + end) / 2) + + if (children[middle].tag == f"{{{X_NS}}}String"): + middle_key = children[middle].get(f"{{{X_NS}}}Key") + + if key.lower() < middle_key.lower(): + end = middle + else: + start = middle + + # insert after the middle or at the end + if middle != -1: + new_tag.tail = root[middle].tail + root.insert(middle + 1, new_tag) + else: + new_tag.tail = "\n " + root.append(new_tag) + +def save_translations(tree, file_path): + """Saves the XML tree to the file, attempting to preserve formatting.""" + try: + ET.indent(tree, space=" ") + except AttributeError: + print("Warning: ET.indent not available. Output formatting may not be ideal.") + + tree.write(file_path, encoding='utf-8', xml_declaration=False) + print(f"\nSaved changes to {file_path}") + +def main(): + """Main function to run the translation helper script.""" + if len(sys.argv) < 2: + print("Usage: python utils/translate_helper.py [--check]") + sys.exit(1) + + # Force sys.stdin to use UTF-8 decoding + if sys.stdin.encoding.lower() != 'utf-8': + print("Changin input encoding to UTF-8") + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8') + + # get arguments + lang_id = sys.argv[1] + is_check_mode = len(sys.argv) > 2 and sys.argv[2] == '--check' + + # setup XML parser + register_namespaces() + + # try to find XML files + target_file_path, ref_file_path = get_locale_files(lang_id) + + # parse files + parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) + target_tree = ET.parse(target_file_path, parser) + target_root = target_tree.getroot() + + ref_tree = ET.parse(ref_file_path) + ref_root = ref_tree.getroot() + + # get all translation keys + target_strings = get_strings(target_root) + ref_strings = get_strings(ref_root) + + # compute the missing keys between the target and reference files + missing_keys = sorted([key for key in ref_strings.keys() if key not in target_strings]) + + if not missing_keys: + print("All keys are translated. Nothing to do.") + return + + print(f"Found {len(missing_keys)} missing keys for language '{lang_id}'.") + + # running in check mode will only display the missing keys + if is_check_mode: + print("Missing keys:") + for key in missing_keys: + print(f" - {key}") + return + + # running in normal mode will trigger the translation process + print("Starting interactive translation...\n") + changes_made = False + try: + # for each missing key + for i, key in enumerate(missing_keys): + + # show the original text + original_text = ref_strings.get(key, "") + print("-" * 40) + print(f"({i+1}/{len(missing_keys)}) Key: '{key}'") + print(f"Original: '{original_text}'") + + # asks for a translated version + user_input = input("Enter translation (or press Enter to skip, 'q' to save and quit): ") + + # if 'q' quit and save + if user_input.lower() == 'q': + print("\nQuitting and saving changes...") + break + # if valid input, save + elif user_input: + add_new_string_tag(target_root, key, user_input) + changes_made = True + print(f"Added translation for '{key}'") + + except (KeyboardInterrupt, EOFError): + print("\n\nProcess interrupted. Saving changes...") + finally: + # if there was any changes, save back to the target file + if changes_made: + save_translations(target_tree, target_file_path) + else: + print("\nNo changes were made.") + +if __name__ == "__main__": + main()