summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2021-02-27 11:43:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2021-02-27 11:54:33 +0000
commitb00ed08675e64ee7e1da0ffdff9563b97e8b94ac (patch)
treeeb361597c3a92f02d468520bd546f7b2402cef1e
parentInitial commit. (diff)
downloadgitui-upstream/0.11.0+dfsg.tar.xz
gitui-upstream/0.11.0+dfsg.zip
Adding upstream version 0.11.0+dfsg.upstream/0.11.0+dfsgupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r--.github/FUNDING.yml1
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md32
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.md20
-rw-r--r--.github/stale.yml17
-rw-r--r--.github/workflows/cd.yml94
-rw-r--r--.github/workflows/ci.yml128
-rw-r--r--.gitignore5
-rw-r--r--.vscode/launch.json13
-rw-r--r--.vscode/settings.json5
-rw-r--r--CHANGELOG.md296
-rw-r--r--Cargo.lock1248
-rw-r--r--Cargo.toml73
-rw-r--r--KEY_CONFIG.md16
-rw-r--r--LICENSE.md21
-rw-r--r--Makefile66
-rw-r--r--README.md165
-rw-r--r--THEMES.md13
-rw-r--r--assets/amend.gifbin0 -> 156611 bytes
-rw-r--r--assets/binary_diff.pngbin0 -> 31923 bytes
-rw-r--r--assets/branches.gifbin0 -> 151023 bytes
-rw-r--r--assets/cmdbar.gifbin0 -> 124545 bytes
-rw-r--r--assets/commit-details.gifbin0 -> 934670 bytes
-rw-r--r--assets/compact-tree.pngbin0 -> 39107 bytes
-rw-r--r--assets/demo.gifbin0 -> 1877839 bytes
-rw-r--r--assets/expandable-commands.drawio1
-rw-r--r--assets/light-theme.pngbin0 -> 427749 bytes
-rw-r--r--assets/log-commit-info.drawio1
-rw-r--r--assets/logo.pngbin0 -> 18644 bytes
-rw-r--r--assets/msg-scrolling.gifbin0 -> 223893 bytes
-rw-r--r--assets/newlines.gifbin0 -> 6789 bytes
-rw-r--r--assets/perf_compare.jpgbin0 -> 63595 bytes
-rw-r--r--assets/push.gifbin0 -> 51499 bytes
-rw-r--r--assets/screenshots/s00-diff.pngbin0 -> 523238 bytes
-rw-r--r--assets/screenshots/s01-log.pngbin0 -> 1023720 bytes
-rw-r--r--assets/screenshots/s02-revert.pngbin0 -> 430910 bytes
-rw-r--r--assets/screenshots/s03-commit.pngbin0 -> 503475 bytes
-rw-r--r--assets/scrollbar.gifbin0 -> 400851 bytes
-rw-r--r--assets/select-copy.gifbin0 -> 211841 bytes
-rw-r--r--assets/spinner.gifbin0 -> 62088 bytes
-rw-r--r--assets/stashing.drawio1
-rw-r--r--assets/stashing.gifbin0 -> 1275650 bytes
-rw-r--r--assets/tagging.gifbin0 -> 263141 bytes
-rw-r--r--assets/vi_support.gifbin0 -> 137827 bytes
-rw-r--r--assets/vim_style_key_config.ron75
-rw-r--r--asyncgit/Cargo.toml26
l---------asyncgit/LICENSE.md1
-rw-r--r--asyncgit/README.md12
-rw-r--r--asyncgit/src/cached/branchname.rs45
-rw-r--r--asyncgit/src/cached/mod.rs7
-rw-r--r--asyncgit/src/commit_files.rs108
-rw-r--r--asyncgit/src/diff.rs191
-rw-r--r--asyncgit/src/error.rs34
-rw-r--r--asyncgit/src/lib.rs67
-rw-r--r--asyncgit/src/push.rs302
-rw-r--r--asyncgit/src/revlog.rs183
-rw-r--r--asyncgit/src/status.rs186
-rw-r--r--asyncgit/src/sync/branch.rs454
-rw-r--r--asyncgit/src/sync/commit.rs248
-rw-r--r--asyncgit/src/sync/commit_details.rs161
-rw-r--r--asyncgit/src/sync/commit_files.rs174
-rw-r--r--asyncgit/src/sync/commits_info.rs193
-rw-r--r--asyncgit/src/sync/cred.rs257
-rw-r--r--asyncgit/src/sync/diff.rs555
-rw-r--r--asyncgit/src/sync/hooks.rs388
-rw-r--r--asyncgit/src/sync/hunks.rs189
-rw-r--r--asyncgit/src/sync/ignore.rs129
-rw-r--r--asyncgit/src/sync/logwalker.rs115
-rw-r--r--asyncgit/src/sync/mod.rs151
-rw-r--r--asyncgit/src/sync/remotes.rs254
-rw-r--r--asyncgit/src/sync/reset.rs314
-rw-r--r--asyncgit/src/sync/stash.rs214
-rw-r--r--asyncgit/src/sync/status.rs142
-rw-r--r--asyncgit/src/sync/tags.rs86
-rw-r--r--asyncgit/src/sync/utils.rs357
-rw-r--r--asyncgit/src/tags.rs128
-rw-r--r--invalidstring/Cargo.toml14
l---------invalidstring/LICENSE.md1
-rw-r--r--invalidstring/README.md5
-rw-r--r--invalidstring/src/lib.rs8
-rw-r--r--rustfmt.toml1
-rw-r--r--scopetime/Cargo.toml19
l---------scopetime/LICENSE.md1
-rw-r--r--scopetime/README.md25
-rw-r--r--scopetime/src/lib.rs72
-rw-r--r--src/app.rs666
-rw-r--r--src/clipboard.rs75
-rw-r--r--src/cmdbar.rs205
-rw-r--r--src/components/changes.rs302
-rw-r--r--src/components/command.rs85
-rw-r--r--src/components/commit.rs280
-rw-r--r--src/components/commit_details/details.rs491
-rw-r--r--src/components/commit_details/mod.rs208
-rw-r--r--src/components/commitlist.rs437
-rw-r--r--src/components/create_branch.rs144
-rw-r--r--src/components/cred.rs164
-rw-r--r--src/components/diff.rs698
-rw-r--r--src/components/externaleditor.rs189
-rw-r--r--src/components/filetree.rs530
-rw-r--r--src/components/help.rs247
-rw-r--r--src/components/inspect_commit.rs258
-rw-r--r--src/components/mod.rs226
-rw-r--r--src/components/msg.rs142
-rw-r--r--src/components/push.rs262
-rw-r--r--src/components/rename_branch.rs166
-rw-r--r--src/components/reset.rs168
-rw-r--r--src/components/select_branch.rs394
-rw-r--r--src/components/stashmsg.rs148
-rw-r--r--src/components/tag_commit.rs144
-rw-r--r--src/components/textinput.rs503
-rw-r--r--src/components/utils/filetree.rs428
-rw-r--r--src/components/utils/logitems.rs77
-rw-r--r--src/components/utils/mod.rs35
-rw-r--r--src/components/utils/statustree.rs903
-rw-r--r--src/input.rs111
-rw-r--r--src/keys.rs266
-rw-r--r--src/main.rs320
-rw-r--r--src/notify_mutex.rs42
-rw-r--r--src/profiler.rs39
-rw-r--r--src/queue.rs67
-rw-r--r--src/spinner.rs49
-rw-r--r--src/strings.rs709
-rw-r--r--src/tabs/mod.rs9
-rw-r--r--src/tabs/revlog.rs315
-rw-r--r--src/tabs/stashing.rs260
-rw-r--r--src/tabs/stashlist.rs179
-rw-r--r--src/tabs/status.rs585
-rw-r--r--src/ui/mod.rs101
-rw-r--r--src/ui/scrollbar.rs80
-rw-r--r--src/ui/scrolllist.rs86
-rw-r--r--src/ui/style.rs293
-rw-r--r--src/version.rs35
131 files changed, 20229 insertions, 0 deletions
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..9e25c35
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: extrawurst
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..4a57048
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,32 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Context (please complete the following information):**
+ - OS/Distro + Version: [e.g. `macOS 10.15.5`]
+ - GitUI Version [e.g. `0.5`]
+ - Rust version: [e.g `1.44`]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..bbcbbe7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/stale.yml b/.github/stale.yml
new file mode 100644
index 0000000..3ce7f76
--- /dev/null
+++ b/.github/stale.yml
@@ -0,0 +1,17 @@
+# Number of days of inactivity before an issue becomes stale
+daysUntilStale: 90
+# Number of days of inactivity before a stale issue is closed
+daysUntilClose: 7
+# Issues with these labels will never be considered stale
+exemptLabels:
+ - pinned
+ - security
+# Label to use when marking an issue as stale
+staleLabel: wontfix
+# Comment to post when marking an issue as stale. Set to `false` to disable
+markComment: >
+ This issue has been automatically marked as stale because it has not had
+ recent activity. It will be closed if no further activity occurs. Thank you
+ for your contributions.
+# Comment to post when closing a stale issue. Set to `false` to disable
+closeComment: false \ No newline at end of file
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 0000000..ce4db2b
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,94 @@
+name: CD
+
+on:
+ push:
+ tags:
+ - '*'
+
+jobs:
+ release:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Get version
+ id: get_version
+ run: echo ::set-output name=version::${GITHUB_REF/refs\/tags\//}
+
+ - name: Install Rust
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: stable
+ profile: minimal
+ components: clippy
+
+ - name: Build
+ run: cargo build
+ - name: Run tests
+ run: make test
+ - name: Run clippy
+ run: |
+ cargo clean
+ make clippy
+
+ - name: Setup MUSL
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ rustup target add x86_64-unknown-linux-musl
+ sudo apt-get -qq install musl-tools
+
+ - name: Build Release Mac
+ if: matrix.os == 'macos-latest'
+ run: make release-mac
+ - name: Build Release Linux
+ if: matrix.os == 'ubuntu-latest'
+ run: make release-linux-musl
+ - name: Build Release Win
+ if: matrix.os == 'windows-latest'
+ run: make release-win
+
+ - name: Set SHA
+ if: matrix.os == 'macos-latest'
+ id: shasum
+ run: |
+ echo ::set-output name=sha::"$(shasum -a 256 ./release/gitui-mac.tar.gz | awk '{printf $1}')"
+
+ - name: Extract release notes
+ if: matrix.os == 'ubuntu-latest'
+ id: release_notes
+ uses: ffurrer2/extract-release-notes@v1
+ - name: Release
+ uses: softprops/action-gh-release@v1
+ with:
+ body: ${{ steps.release_notes.outputs.release_notes }}
+ prerelease: ${{ contains(github.ref, '-') }}
+ files: |
+ ./release/*.tar.gz
+ ./release/*.zip
+ ./release/*.msi
+
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # - name: Bump personal tap formula
+ # uses: mislav/bump-homebrew-formula-action@v1
+ # if: "matrix.os == 'macos-latest' && !contains(github.ref, '-')" # skip prereleases
+ # env:
+ # COMMITTER_TOKEN: ${{ secrets.BREW_TOKEN }}
+ # with:
+ # formula-name: gitui
+ # homebrew-tap: extrawurst/tap
+ # download-url: https://github.com/extrawurst/gitui/releases/download/${{ steps.get_version.outputs.version }}/gitui-mac.tar.gz
+
+ - name: Bump homebrew-core formula
+ uses: mislav/bump-homebrew-formula-action@v1
+ if: "matrix.os == 'macos-latest' && !contains(github.ref, '-')" # skip prereleases
+ env:
+ COMMITTER_TOKEN: ${{ secrets.BREW_TOKEN }}
+ with:
+ formula-name: gitui
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e3e4b51
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,128 @@
+name: CI
+
+on:
+ schedule:
+ - cron: '0 2 * * *' # run at 2 AM UTC
+ push:
+ branches: [ '*' ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ rust: [nightly, stable]
+ runs-on: ${{ matrix.os }}
+ continue-on-error: ${{ matrix.rust == 'nightly' }}
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Restore cargo cache
+ uses: actions/cache@v2
+ env:
+ cache-name: ci
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ ~/.cargo/bin
+ target
+ key: ${{ matrix.os }}-${{ env.cache-name }}-${{ matrix.rust }}-${{ hashFiles('Cargo.lock') }}
+
+ - name: MacOS Workaround
+ if: matrix.os == 'macos-latest'
+ run: cargo clean --locked -p serde_derive -p thiserror
+
+ - name: Install Rust
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: ${{ matrix.rust }}
+ default: true
+ profile: minimal
+ components: clippy
+
+ - name: Build Debug
+ run: |
+ cargo build
+
+ - name: Run tests
+ run: make test
+
+ - name: Run clippy
+ run: |
+ make clippy
+
+ - name: Build Release
+ run: make build-release
+
+ - name: Build MSI (windows)
+ if: matrix.os == 'windows-latest'
+ run: |
+ cargo install cargo-wix
+ cargo wix --no-build --nocapture --output ./target/wix/gitui.msi
+ ls -l ./target/wix/gitui.msi
+
+ build-linux-musl:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@master
+ - name: Install Rust
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: stable
+ profile: minimal
+ target: x86_64-unknown-linux-musl
+ - name: Setup MUSL
+ run: |
+ sudo apt-get -qq install musl-tools
+ - name: Build Debug
+ run: |
+ make build-linux-musl-debug
+ ./target/x86_64-unknown-linux-musl/debug/gitui --version
+ - name: Build Release
+ run: |
+ make build-linux-musl-release
+ ./target/x86_64-unknown-linux-musl/release/gitui --version
+ - name: Test
+ run: |
+ make test-linux-musl
+
+ rustfmt:
+ name: Rustfmt
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@master
+ - name: Install Rust
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: stable
+ components: rustfmt
+ - run: cargo fmt -- --check
+
+ sec:
+ name: Security audit
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions-rs/audit-check@v1
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ log-test:
+ name: Changelog Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@master
+ - name: Extract release notes
+ id: extract_release_notes
+ uses: ffurrer2/extract-release-notes@v1
+ with:
+ release_notes_file: ./release-notes.txt
+ - uses: actions/upload-artifact@v1
+ with:
+ name: release-notes.txt
+ path: ./release-notes.txt \ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..715365c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/target
+/release
+.DS_Store
+/.idea/
+flamegraph.svg
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..4f8d508
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "(OSX) Launch",
+ "type": "lldb",
+ "request": "launch",
+ "program": "${workspaceRoot}/target/debug/gitui",
+ "args": [],
+ "cwd": "${workspaceRoot}",
+ }
+ ]
+} \ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..a9c40cf
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "editor.formatOnSave": true,
+ "workbench.settings.enableNaturalLanguageSearch": false,
+ "telemetry.enableTelemetry": false,
+} \ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d650a52
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,296 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+<!-- ## Unreleased -->
+
+## [0.11.0] - 2020-12-20
+
+### Added
+- push to remote ([#265](https://github.com/extrawurst/gitui/issues/265)) ([#267](https://github.com/extrawurst/gitui/issues/267))
+
+![push](assets/push.gif)
+
+- number of incoming/outgoing commits to upstream ([#362](https://github.com/extrawurst/gitui/issues/362))
+- new branch list popup incl. checkout/delete/rename [[@WizardOhio24](https://github.com/WizardOhio24)] ([#303](https://github.com/extrawurst/gitui/issues/303)) ([#323](https://github.com/extrawurst/gitui/issues/323))
+
+![branches](assets/branches.gif)
+
+- compact treeview [[@WizardOhio24](https://github.com/WizardOhio24)] ([#192](https://github.com/extrawurst/gitui/issues/192))
+
+![tree](assets/compact-tree.png)
+
+- scrollbar in long commit messages [[@timaliberdov](https://github.com/timaliberdov)] ([#308](https://github.com/extrawurst/gitui/issues/308))
+- added windows scoop recipe ([#164](https://github.com/extrawurst/gitui/issues/164))
+- added gitui to [chocolatey](https://chocolatey.org/packages/gitui) on windows by [@nils-a](https://github.com/nils-a)
+- added gitui gentoo instructions to readme [[@dm9pZCAq](https://github.com/dm9pZCAq)] ([#430](https://github.com/extrawurst/gitui/pull/430))
+- added windows installer (msi) to release [[@pm100](https://github.com/pm100)] ([#360](https://github.com/extrawurst/gitui/issues/360))
+- command to copy commit hash [[@yanganto](https://github.com/yanganto)] ([#281](https://github.com/extrawurst/gitui/issues/281))
+
+### Changed
+- upgrade `dirs` to `dirs-next` / remove cfg migration code ([#351](https://github.com/extrawurst/gitui/issues/351)) ([#366](https://github.com/extrawurst/gitui/issues/366))
+- do not highlight selection in diff view when not focused ([#270](https://github.com/extrawurst/gitui/issues/270))
+- copy to clipboard using `xclip`(linux), `pbcopy`(mac) or `clip`(win) [[@cruessler](https://github.com/cruessler)] ([#262](https://github.com/extrawurst/gitui/issues/262))
+
+### Fixed
+- crash when changing git repo while gitui is open ([#271](https://github.com/extrawurst/gitui/issues/271))
+- remove workaround for color serialization [[@1wilkens](https://github.com/1wilkens)] ([#149](https://github.com/extrawurst/gitui/issues/149))
+- crash on small terminal size ([#307](https://github.com/extrawurst/gitui/issues/307))
+- fix vim keybindings uppercase handling [[@yanganto](https://github.com/yanganto)] ([#286](https://github.com/extrawurst/gitui/issues/286))
+- remove shift tab windows workaround [[@nils-a](https://github.com/nils-a)] ([#112](https://github.com/extrawurst/gitui/issues/112))
+- core.editor is ignored [[@pm100](https://github.com/pm100)] ([#414](https://github.com/extrawurst/gitui/issues/414))
+
+## [0.10.1] - 2020-09-01
+
+### Fixed
+- static linux binaries broke due to new clipboard feature which is disabled on linux for now ([#259](https://github.com/extrawurst/gitui/issues/259))
+
+## [0.10.0] - 2020-08-29
+
+### Added
+
+- fully **customizable key bindings** (see [KEY_CONFIG.md](KEY_CONFIG.md)) [[@yanganto](https://github.com/yanganto)] ([#109](https://github.com/extrawurst/gitui/issues/109)) ([#57](https://github.com/extrawurst/gitui/issues/57))
+- support scrolling in long commit messages [[@cruessler](https://github.com/cruessler)]([#208](https://github.com/extrawurst/gitui/issues/208))
+
+![scrolling](assets/msg-scrolling.gif)
+
+- copy lines from diffs to clipboard [[@cruessler](https://github.com/cruessler)]([#229](https://github.com/extrawurst/gitui/issues/229))
+
+![select-copy](assets/select-copy.gif)
+
+- scrollbar in long diffs ([#204](https://github.com/extrawurst/gitui/issues/204))
+
+![scrollbar](assets/scrollbar.gif)
+
+- allow creating new branch ([#253](https://github.com/extrawurst/gitui/issues/253))
+
+### Fixed
+
+- selection error in stashlist when deleting last element ([#223](https://github.com/extrawurst/gitui/issues/223))
+- git hooks broke ci build on windows [[@dr-BEat](https://github.com/dr-BEat)] ([#235](https://github.com/extrawurst/gitui/issues/235))
+
+## [0.9.1] - 2020-07-30
+
+### Added
+
+- move to (un)staged when the current selection is empty [[@jonstodle](https://github.com/jonstodle)]([#215](https://github.com/extrawurst/gitui/issues/215))
+- pending load of a diff/status is visualized ([#160](https://github.com/extrawurst/gitui/issues/160))
+- entry on [git-scm.com](https://git-scm.com/downloads/guis) in the list of GUI tools [[@Vidar314](https://github.com/Vidar314)] (see [PR](https://github.com/git/git-scm.com/pull/1485))
+- commits can be tagged in revlog [[@cruessler](https://github.com/cruessler)]([#103](https://github.com/extrawurst/gitui/issues/103))
+
+![](assets/tagging.gif)
+
+### Changed
+
+- async fetching tags to improve reactivity in giant repos ([#170](https://github.com/extrawurst/gitui/issues/170))
+
+### Fixed
+
+- removed unmaintained dependency `spin` ([#172](https://github.com/extrawurst/gitui/issues/172))
+- opening relative paths in external editor may fail in subpaths ([#184](https://github.com/extrawurst/gitui/issues/184))
+- crashes in revlog with utf8 commit messages ([#188](https://github.com/extrawurst/gitui/issues/188))
+- `add_to_ignore` failed on files without a newline at EOF ([#191](https://github.com/extrawurst/gitui/issues/191))
+- new tags were not picked up in revlog view ([#190](https://github.com/extrawurst/gitui/issues/190))
+- tags not shown in commit details popup ([#193](https://github.com/extrawurst/gitui/issues/193))
+- min size for relative popups on small terminals ([#179](https://github.com/extrawurst/gitui/issues/179))
+- fix crash on resizing terminal to very small width ([#198](https://github.com/extrawurst/gitui/issues/198))
+- fix broken tags when using a different internal representation ([#206](https://github.com/extrawurst/gitui/issues/206))
+- tags are not cleanly seperated in details view ([#212](https://github.com/extrawurst/gitui/issues/212))
+
+## [0.8.1] - 2020-07-07
+
+### Added
+
+- open file in editor [[@jonstodle](https://github.com/jonstodle)]([#166](https://github.com/extrawurst/gitui/issues/166))
+
+### Fixed
+
+- switch deprecated transitive dependency `net2`->`socket2` [in `crossterm`->`mio`]([#66](https://github.com/extrawurst/gitui/issues/66))
+- crash diffing a stash that was created via cli ([#178](https://github.com/extrawurst/gitui/issues/178))
+- zero delta file size in diff of untracked binary file ([#171](https://github.com/extrawurst/gitui/issues/171))
+- newlines not visualized correctly in commit editor ([#169](https://github.com/extrawurst/gitui/issues/169))
+
+![](assets/newlines.gif)
+
+## [0.8.0] - 2020-07-06
+
+### Added
+
+- core homebrew [formulae](https://formulae.brew.sh/formula/gitui#default): `brew install gitui` [[@vladimyr](https://github.com/vladimyr)](<[#137](https://github.com/extrawurst/gitui/issues/137)>)
+- show file sizes and delta on binary diffs ([#141](https://github.com/extrawurst/gitui/issues/141))
+
+![](assets/binary_diff.png)
+
+- external editor support for commit messages [[@jonstodle](https://github.com/jonstodle)](<[#46](https://github.com/extrawurst/gitui/issues/46)>)
+
+![](assets/vi_support.gif)
+
+### Changed
+
+- use terminal blue as default selection background ([#129](https://github.com/extrawurst/gitui/issues/129))
+- author column in revlog is now fixed width for better alignment ([#148](https://github.com/extrawurst/gitui/issues/148))
+- cleaner tab bar and background work indicating spinner:
+
+![](assets/spinner.gif)
+
+### Fixed
+
+- clearer help headers ([#131](https://github.com/extrawurst/gitui/issues/131))
+- display non-utf8 commit messages at least partially ([#150](https://github.com/extrawurst/gitui/issues/150))
+- hooks ignored when running `gitui` in subfolder of workdir ([#151](https://github.com/extrawurst/gitui/issues/151))
+- better scrolling in file-trees [[@tisorlawan](https://github.com/tisorlawan)]([#144](https://github.com/extrawurst/gitui/issues/144))
+- show untracked files in stash commit details [[@MCord](https://github.com/MCord)]([#130](https://github.com/extrawurst/gitui/issues/130))
+- in some repos looking up the branch name was a bottleneck ([#159](https://github.com/extrawurst/gitui/issues/159))
+- some optimizations in reflog
+- fix arrow utf8 encoding in help window [[@daober](https://github.com/daober)]([#142](https://github.com/extrawurst/gitui/issues/142))
+
+## [0.7.0] - 2020-06-15
+
+### Added
+
+- Inspect stash commit in detail ([#121](https://github.com/extrawurst/gitui/issues/121))
+- Support reset/revert individual hunks ([#11](https://github.com/extrawurst/gitui/issues/11))
+- Commit Amend (`ctrl+a`) when in commit popup ([#89](https://github.com/extrawurst/gitui/issues/89))
+
+![](assets/amend.gif)
+
+### Changed
+
+- file trees: `arrow-right` on expanded folder moves down into folder
+- better scrolling in diff ([#52](https://github.com/extrawurst/gitui/issues/52))
+- display current branch in status/log ([#115](https://github.com/extrawurst/gitui/issues/115))
+- commit msg popup: add cursor and more controls (`arrow-left/right`, `delete` & `backspace`) [[@alistaircarscadden](https://github.com/alistaircarscadden)]([#46](https://github.com/extrawurst/gitui/issues/46))
+- moved `theme.ron` from `XDG_CACHE_HOME` to `XDG_CONFIG_HOME` [[@jonstodle](https://github.com/jonstodle)](<[#98](https://github.com/extrawurst/gitui/issues/98)>)
+
+### Fixed
+
+- reset file inside folder failed when running `gitui` in a subfolder too ([#118](https://github.com/extrawurst/gitui/issues/118))
+- selection could disappear into collapsed folder ([#120](https://github.com/extrawurst/gitui/issues/120))
+- `Files: loading` sometimes wrong ([#119](https://github.com/extrawurst/gitui/issues/119))
+
+## [0.6.0] - 2020-06-09
+
+![](assets/commit-details.gif)
+
+### Changed
+
+- changed hotkeys for selecting stage/workdir (**Note:** use `[w]`/`[s]` to change between workdir and stage) and added hotkeys (`[1234]`) to switch to tabs directly ([#92](https://github.com/extrawurst/gitui/issues/92))
+- `arrow-up`/`down` on bottom/top of status file list switches focus ([#105](https://github.com/extrawurst/gitui/issues/105))
+- highlight tags in revlog better
+
+### Added
+
+- New `Stage all [a]`/`Unstage all [a]` in changes lists ([#82](https://github.com/extrawurst/gitui/issues/82))
+- add `-d`, `--directory` options to set working directory via program arg [[@alistaircarscadden](https://github.com/alistaircarscadden)]([#73](https://github.com/extrawurst/gitui/issues/73))
+- commit detail view in revlog ([#80](https://github.com/extrawurst/gitui/issues/80))
+
+### Fixed
+
+- app closes when staging invalid file/path ([#108](https://github.com/extrawurst/gitui/issues/108))
+- `shift+tab` not working on windows [[@MCord](https://github.com/MCord)]([#111](https://github.com/extrawurst/gitui/issues/111))
+
+## [0.5.0] - 2020-06-01
+
+### Changed
+
+- support more commands allowing optional multiline commandbar ([#83](https://github.com/extrawurst/gitui/issues/83))
+
+![](assets/cmdbar.gif)
+
+### Added
+
+- support adding untracked file/folder to `.gitignore` ([#44](https://github.com/extrawurst/gitui/issues/44))
+- support reverse tabbing using shift+tab ([#92](https://github.com/extrawurst/gitui/issues/92))
+- switch to using cmd line args instead of `ENV` (`-l` for logging and `--version`) **please convert your GITUI_LOGGING usage** [[@shenek](https://github.com/shenek)]([#88](https://github.com/extrawurst/gitui/issues/88))
+- added missing LICENSE.md files in sub-crates [[@ignatenkobrain](https://github.com/ignatenkobrain)]([#94](https://github.com/extrawurst/gitui/pull/94))
+
+### Fixed
+
+- error when diffing huge files ([#96](https://github.com/extrawurst/gitui/issues/96))
+- expressive error when run in bare repos ([#100](https://github.com/extrawurst/gitui/issues/100))
+
+## [0.4.0] - 2020-05-25
+
+### Added
+
+- stashing support (save,apply,drop) ([#3](https://github.com/extrawurst/gitui/issues/3))
+
+### Changed
+
+- log tab refreshes when head changes ([#78](https://github.com/extrawurst/gitui/issues/78))
+- performance optimization of the log tab in big repos
+- more readable default color for the commit hash in the log tab
+- more error/panic resiliance (`unwrap`/`panic` denied by clippy now) [[@MCord](https://github.com/MCord)](<[#77](https://github.com/extrawurst/gitui/issues/77)>)
+
+### Fixes
+
+- panic on small terminal width ([#72](https://github.com/extrawurst/gitui/issues/72))
+
+![](assets/stashing.gif)
+
+## [0.3.0] - 2020-05-20
+
+### Added
+
+- support color themes and light mode [[@MCord](https://github.com/MCord)]([#28](https://github.com/extrawurst/gitui/issues/28))
+
+### Changed
+
+- more natural scrolling in log tab ([#52](https://github.com/extrawurst/gitui/issues/52))
+
+### Fixed
+
+- crash on commit when git name was not set ([#74](https://github.com/extrawurst/gitui/issues/74))
+- log tab shown empty in single commit repos ([#75](https://github.com/extrawurst/gitui/issues/75))
+
+![](assets/light-theme.png)
+
+## [0.2.6] - 2020-05-18
+
+### Fixed
+
+- fix crash help in small window size ([#63](https://github.com/extrawurst/gitui/issues/63))
+
+## [0.2.5] - 2020-05-16
+
+### Added
+
+- introduced proper changelog
+- hook support on windows [[@MCord](https://github.com/MCord)]([#14](https://github.com/extrawurst/gitui/issues/14))
+
+### Changed
+
+- show longer commit messages in log view
+- introduce propper error handling in `asyncgit` [[@MCord](https://github.com/MCord)]([#53](https://github.com/extrawurst/gitui/issues/53))
+- better error message when trying to run outside of a valid git repo ([#56](https://github.com/extrawurst/gitui/issues/56))
+- improve ctrl+c handling so it is checked first and no component needs to worry of blocking it
+
+### Fixed
+
+- support multiple tags per commit in log ([#61](https://github.com/extrawurst/gitui/issues/61))
+
+## [0.2.3] - 2020-05-12
+
+### Added
+
+- support more navigation keys: home/end/pageUp/pageDown ([#43](https://github.com/extrawurst/gitui/issues/43))
+- highlight current tab a bit better
+
+## [0.2.2] - 2020-05-10
+
+### Added
+
+- show tags in commit log ([#47](https://github.com/extrawurst/gitui/issues/47))
+- support home/end key in diff ([#43](https://github.com/extrawurst/gitui/issues/43))
+
+### Changed
+
+- close application shortcut is now the standard `ctrl+c`
+- some diff improvements ([#42](https://github.com/extrawurst/gitui/issues/42))
+
+### Fixed
+
+- document tab key to switch tabs ([#48](https://github.com/extrawurst/gitui/issues/48))
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..d2c40c4
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1248 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+[[package]]
+name = "addr2line"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c0929d69e78dd9bf5408269919fcbcaeb2e35e5d43e5815517cdc6a8e11a423"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee2a4ec343196209d6594e19543ae87a39f96d5534d7174822a3ad825dd6ed7e"
+
+[[package]]
+name = "ahash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "865f8b0b3fced577b7df82e9b0eb7609595d7209c0b39e78d0646672e244b1b1"
+dependencies = [
+ "getrandom 0.2.0",
+ "lazy_static",
+ "version_check",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c0df63cb2955042487fad3aefd2c6e3ae7389ac5dc1beb28921de0b69f779d4"
+
+[[package]]
+name = "arrayvec"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9"
+dependencies = [
+ "nodrop",
+]
+
+[[package]]
+name = "asyncgit"
+version = "0.11.0"
+dependencies = [
+ "crossbeam-channel",
+ "git2",
+ "invalidstring",
+ "log",
+ "rayon-core",
+ "scopetime",
+ "serial_test",
+ "tempfile",
+ "thiserror",
+ "url",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
+
+[[package]]
+name = "backtrace"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef5140344c85b01f9bbb4d4b7288a8aa4b3287ccef913a14bcc78a1063623598"
+dependencies = [
+ "addr2line",
+ "cfg-if 1.0.0",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "base64"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff"
+
+[[package]]
+name = "bitflags"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
+
+[[package]]
+name = "bytemuck"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41aa2ec95ca3b5c54cf73c91acf06d24f4495d5f1b1c12506ae3483d646177ac"
+
+[[package]]
+name = "bytesize"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81a18687293a1546b67c246452202bbbf143d239cb43494cc163da14979082da"
+
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
+[[package]]
+name = "cc"
+version = "1.0.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c0496836a84f8d0495758516b8621a622beb77c0fed418570e50764093ced48"
+dependencies = [
+ "jobserver",
+]
+
+[[package]]
+name = "cfg-if"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
+dependencies = [
+ "libc",
+ "num-integer",
+ "num-traits",
+ "time",
+ "winapi",
+]
+
+[[package]]
+name = "clap"
+version = "2.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
+dependencies = [
+ "bitflags",
+ "textwrap 0.11.0",
+ "unicode-width",
+]
+
+[[package]]
+name = "const_fn"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c478836e029dcef17fb47c89023448c64f781a046e0300e257ad8225ae59afab"
+
+[[package]]
+name = "cpp_demangle"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44919ecaf6f99e8e737bc239408931c9a01e9a6c74814fee8242dd2506b65390"
+dependencies = [
+ "cfg-if 1.0.0",
+ "glob",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dca26ee1f8d361640700bde38b2c37d8c22b3ce2d360e1fc1c74ea4b0aa7d775"
+dependencies = [
+ "cfg-if 1.0.0",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94af6efb46fef72616855b036a624cf27ba656ffc9be1b9a3c931cfc7749a9a9"
+dependencies = [
+ "cfg-if 1.0.0",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1aaa739f95311c2c7887a76863f500026092fb1dce0161dab577e559ef3569d"
+dependencies = [
+ "cfg-if 1.0.0",
+ "const_fn",
+ "crossbeam-utils",
+ "lazy_static",
+ "memoffset",
+ "scopeguard",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d96d1e189ef58269ebe5b97953da3274d83a93af647c2ddd6f9dab28cedb8d"
+dependencies = [
+ "autocfg",
+ "cfg-if 1.0.0",
+ "lazy_static",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e86d73f2a0b407b5768d10a8c720cf5d2df49a9efc10ca09176d201ead4b7fb"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "lazy_static",
+ "libc",
+ "mio",
+ "parking_lot",
+ "serde",
+ "signal-hook",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2265c3f8e080075d9b6417aa72293fc71662f34b4af2612d8d1b074d29510db"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "debugid"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f91cf5a8c2f2097e2a32627123508635d47ce10563d999ec1a95addf08b502ba"
+dependencies = [
+ "uuid",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if 1.0.0",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99de365f605554ae33f115102a02057d4fc18b01f3284d6870be0938743cfe7d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "either"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ece68d15c92e84fa4f19d3780f1294e5ca82a78a6d515f1efaabcc144688be00"
+dependencies = [
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc587bc0ec293155d5bfa6b9891ec18a1e330c234f896ea47fbada4cadbe47e6"
+dependencies = [
+ "cfg-if 0.1.10",
+ "libc",
+ "wasi 0.9.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee8025cf36f917e6a52cce185b7c7177689b838b7ec138364e50cc2277a56cf4"
+dependencies = [
+ "cfg-if 0.1.10",
+ "libc",
+ "wasi 0.9.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "gimli"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6503fe142514ca4799d4c26297c4248239fe8838d827db6bd6065c6ed29a6ce"
+
+[[package]]
+name = "git2"
+version = "0.13.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca6f1a0238d7f8f8fd5ee642f4ebac4dbc03e03d1f78fbe7a3ede35dcf7e2224"
+dependencies = [
+ "bitflags",
+ "libc",
+ "libgit2-sys",
+ "log",
+ "openssl-probe",
+ "openssl-sys",
+ "url",
+]
+
+[[package]]
+name = "gitui"
+version = "0.11.0"
+dependencies = [
+ "anyhow",
+ "asyncgit",
+ "backtrace",
+ "bitflags",
+ "bytesize",
+ "chrono",
+ "clap",
+ "crossbeam-channel",
+ "crossterm",
+ "dirs-next",
+ "itertools",
+ "log",
+ "pprof",
+ "rayon-core",
+ "ron",
+ "scopeguard",
+ "scopetime",
+ "serde",
+ "simplelog",
+ "textwrap 0.13.1",
+ "tui",
+ "unicode-width",
+ "which",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
+
+[[package]]
+name = "hashbrown"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04"
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "idna"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02e2673c30ee86b5b96a9cb52ad15718aa1f966f5ab9ad54a8b95d5ca33120a9"
+dependencies = [
+ "matches",
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55e2e4c765aa53a0424761bf9f41aa7a6ac1efa87238f59560640e27fca028f2"
+dependencies = [
+ "autocfg",
+ "hashbrown",
+]
+
+[[package]]
+name = "inferno"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "514b79a9791d88f90889bd38a88dc43a6058b3ec72a8930a817c6e59fa9e4927"
+dependencies = [
+ "ahash",
+ "indexmap",
+ "itoa",
+ "lazy_static",
+ "log",
+ "num-format",
+ "quick-xml",
+ "rgb",
+ "str_stack",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec"
+dependencies = [
+ "cfg-if 1.0.0",
+]
+
+[[package]]
+name = "invalidstring"
+version = "0.1.2"
+
+[[package]]
+name = "itertools"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6"
+
+[[package]]
+name = "jobserver"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c71313ebb9439f74b00d9d2dcec36440beaf57a6aa0623068441dd7cd81a7f2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1482821306169ec4d07f6aca392a4681f66c75c9918aa49641a2595db64053cb"
+
+[[package]]
+name = "libgit2-sys"
+version = "0.12.14+1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f25af58e6495f7caf2919d08f212de550cfa3ed2f5e744988938ea292b9f549"
+dependencies = [
+ "cc",
+ "libc",
+ "libssh2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+]
+
+[[package]]
+name = "libssh2-sys"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df40b13fe7ea1be9b9dffa365a51273816c345fc1811478b57ed7d964fbfc4ce"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "libz-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "602113192b08db8f38796c4e85c39e960c145965140e918018bcde1952429655"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "lock_api"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd96ffd135b2fd7b973ac026d28085defbe8983df057ced3eb4f2130b0831312"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b"
+dependencies = [
+ "cfg-if 0.1.10",
+]
+
+[[package]]
+name = "matches"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08"
+
+[[package]]
+name = "memchr"
+version = "2.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525"
+
+[[package]]
+name = "memmap"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6585fd95e7bb50d6cc31e20d4cf9afb4e2ba16c5846fc76793f11218da9c475b"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "157b4208e3059a8f9e78d559edc658e13df41410cb3ae03979c83130067fdd87"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f2d26ec3309788e423cfbf68ad1800f061638098d76a83681af979dc4eda19d"
+dependencies = [
+ "adler",
+ "autocfg",
+]
+
+[[package]]
+name = "mio"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f33bc887064ef1fd66020c9adfc45bb9f33d75a42096c81e7c56c65b75dd1a8b"
+dependencies = [
+ "libc",
+ "log",
+ "miow",
+ "ntapi",
+ "winapi",
+]
+
+[[package]]
+name = "miow"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a33c1b55807fbed163481b5ba66db4b2fa6cde694a5027be10fb724206c5897"
+dependencies = [
+ "socket2",
+ "winapi",
+]
+
+[[package]]
+name = "nix"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e4785f2c3b7589a0d0c1dd60285e1188adac4006e8abd6dd578e1567027363"
+dependencies = [
+ "bitflags",
+ "cc",
+ "cfg-if 0.1.10",
+ "libc",
+ "void",
+]
+
+[[package]]
+name = "nodrop"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+
+[[package]]
+name = "ntapi"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "num-format"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bafe4179722c2894288ee77a9f044f02811c86af699344c498b0840c698a2465"
+dependencies = [
+ "arrayvec",
+ "itoa",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
+dependencies = [
+ "autocfg",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "object"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b63360ec3cb337817c2dbd47ab4a0f170d285d8e5a2064600f3def1402397"
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
+
+[[package]]
+name = "openssl-src"
+version = "111.12.0+1.1.1h"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "858a4132194f8570a7ee9eb8629e85b23cbc4565f2d4a162e87556e5956abf61"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a842db4709b604f0fe5d1170ae3565899be2ad3d9cbc72dedc789ac0511f78de"
+dependencies = [
+ "autocfg",
+ "cc",
+ "libc",
+ "openssl-src",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb"
+dependencies = [
+ "instant",
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c6d9b8427445284a09c55be860a15855ab580a417ccad9da88f5a06787ced0"
+dependencies = [
+ "cfg-if 1.0.0",
+ "instant",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "winapi",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c"
+
+[[package]]
+name = "pprof"
+version = "0.3.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "937e4766a8d473f9dd3eb318c654dec77d6715a87ab50081d6e5cfceea73c105"
+dependencies = [
+ "backtrace",
+ "inferno",
+ "lazy_static",
+ "libc",
+ "log",
+ "nix",
+ "parking_lot",
+ "symbolic-demangle",
+ "tempfile",
+ "thiserror",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
+dependencies = [
+ "unicode-xid",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26aab6b48e2590e4a64d1ed808749ba06257882b461d01ca71baeb747074a6dd"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+dependencies = [
+ "getrandom 0.1.15",
+ "libc",
+ "rand_chacha",
+ "rand_core",
+ "rand_hc",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+dependencies = [
+ "getrandom 0.1.15",
+]
+
+[[package]]
+name = "rand_hc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ab346ac5921dc62ffa9f89b7a773907511cdfa5490c572ae9be1be33e8afa4a"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-utils",
+ "lazy_static",
+ "num_cpus",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.1.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
+
+[[package]]
+name = "redox_users"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d"
+dependencies = [
+ "getrandom 0.1.15",
+ "redox_syscall",
+]
+
+[[package]]
+name = "remove_dir_all"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "rgb"
+version = "0.8.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "287f3c3f8236abb92d8b7e36797f19159df4b58f0a658cc3fb6dd3004b1f3bd3"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "ron"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10b27f2d1934e1c43458a35bfaaf1aa7550dbb364a53b963c6894c9121bfd6d5"
+dependencies = [
+ "base64",
+ "bitflags",
+ "serde",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e3bad0ee36814ca07d7968269dd4b7ec89ec2da10c4bb613928d3077083c232"
+
+[[package]]
+name = "scopeguard"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
+
+[[package]]
+name = "scopetime"
+version = "0.1.1"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serial_test"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0bccbcf40c8938196944a3da0e133e031a33f4d6b72db3bda3cc556e361905d"
+dependencies = [
+ "lazy_static",
+ "parking_lot",
+ "serial_test_derive",
+]
+
+[[package]]
+name = "serial_test_derive"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2acd6defeddb41eb60bb468f8825d0cfd0c2a76bc03bfd235b6a1dc4f6a1ad5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "signal-hook"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "604508c1418b99dfe1925ca9224829bb2a8a9a04dda655cc01fcad46f4ab05ed"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce32ea0c6c56d5eacaeb814fbed9960547021d3edd010ded1425f180536b20ab"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "simplelog"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b2736f58087298a448859961d3f4a0850b832e72619d75adc69da7993c2cd3c"
+dependencies = [
+ "chrono",
+ "log",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae524f056d7d770e174287294f562e95044c68e88dec909a00d2094805db9d75"
+
+[[package]]
+name = "smawk"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1bc737c97d093feb72e67f4926d9b22d717ce8580cd25f0ce86d74e859c466d"
+
+[[package]]
+name = "socket2"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c29947abdee2a218277abeca306f25789c938e500ea5a9d4b12a5a504466902"
+dependencies = [
+ "cfg-if 1.0.0",
+ "libc",
+ "redox_syscall",
+ "winapi",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
+[[package]]
+name = "str_stack"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb"
+
+[[package]]
+name = "symbolic-common"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0caab39ce6f074031b8fd3dd297bfda70a2d1f33c6e7cc1b737ac401f856448d"
+dependencies = [
+ "debugid",
+ "memmap",
+ "stable_deref_trait",
+ "uuid",
+]
+
+[[package]]
+name = "symbolic-demangle"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b77ecb5460a87faa37ed53521eed8f073c8339b7a5788c1f93efc09ce74e1b68"
+dependencies = [
+ "cpp_demangle",
+ "rustc-demangle",
+ "symbolic-common",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2af957a63d6bd42255c359c93d9bfdb97076bd3b820897ce55ffbfbf107f44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9"
+dependencies = [
+ "cfg-if 0.1.10",
+ "libc",
+ "rand",
+ "redox_syscall",
+ "remove_dir_all",
+ "winapi",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
+dependencies = [
+ "unicode-width",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aca9e798437265144ddacce09343df0b3413a0aef54b1e212e640e472118b80"
+dependencies = [
+ "smawk",
+ "unicode-width",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e9ae34b84616eedaaf1e9dd6026dbe00dcafa92aa0c8077cb69df1fcfe5e53e"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ba20f23e85b10754cd195504aebf6a27e2e6cbe28c17778a0c930724628dd56"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
+dependencies = [
+ "libc",
+ "wasi 0.10.0+wasi-snapshot-preview1",
+ "winapi",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf8dbc19eb42fba10e8feaaec282fb50e2c14b2726d6301dbfeed0f73306a6f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
+
+[[package]]
+name = "tui"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4e6c82bb967df89f20b875fa8835fab5d5622c6a5efa574a1f0b6d0aa6e8f6"
+dependencies = [
+ "bitflags",
+ "cassowary",
+ "crossterm",
+ "serde",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5"
+dependencies = [
+ "matches",
+]
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13e63ab62dbe32aeee58d1c5408d35c36c392bba5d9d3142287219721afe606"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
+
+[[package]]
+name = "url"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5909f2b0817350449ed73e8bcd81c8c3c8d9a7a5d8acba4b27db277f1868976e"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
+name = "uuid"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fde2f6a4bea1d6e007c4ad38c6839fa71cbb63b6dbf5b595aa38dc9b1093c11"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6454029bf181f092ad1b853286f23e2c507d8e8194d01d92da4a55c274a5508c"
+
+[[package]]
+name = "version_check"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed"
+
+[[package]]
+name = "void"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
+
+[[package]]
+name = "wasi"
+version = "0.9.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+
+[[package]]
+name = "wasi"
+version = "0.10.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
+
+[[package]]
+name = "which"
+version = "4.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87c14ef7e1b8b8ecfc75d5eca37949410046e66f15d185c01d70824f1f8111ef"
+dependencies = [
+ "libc",
+ "thiserror",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..22ece8b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,73 @@
+[package]
+name = "gitui"
+version = "0.11.0"
+authors = ["Stephan Dilly <dilly.stephan@gmail.com>"]
+description = "blazing fast terminal-ui for git"
+edition = "2018"
+exclude = [".github/*",".vscode/*"]
+homepage = "https://github.com/extrawurst/gitui"
+repository = "https://github.com/extrawurst/gitui"
+readme = "README.md"
+license = "MIT"
+categories = ["command-line-utilities"]
+keywords = [
+ "git",
+ "gui",
+ "cli",
+ "terminal",
+ "ui",
+]
+
+[dependencies]
+scopetime = { path = "./scopetime", version = "0.1" }
+asyncgit = { path = "./asyncgit", version = "0.11" }
+crossterm = { version = "0.18", features = [ "serde" ] }
+clap = { version = "2.33", default-features = false }
+tui = { version = "0.13", default-features = false, features = ['crossterm', 'serde'] }
+bytesize = { version = "1.0.1", default-features = false}
+itertools = "0.9"
+rayon-core = "1.9"
+log = "0.4"
+simplelog = { version = "0.8", default-features = false }
+dirs-next = "2.0"
+crossbeam-channel = "0.5"
+scopeguard = "1.1"
+bitflags = "1.2"
+chrono = "0.4"
+backtrace = "0.3"
+ron = "0.6"
+serde = "1.0"
+anyhow = "1.0.35"
+unicode-width = "0.1"
+textwrap = "0.13"
+
+[target.'cfg(target_os = "linux")'.dependencies]
+which = "4.0"
+
+# pprof is not available on windows
+[target.'cfg(not(windows))'.dependencies]
+pprof = { version = "0.3", features = ["flamegraph"], optional = true }
+
+[badges]
+maintenance = { status = "actively-developed" }
+
+[features]
+default=[]
+timing=["scopetime/enabled"]
+
+[workspace]
+members=[
+ "asyncgit",
+ "scopetime",
+]
+
+[profile.release]
+lto = true
+opt-level = 'z' # Optimize for size.
+codegen-units = 1
+
+# make debug build as fast as release
+# usage of utf8 encoding inside tui
+# makes their debug profile slow
+[profile.dev.package."tui"]
+opt-level = 3 \ No newline at end of file
diff --git a/KEY_CONFIG.md b/KEY_CONFIG.md
new file mode 100644
index 0000000..3841dc9
--- /dev/null
+++ b/KEY_CONFIG.md
@@ -0,0 +1,16 @@
+# Key Config
+
+The default keys are based on arrow keys to navigate.
+
+However popular demand lead to fully customizability of the key bindings.
+
+On first start `gitui` will create `key_config.ron` file automatically based on the defaults.
+This file allows changing every key binding.
+
+The config file format based on the [Ron file format](https://github.com/ron-rs/ron).
+The location of the file depends on your OS:
+* `$HOME/Library/Application Support/gitui/key_config.ron` (mac)
+* `$XDG_CONFIG_HOME/gitui/key_config.ron` (linux using XDG)
+* `$HOME/.config/gitui/key_config.ron` (linux)
+
+Here is a [vim style key config](assets/vim_style_key_config.ron) with `h`, `j`, `k`, `l` to navigate. Use it to copy the content into `key_config.ron` to get vim style key bindings.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..7306a7d
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Stephan Dilly
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE. \ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..549faad
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,66 @@
+
+.PHONY: debug build-release release-linux-musl test clippy clippy-pedantic install install-debug
+
+profile:
+ cargo run --features=timing,pprof -- -l
+
+debug:
+ cargo run --features=timing -- -l
+
+build-release:
+ cargo build --release
+
+release-mac: build-release
+ strip target/release/gitui
+ mkdir -p release
+ tar -C ./target/release/ -czvf ./release/gitui-mac.tar.gz ./gitui
+ ls -lisah ./release/gitui-mac.tar.gz
+
+release-win: build-release
+ mkdir -p release
+ tar -C ./target/release/ -czvf ./release/gitui-win.tar.gz ./gitui.exe
+ cargo install cargo-wix
+ cargo wix --no-build --nocapture --output ./release/gitui.msi
+ ls -l ./release/gitui.msi
+
+release-linux-musl: build-linux-musl-release
+ strip target/x86_64-unknown-linux-musl/release/gitui
+ mkdir -p release
+ tar -C ./target/x86_64-unknown-linux-musl/release/ -czvf ./release/gitui-linux-musl.tar.gz ./gitui
+
+build-linux-musl-debug:
+ cargo build --target=x86_64-unknown-linux-musl
+
+build-linux-musl-release:
+ cargo build --release --target=x86_64-unknown-linux-musl
+
+test-linux-musl:
+ cargo test --workspace --target=x86_64-unknown-linux-musl
+
+test:
+ cargo test --workspace
+
+fmt:
+ cargo fmt -- --check
+
+clippy:
+ touch src/main.rs
+ cargo clean -p gitui -p asyncgit -p scopetime
+ cargo clippy --all-features
+
+clippy-nightly:
+ touch src/main.rs
+ cargo clean -p gitui -p asyncgit -p scopetime
+ cargo +nightly clippy --all-features
+
+clippy-pedantic:
+ cargo clean -p gitui -p asyncgit -p scopetime
+ cargo clippy --all-features -- -W clippy::pedantic
+
+check: fmt clippy test
+
+install:
+ cargo install --path "." --offline
+
+install-timing:
+ cargo install --features=timing --path "." --offline \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..475117b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,165 @@
+<h1 align="center">
+<img width="300px" src="assets/logo.png" />
+
+[![CI][s0]][l0] [![crates][s1]][l1] ![MIT][s2] [![UNSAFE][s3]][l3] [![ITCH][s4]][l4] [![DISC][s5]][l5] [![TWEET][s6]][l6]
+
+</h1>
+
+[s0]: https://github.com/extrawurst/gitui/workflows/CI/badge.svg
+[l0]: https://github.com/extrawurst/gitui/actions
+[s1]: https://img.shields.io/crates/v/gitui.svg
+[l1]: https://crates.io/crates/gitui
+[s2]: https://img.shields.io/badge/license-MIT-blue.svg
+[s3]: https://img.shields.io/badge/unsafe-forbidden-success.svg
+[l3]: https://github.com/rust-secure-code/safety-dance/
+[s4]: https://img.shields.io/badge/itch.io-ok-green
+[l4]: https://extrawurst.itch.io/gitui
+[s5]: https://img.shields.io/discord/723083834811220028.svg?logo=chat
+[l5]: https://discord.gg/7TGFfuq
+[s6]: https://img.shields.io/twitter/follow/extrawurst?label=follow&style=social
+[l6]: https://twitter.com/intent/follow?screen_name=extrawurst
+
+<h5 align="center">Blazing fast terminal client for git written in Rust</h1>
+
+![](assets/demo.gif)
+
+# Features
+
+- Fast and intuitive **keyboard only** control
+- Context based help (**no need to memorize** tons of hot-keys)
+- Inspect, commit, and amend changes (incl. hooks: _commit-msg_/_post-commit_)
+- Stage, unstage, revert and reset files and hunks
+- Stashing (save, apply, drop, and inspect)
+- Push to remote
+- Branch List (create, rename, delete)
+- Browse commit log, diff committed changes
+- Scalable terminal UI layout
+- Async [input polling](assets/perf_compare.jpg)
+- Async git API for fluid control
+
+# Benchmarks
+
+For a [RustBerlin meetup presentation](https://youtu.be/rpilJV-eIVw?t=5334) ([slides](https://github.com/extrawurst/gitui-presentation)) I compared `lazygit`,`tig` and `gitui` by parsing the entire Linux git repository (which contains over 900k commits):
+
+| | Time | Memory (GB) | Binary (MB) | Freezes | Crashes |
+| --------- | ----------- | ----------- | ----------- | --------- | --------- |
+| `gitui` | **24 s** ✅ | **0.17** ✅ | 1.4 | **No** ✅ | **No** ✅ |
+| `lazygit` | 57 s | 2.6 | 16 | Yes | Sometimes |
+| `tig` | 4 m 20 s | 1.3 | **0.6** ✅ | Sometimes | **No** ✅ |
+
+# Motivation
+
+I do most of my git usage in a terminal but I frequently found myself using git UIs for some use cases like: index, commit, diff, stash and log.
+
+Over the last 2 years my go-to GUI tool for this was [fork](https://git-fork.com) because it was snappy, free, and not bloated. Unfortunately the _free_ part will [change soon](https://github.com/ForkIssues/TrackerWin/issues/571) and so I decided to build a fast and simple terminal tool to help with features I use the most.
+
+# Known Limitations
+
+- no support for `pull` yet (see [#90](https://github.com/extrawurst/gitui/issues/90))
+- no support for [bare repositories](https://git-scm.com/book/en/v2/Git-on-the-Server-Getting-Git-on-a-Server) (see [#100](https://github.com/extrawurst/gitui/issues/100))
+- no support for [core.hooksPath](https://git-scm.com/docs/githooks) config
+
+Currently, this tool does not fully substitute the _git shell_, however both tools work well in tandem.
+
+`gitui` currently lacks essential features in git like push, pull, and checkout. The priorities are the basics (add, commit), and on features that are making me mad when done on the _git shell_, like stashes and hunks. Eventually, I will be able to work on features that could lead to making `gitui` a one stop solution to get rid of the shell entirely - but for that I need help - this is just a spare time project right now.
+
+All support is welcomed! Sponsors as well! ❤️
+
+# Installation
+
+For the time being this product is in alpha and is not considered production ready. However, for personal use it is reasonably stable and is being used while developing itself.
+
+### Arch Linux
+
+There is an [AUR package](https://aur.archlinux.org/packages/gitui/) for `gitui`:
+
+```sh
+git clone https://aur.archlinux.org/gitui.git
+cd gitui
+makepkg -si
+```
+
+### Fedora
+
+```sh
+sudo dnf install gitui
+```
+
+### Gentoo
+
+Available in [dm9pZCAq overlay](https://github.com/gentoo-mirror/dm9pZCAq)
+
+```sh
+sudo eselect repository enable dm9pZCAq
+sudo emerge --sync dm9pZCAq
+sudo emerge dev-vcs/gitui::dm9pZCAq
+```
+
+### Homebrew (macOS)
+
+```sh
+brew install gitui
+```
+
+### [Scoop](https://github.com/ScoopInstaller/Main/blob/master/bucket/gitui.json) (Windows)
+
+```
+scoop install gitui
+```
+
+### [Chocolatey](https://chocolatey.org/packages/gitui) (Windows)
+
+```
+choco install gitui
+```
+
+## Release Binaries
+
+[Available for download in releases](https://github.com/extrawurst/gitui/releases)
+
+Binaries available for:
+
+- Linux
+- macOS
+- Windows
+
+# Build
+
+### Requirements
+
+- Latest `rust` and `cargo`
+ - See [Install Rust](https://www.rust-lang.org/tools/install)
+
+### Cargo Install
+
+The simplest way to start playing around with `gitui` is to have `cargo` build and install it with `cargo install gitui`
+
+# Diagnostics
+
+To run with logging enabled run `gitui -l`.
+
+This will log to:
+
+- macOS: `$HOME/Library/Caches/gitui/gitui.log`
+- Linux using `XDG`: `$XDG_CACHE_HOME/gitui/gitui.log`
+- Linux: `$HOME/.cache/gitui/gitui.log`
+
+# Color Theme
+
+![](assets/light-theme.png)
+
+`gitui` should automatically work on both light and dark terminal themes.
+
+However, you can customize everything to your liking: See [Themes](THEMES.md).
+
+# Key Bindings
+
+The key bindings can be customized: See [Key Config](KEY_CONFIG.md) on how to set them to `vim`-like bindings.
+
+# Inspiration
+
+- [lazygit](https://github.com/jesseduffield/lazygit)
+- [tig](https://github.com/jonas/tig)
+- [GitUp](https://github.com/git-up/GitUp)
+ - It would be nice to come up with a way to have the map view available in a terminal tool
+- [git-brunch](https://github.com/andys8/git-brunch)
diff --git a/THEMES.md b/THEMES.md
new file mode 100644
index 0000000..9972614
--- /dev/null
+++ b/THEMES.md
@@ -0,0 +1,13 @@
+# Themes
+
+default on light terminal:
+![](assets/light-theme.png)
+
+to change the colors of the program you have to modify `theme.ron` file
+[Ron format](https://github.com/ron-rs/ron) located at config path. The path differs depending on the operating system:
+
+* `$HOME/Library/Application Support/gitui/theme.ron` (mac)
+* `$XDG_CONFIG_HOME/gitui/theme.ron` (linux using XDG)
+* `$HOME/.config/gitui/theme.ron` (linux)
+
+Valid colors can be found in tui-rs' [Color](https://docs.rs/tui/0.12.0/tui/style/enum.Color.html) struct. note that rgb colors might not be supported in every terminal.
diff --git a/assets/amend.gif b/assets/amend.gif
new file mode 100644
index 0000000..724248f
--- /dev/null
+++ b/assets/amend.gif
Binary files differ
diff --git a/assets/binary_diff.png b/assets/binary_diff.png
new file mode 100644
index 0000000..d75ee52
--- /dev/null
+++ b/assets/binary_diff.png
Binary files differ
diff --git a/assets/branches.gif b/assets/branches.gif
new file mode 100644
index 0000000..924504e
--- /dev/null
+++ b/assets/branches.gif
Binary files differ
diff --git a/assets/cmdbar.gif b/assets/cmdbar.gif
new file mode 100644
index 0000000..edc3e45
--- /dev/null
+++ b/assets/cmdbar.gif
Binary files differ
diff --git a/assets/commit-details.gif b/assets/commit-details.gif
new file mode 100644
index 0000000..d493f47
--- /dev/null
+++ b/assets/commit-details.gif
Binary files differ
diff --git a/assets/compact-tree.png b/assets/compact-tree.png
new file mode 100644
index 0000000..4f4ac83
--- /dev/null
+++ b/assets/compact-tree.png
Binary files differ
diff --git a/assets/demo.gif b/assets/demo.gif
new file mode 100644
index 0000000..60eed27
--- /dev/null
+++ b/assets/demo.gif
Binary files differ
diff --git a/assets/expandable-commands.drawio b/assets/expandable-commands.drawio
new file mode 100644
index 0000000..7fb46c9
--- /dev/null
+++ b/assets/expandable-commands.drawio
@@ -0,0 +1 @@
+<mxfile host="app.diagrams.net" modified="2020-05-23T13:11:59.516Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15" etag="nKCWIzIC1T2-_TwD7yCE" version="13.1.3" type="device"><diagram id="KDklGPkv8WkujI4HOg4Q" name="Page-1">5Zhdr5MwAIZ/DZcnGe2Acek+1GiciYsx8a6HdtBYKJZOxvn1tqOMsXLiNGESdrPRtx/Q9wH6Fgeu0uM7gfLkE8eEOWCGjw5cOwAEC1/9aqGqBQ+GtRALimvJbYUdfSFGnBn1QDEpOg0l50zSvCtGPMtIJDsaEoKX3WZ7zrpnzVFMLGEXIWar3yiWSa0uQNDq7wmNk+bMrm/ml6KmsZlJkSDMywsJbhy4EpzL+ig9rgjT3jW+1P3evlJ7vjBBMnlLh+rj9oO/3e23n+HL5vv8iX3FX57MKL8QO5gJm4uVVeOA4IcMEz3IzIHLMqGS7HIU6dpSIVdaIlOmSq46NMMRIcnx1et0z7NXdw3hKZGiUk2aDg366qpctv5Dz2jJhfdgZkRkmMfnsVtb1IFx5i9cAiN0CVy5BHpcgj0uucFQLkHLpYinKZVKc7xl5HhryzU1f9m1ppCC/yArzrhQSsYz1XK5p4xdSYjROFPFSPlFlL7UblL13L4xFSnFWJ+ml0WX1gA4QGjjCHrv2YFgzC0YgugZ1jDW04YRjgyGZ8EopF53Tixqz6bMA/gj4+FbPFIuDI7iZMaUccDgzzgW98QRjG95nfujCyGLEboU/GsI8YZyKXzgEHKN4/xS/V/v2eahecgU4s3HRsPe7D1UDPHdsQGx95WMFMWj5JDghj3TXXOIa+d0hLGjvzjprnF2Cok+SrUd2XOh/xQpOmlK1qLSQym8KyU7vSeE5fVTk0yahXfDRqo/lA7Fwo7uPw9N2HK8zbTzlrWgDLevVcX2S/Gp7uJzO9z8Bg==</diagram></mxfile> \ No newline at end of file
diff --git a/assets/light-theme.png b/assets/light-theme.png
new file mode 100644
index 0000000..0aa94d2
--- /dev/null
+++ b/assets/light-theme.png
Binary files differ
diff --git a/assets/log-commit-info.drawio b/assets/log-commit-info.drawio
new file mode 100644
index 0000000..3f79536
--- /dev/null
+++ b/assets/log-commit-info.drawio
@@ -0,0 +1 @@
+<mxfile host="app.diagrams.net" modified="2020-06-02T16:58:00.925Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15" etag="XbmHwEoPIuusTyLd3JQZ" version="13.1.13" type="device"><diagram id="t1-bsFE1bwoXy9pVcyMy" name="Page-1">7VzbcuI4EP0aV+0+QNmyxeUxQJjULsluLTNJzb6kFCzAE9vy2CKQfP1KvttSKmQLYQ+Qh2BaQrLPabVa3Q2aOfZ2X0IUrG+JjV0N6PZOMycaAEA3DfbCJa+JpG8ME8EqdOxEZBSCufOGU6GeSjeOjaNKR0qIS52gKlwQ38cLWpGhMCTbarclcauzBmiFBcF8gVxR+uDYdJ1IB6BfyG+ws1pnMxu99Pk8lHVOnyRaI5tsSyLzWjPHISE0ufJ2Y+xy8DJcks9N32nNbyzEPt3nA4PHR3ezfQBvsEP/Ivff/ny5HnZ6ySgvyN2kD5zeLH3NEAjJxrcxH0TXzNF27VA8D9CCt24Z50y2pp7L3hnsUryp9D5fcEjxriRKb/ILJh6m4SvrkumMlQKWaoyVqcK2wN/op7J1GftBKkQp56t87AIWdpEi8wmUjBaiZFRRMiUomQMJSmCoCiWzhSiBGkqWRJekKFmqUIICSlcbuiahZl4x8ZQQjd9zD3kcDZfdzugJhZrFLBfpLoiXN61oDFEv6RKyq1gyQRQnQwGLveo6/2eI/eY3V0k33UBP5vIHeo5s1B9E9lAgjWFNq8xENCTPeExcftsTn/is52jpuG5NhFxn5bO3Ll7yEThvDjOtV6mYkuAdLajqScQaHX81i0eZwELylQ/QuK5AVarSb/+CsvSmQRoIIN3iKOK7+gH0WAPmMv77WJk9x7b5THvo8yF4MGvKOhR5gDK7poqGoUDDPY5v2CX+ik/o2+w/3gUhY8d5wbHL5nkO5a5SRpje7Xbjj4TY4xNGwYa/2pwN5gS2zjApWVBwvwWlbIMygEDlOGWqmQWlfvGY4uKxjrl4DNEpGLsk4itCg6PraKHBiQLtXzAIcdi8LastATgQ6RgelQ5xT2nfxgv33HgNUxlMos2fOu5h7PQvs/FaEi/RPKayZgPXWDBO2V7AKgW9hs03EE++nALRaJwuBRKTrYqCO++Ph1v9bvbPj+nu+/zf2f3wedQRfZbGLbZVj9DIzpOyONZBXDspSqKeThxmYk/Fr7PqJ3iJXweOqZVW+7Sy7kYcNbgqBUn0fRsHibk2TUZXpTC1MFLfyZWikfCqFKY2xczag4p4oEnCvlo9TKzPKQ7WyGdXE2be+VNMs+BxFMTyAsnezw1PXo0WySbAo8nh6gn9pvNzK2B3qkuvfueXHFl9SXzaWSLPYRPFH/eIT6KYi0qXKE4LxtHqYFfMm8WyNQDrGUjIwOHSOL2Wv8vAgjFcTDLh1/zGIMcHMow/6mvkfTOl+F/DgGKYhJS8pWhIgM4bMrXlAj5VRXW5MFZeLo/Vl0uM+G1pqkRn93mYvCl/jkKxIbcAec9h8dz6ayGGJXGi+MXYg1JbugAKZKxSI1sIecOqNH8d5fhtDnVZWFWAtJ+gKYmCs6WV6HimVgd331txhBXN9r6xBGWOkhhJuCTFmk2KNZEEk6pG5nm1aUeXBX3kwTdlO7oh1iScQ96rdqa2JCc8VWkvOQ1i4OGS9zroElK3gsRoyInlueoBqCOmueSIi/GQBPHGUgYKfKv+x6Af10KJ8RWIlj08uJz9Lme/y9nv7M9+/T33YXUGSoxszphjpMW1D0m665SrHwRGjln+ICekhTFUuG+dobI0haQo5PSrHerHLUmVoapiBzkJ8pKTUyx2yBRfknk6bmZdXl8imoTTgbzpcxIQYzvNGeBsm4LtS4lmsJTrGTBFjns+zgNsm/MgqYVqge7ukac2ezKYlEWfwBnEQmDbYiFA9GxTH46bi04tjXTShsPQPzYc0uI0ZdyI/vWcIrpJyTFiOlJ+/KcoUHNIbws9gsXKotUleqQGSxk9oued50+zoMmMrBKyQEJWFlh5KqIq50NgvRaxaQJN0Y8/4/UlhMEap0fm81/W1/sENri+kD79ubu9ebv7vvW+RnT+2Pu2lf5GQDl3kjC0f+rlyid0zZAG+hT5Cy7JspN6nuh/J3R9SdlcUjYnnLJBEbJtZP96mRvBvEmM4CdcQknmRn6IVWXyxKP+J6qKPzBqF+t1sV4nab3O3mxJio2Pa7bEyFviWM+c6CC1Xwo8Zm6Y0t9G42AfhBirV/0OJ5B8jdaQZfcO8YVyKTFipVKZGDYrt9F5hP9siBr2uzWmIOhKfiVkCEWuLKiIK7FoI9/J/ybBJjgffkwdHJUfLd1nirbSDmNe/wc=</diagram></mxfile> \ No newline at end of file
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 0000000..46bad40
--- /dev/null
+++ b/assets/logo.png
Binary files differ
diff --git a/assets/msg-scrolling.gif b/assets/msg-scrolling.gif
new file mode 100644
index 0000000..7d3bfe6
--- /dev/null
+++ b/assets/msg-scrolling.gif
Binary files differ
diff --git a/assets/newlines.gif b/assets/newlines.gif
new file mode 100644
index 0000000..8b9a19e
--- /dev/null
+++ b/assets/newlines.gif
Binary files differ
diff --git a/assets/perf_compare.jpg b/assets/perf_compare.jpg
new file mode 100644
index 0000000..bf685cf
--- /dev/null
+++ b/assets/perf_compare.jpg
Binary files differ
diff --git a/assets/push.gif b/assets/push.gif
new file mode 100644
index 0000000..528451d
--- /dev/null
+++ b/assets/push.gif
Binary files differ
diff --git a/assets/screenshots/s00-diff.png b/assets/screenshots/s00-diff.png
new file mode 100644
index 0000000..318d8a2
--- /dev/null
+++ b/assets/screenshots/s00-diff.png
Binary files differ
diff --git a/assets/screenshots/s01-log.png b/assets/screenshots/s01-log.png
new file mode 100644
index 0000000..5a18110
--- /dev/null
+++ b/assets/screenshots/s01-log.png
Binary files differ
diff --git a/assets/screenshots/s02-revert.png b/assets/screenshots/s02-revert.png
new file mode 100644
index 0000000..d2d6bc6
--- /dev/null
+++ b/assets/screenshots/s02-revert.png
Binary files differ
diff --git a/assets/screenshots/s03-commit.png b/assets/screenshots/s03-commit.png
new file mode 100644
index 0000000..715f264
--- /dev/null
+++ b/assets/screenshots/s03-commit.png
Binary files differ
diff --git a/assets/scrollbar.gif b/assets/scrollbar.gif
new file mode 100644
index 0000000..7207bfb
--- /dev/null
+++ b/assets/scrollbar.gif
Binary files differ
diff --git a/assets/select-copy.gif b/assets/select-copy.gif
new file mode 100644
index 0000000..8351fa7
--- /dev/null
+++ b/assets/select-copy.gif
Binary files differ
diff --git a/assets/spinner.gif b/assets/spinner.gif
new file mode 100644
index 0000000..3f1d0e2
--- /dev/null
+++ b/assets/spinner.gif
Binary files differ
diff --git a/assets/stashing.drawio b/assets/stashing.drawio
new file mode 100644
index 0000000..f6bea89
--- /dev/null
+++ b/assets/stashing.drawio
@@ -0,0 +1 @@
+<mxfile host="app.diagrams.net" modified="2020-05-19T12:41:55.023Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15" etag="G_7kN2nuUboj_f0S3bPh" version="13.1.3" type="device" pages="2"><diagram id="hAseTAaTBIQ-p2lVXsqS" name="Page-1">7Vlbk5owFP41zLQPO0NAUB+97LZ92Je1l+coEegGQiGu2l/f3CBEcNzuiLK2L5p8JyTk+87JSYLlzpLdpxxm0SMJELYcO9hZ7txynOHIZ78c2EvAc8cSCPM4kBDQwCL+jRRoK3QTB6gwGlJCMI0zE1yRNEUramAwz8nWbLYm2Bw1gyFqAIsVxE30RxzQSKIjZ6jxzygOo3Jk4Kv5JbBsrGZSRDAg2xrk3lvuLCeEylKymyHMuSt5kc89HLFWL5ajlL7mgadvOMToyV8+foFPYA6+D4Y/71QvLxBv1ITVy9J9yUBONmmAeCe25U63UUzRIoMrbt0yyRkW0QSzGmBF1R3KKdodfU9QzZ55DSIJovmeNSkfsBVhpceo6lbT7/oKi2rUOyUIleRh1bVmhRUUMX9BktNDkoBJ0qhJUuV4BkmgK5LcBkkLCummaFDFJk1NPgqak2c0I5jkDElJylpO1zHGBxDEcZiy6oqRhBg+5RTGLFYnypDEQcCHaRXAlKgDDVocddAmQVcKDNoUKKI4DXuqwZqkdKFeCpxDEO+kIK0LR1eC+E1B4AvqqRgdBIQzvnJEjPu3cA/8Yc+yWylar1gamiy1pDe31ZM6S2+guVO69fx2KMK18xto24f5mJNdZDBl5ZCKmUtsWQIiC7IzhMLZ2MvDtgwzu+ilpvV8aZ9BYO+0wBfNl6C5h4yYdJbDurO/xgmSpcmGRoxiURbapjDpIqtitKa9C8K2jf5lo7C5zZxkGd6boVhTwv+14Ufc6UqyPGHGPFzCD+wFbSlia+kjL3IKbe72d2uYxGwQ8XhCUlII3o0mhbg84A3sbKfH1UHtHV5TeGzeHBWH8KpW8uAJJhgy52X+Yh6fucfoO9UWVG1Lqd/UjaO7kXRXljL3lGZJd2UWzs9q0v05DES1HgIcFR7PDToMDFiGAofU+sVBMyC4UYUEN9aCgltEWHC8DAwO2gKpzU0GyGvYq0wVcTpkPB40uiXQTNt7jXs1WAZRZRrUTCqWtBQ1Gyw0HtaGP1RVVCtp66DpcKpdwzPfU27qYKm7+vEBeI2lbp6T7HYV8MBpBbyLKtA8Qj/EGIFbzfavuda7bAgMWwVonhBvVIDqMvp6Coz+b4pPZYrrq1R2fLZLlAO+z0BadWFyfG2pvL/O2bgzzlo+PnnTneXNBW/Cye1NSnO4euY7x9v05bfK0p0rt1yzNGRh/+G/o0nb+nJhUVo/r8lrrVvdjTqnv69ddDPqvOsPbGe5MASXujC01BlZ22qnY/f+Dw==</diagram><diagram id="o9nFayAMEWxc9pClOnIG" name="Page-2">7Vxbm5rKEv01edzn46KT4dEr4BZmvKK8nI+LERDEM6ICv/6s4qbOOMkkMdl7f5skRm2a7urqqlWrurE/8Z0gFl+MnaOE9sr/xDF2/InvfuK4z48P+J8KkrygyQt5wfrFtfMi9lwwcdNVUcgUpQfXXu2vKkZh6Efu7rrQCrfblRVdlRkvL+HputqX0L/udWesV28KJpbhvy3VXDty8tJH7vO5XFq5a6fsmX0oxhcYZeViJHvHsMPTRRHf+8R3XsIwyj8FcWflk+5KvSi7l9Pmj6cvf3Y1o20vV8J/48MfeWP977mlGsLLahvdt2kub/po+IdCX8VYo6RUIIa9o49ukGm6fVy9RC70OzTMlf8c7t3IDbe4boZRFAao4NOFtmFt1i/hYWt3Qj98yZriv2R/Ltpo+e6a7o3CHUqN/S63gC9uvILU7azLVlnKlCX4bBuR8Ylv5V+5/v64/sS14wDj7TxLKqcn7YapxQcrZVxDGjNWNzwOeZu3kyavJM2jFVhHxWudlI6Q2oHlypITmWIzfdo6e0NrvjxPBqEtjU9P7uMRd/HDrZUOAyHRk8f4abppDvm8nuy2OX0xSA1NODxP5Hjo9dCWvdOlcYjvvOI5p1VHXhvifKdzDoOyRPX6WZkd+L7NDI6rLuMOp62DOmlFSqfByl0reeq0UI/aUpmVFvuQx11q6ovOD4621twMNfVoikLy5LYTnZNDxZuxQ7pnOtvLXZlVJ5vPstd41MV5YCWP0M2zaNH/0sBf8qM19alAhtU0f7c5f2OLa0H2FE55I2/LlUV9Z4onQXbVQM/+LjPZrEB40dGGnEKXXdKnvLZEYWdux77lsr4dzA+2pAhy0N+bHfn47MWJrqmMLFFfvRP010Qf6bUMcvJaBmX6y2Wgd97QxowB3ahd6s/xDc0ObdLVpQ6C5tEMZld9m4Fw0Cff7HcrS+NkqTVTHbZk4j5bGjjmVt0vF2P/zw5zGnqzdW4LGxf1U+iEWXLrbP50WC1ppPm8DHbZLFr8uGmKM/QgkBUGsAgeFsEZC3W3DGL/KWkz+sJhhlof1mofcG2vazrzlLwnaTmyyoJjaNSHZkJd87eGNMo0pnSozsC3FvMdZNqMg7ljS/NEX1AbDmNLrYdhIsBvrIOdKgeTH2yH7HgyZFVtnPbiIaf7GDFrBTNvDPl0aMTQBtCuz6wmcjba8c52P+DD8MWnidDA9cikea80Mt5ZfBt63Txg3vb6Yv1ZFnMtDDUfPbIptMFC944ezNZLDhoQ5wk09j97oTKGpjczH4OGzO3oQZbmB4vrB/qk7UFaaFX+XGrpyW2lT1K7gXtLLT3IXfh4dw8t9ze66B/0hGVMvoXy3smSSBbWsQJ7Z7psPiNuK1YW1MbF6FNb8vdkXctAaAwxp6sALaXheklIBGmHGqHHfg1rOtgdNjUWOx8tnW7M+1nqzq0eIGNqibGzmoZr4Bphm4/W9yanvqC1wOSaTC7BjMaQ5GPQoZ9RpAf9yNDiJnqRdS2GJY53S66H6yVGtbzML7uZZEXbpW+Fa2XSIL2fTM7P5mCozTmS1qKRavEe13gDlqFPWMjY93TIaJL/YHR0Dda1GS7sZLmAfkWyC9YxoRXZa05tsc/YC+VxyGG8wMO372+8KkPJsb+SCpRM4fnShuyegR8EGOkeflGNiMoy7Wi6Y2r+nhB1Is738ClYkepU13PtUTukPbSZ9REBDTwDWgLCQ4vjDazXffZVJvOP9HSERhp2F5LyKm8sxp5Bn7nXWK5OSylbl977Bsvpux74e7N7gVn8ODG5iGLMAZKSt9+YC1ghRi67RSxZF8gVQPsR7gQS9uEhsWOLs/Aymv2Z+7N0lp6wj/zE3AiuEcw9u3O2Q5PXfSvQ9yZvwef0ncX5LjTy+aytGGNpYJ5jX9+OPr8nazm2c0wpUOsS06et04DGH8xjW/Mh01jUNbIXINtXMGfel48zVpeUae+ol8g3UfsribCCkNffY6zNPP76XMktPsApQltkoePbfqkD0cnbCx1E6MM3F8ptbBJ3LOZ1p2830ZITSD8H8jVwh3Sp6T5808/Q39uXOoJfzxq5X1/4ZoZJ7dMSlgm/3Q4X46NF5dN2hm/wQfAPH5iazQe1EWdtVGM/8xeTX0eYzwdqB1jhkt9C1ojQ7S3GtG7M+VnmG+1vEPVOptgnHNqZQYzXDG3HxyW3r7CK+if8UKaD0wVyBkuN3ZkSYkXP8U1J923R95ZT6JoTYIvyg9xRToXHo6287dKvCLuHHsWD9tHI9R/pC51kpRjjgDNU/dtceQ/Gz8Wbn8S21KDYApQgXTx7N95f+xr8L0OUTrsBPGhUHHA6uuBX1Vjd1xgPPV9ivHutV2pnkHPbrA/IGqgh9Id4zO50cbwnnyx0zleMIhggTowK1FNDso/njpBFMBqDxds7W1TDbDxp49HKMaORsZNSfm9U87eav/0G/iazSl43Wi4GW0NrANvnQYaV0+wemndg0fJiLGBpOTsDbiCuiQOMPZs5wsS32IR+K/8R4bteeDG+1o32L3XlH0ySd4K2RcEz0jfsMFa27TPeVqwlXF+yFlz3gE9HC31XeRXhfd72hZ56B8Xb37LGB5ovwiqbc+C/swgyOktudoVjNgd8AhaAPSAutsnSgPengqXOuD6nT38Y3dIzOsx+J3dz7GAGvpbVIV+gNYYzDnSE7XUGqnBq8ldn4gpfyZBeMcj0LZJmGXOlS72QImctg6PBza4jyI2oCH1RVHSH12ieqql1i8UyNYutWezHWaxyBxar8HlEqLPxv1s2vuGL9bpYvUCBS9R7xekFeXPB6c/8M5ufjCEhHqLNco4OS85B3IP3aT5if+xfSf5hVB/xt1F9ebpmY/IFmlbIdUbSitF9HUV12FiOXaRBRpC3OU9FPoSZWufryjk/3ZFHnzl1TNhHs3+YcoP/5fJXOdURUSOBxx5tzL4d+MTyYKFv+UnOgfbvyVmOq+LnJWpdoHrJVMFC+6wB9NJ7JcucfwV1hNnMFabzjRUrEyEokU9mIJ9IiOK70O3OljYVb1oBncztV5hu2msCyY4r0WFM7eS+k2OCrc8JVXIduCz0OY/IS26hk7Gdp7C/YDVhPfJe8qh3+Guho3CtTp0MnS7zzMzTpbaDXIq4ZGSLwinjdO9wUrVrNep8/EY+LtkhRR6MD1FB9a+4p2TDh5Cr8wOHosQZ3TLuTah3wHuVr+oZoskFJuRXf5izskrX+ovWG4uRfJy3vscZkzr7rrPvd7LvDDVhm7C3AXOJdor3cd6mdr/C237lLkox//+mHNygVcbOaT0X/UifnNajBaQDfgw77WIFsgnpZz/F6axmwemYu3I6rxkSqkHSmaGxPt7b9mJM65F7uZ8jngm2TCsMH9tlgaQl+8wxrs5R6xz1V+eo3uy9HeNUTZ16xfGuaDdawMqlzSPdaV7GjpJ3SoPtMuiFSqe1BydeDztgZEBHpbNZ3xqhqQnoIeeY8o0YQ8zs2Z2lI45tjIlvio5qcrC2ziwdT1DOD44mP/IVZoaXGq16sCbx5Ctee6dTnc4sAbvCd9lXNstv1Ovl9TxYGTwPFoC6Iyo7DaZyMpgOxMFU7ePVNRdOVwcKofxkaFW9JK8nn2ywMlubFeWDMj8u28/1Nin63WaIgrqDzOvQfk+hsW0LBjjLVyaoPEcTXAuILcbdzCKgn7GYcStqo8i3qKzwLNLfFmi2pZWO/n6Uey3qjkuGS/flqxbzfC6or4zfd+je/JkqPR8ndKF2zuOcJ+QZVJ/KIHeiuHghB7PI6l7rttK/7ZscLFMaNKmd1blerHTKNjalXtlzWR5Ryv4QZUqdlh5QzUV1D6JwWV/n5kdTmzPWdvPKJjA3G/1sF+cyRCSBKeedbABzL5F3GD0WWcuc5qt/h8wmfZqWmY3M/sa1m7YltferyewwdBvfs34TV9nN673FaZ3d1NnNh/cWmXvsLardXr2Oc891nA2YON/6iaxF7Y6SKhf4fU9PQIqxBDbX/XDOUj0ZplyhWIFq9Up0vRJ9tRKd5T6IsGB0id29zFnk73hKYvT+U67ben3mnhnLOCDt9X5m/WUqFwgxuuuK8/OGEHEWf++zXPmzrPVqcs23ftNqsvfeanKxs/5LOFexqvZv2v8XIRey0h/OIGPFLRDCuyvfqlBU/qHdrxqvarz6e+BVvfv1j9794v7mu195xnjj95q9+NXuV/Ny90vmimdE3csdsCt++a1dsB+Tot6Dq/fgivw3Q3H4/9ixSI/n1TnEqA8/ZcW8++S/N6qZ4j2ZornqM+tstrg1zSL5m2dw8w14GEWenJHd+vRmDQmxKotjOWuTmYpDdu/NITXesWnmCVnJSgkzlkAa/eNrdWdsY2tsq7HtN2IbV2PbX4Ft9K7fF9funhtf4xreNft7MG36zgkb9S5qnSX/kl3Ur5ywUf9C896/0MxwzPGtH/8Neqyks3/IE+8Xz4TUv+mpd1L/st/01M+C3PVZkOtzJ36Kh42YCiF+6zMhBQ6T/XwDz4p91zNWbH8hzyBG5nyVkXVb++EU+EVMBJHLSHch5AhKbIDvHYcc7JHbH3TcB2ayMxOMip7T5QcMuOfRlCgOCAcrEZz8GdDYt5CJqV6mcQYZTINmhZ5oANcGLg2+wGahyYwJFLP3fqx4OyPtbEaKc5f88veoGdZ5Tc3Q5jzsc62LAuQgGyQeOT8hAieIeYFN2Xcf+kvIpnvX8+W83iW3eH37vKbjE+lfuziucfUSreJX51t+43BItjqxMojFVRisopcE9xWtlGdcFod8fi6+ns4nZjaFosy5OC2TY9n/MI3isM7ioM511fj5MEp8KM6jLL+ej83Mrl2cPcr3/g8=</diagram></mxfile> \ No newline at end of file
diff --git a/assets/stashing.gif b/assets/stashing.gif
new file mode 100644
index 0000000..82bf26c
--- /dev/null
+++ b/assets/stashing.gif
Binary files differ
diff --git a/assets/tagging.gif b/assets/tagging.gif
new file mode 100644
index 0000000..0824c10
--- /dev/null
+++ b/assets/tagging.gif
Binary files differ
diff --git a/assets/vi_support.gif b/assets/vi_support.gif
new file mode 100644
index 0000000..64a5185
--- /dev/null
+++ b/assets/vi_support.gif
Binary files differ
diff --git a/assets/vim_style_key_config.ron b/assets/vim_style_key_config.ron
new file mode 100644
index 0000000..bd193b3
--- /dev/null
+++ b/assets/vim_style_key_config.ron
@@ -0,0 +1,75 @@
+// bit for modifiers
+// bits: 0 None
+// bits: 1 SHIFT
+// bits: 2 CONTROL
+//
+// Note:
+// If the default key layout is lower case,
+// and you want to use `Shift + q` to trigger the exit event,
+// the setting should like this `exit: ( code: Char('Q'), modifiers: ( bits: 1,),),`
+// The Char should be upper case, and the shift modified bit should be set to 1.
+(
+ tab_status: ( code: Char('1'), modifiers: ( bits: 0,),),
+ tab_log: ( code: Char('2'), modifiers: ( bits: 0,),),
+ tab_stashing: ( code: Char('3'), modifiers: ( bits: 0,),),
+ tab_stashes: ( code: Char('4'), modifiers: ( bits: 0,),),
+
+ tab_toggle: ( code: Tab, modifiers: ( bits: 0,),),
+ tab_toggle_reverse: ( code: BackTab, modifiers: ( bits: 1,),),
+
+ focus_workdir: ( code: Char('w'), modifiers: ( bits: 0,),),
+ focus_stage: ( code: Char('s'), modifiers: ( bits: 0,),),
+ focus_right: ( code: Char('l'), modifiers: ( bits: 0,),),
+ focus_left: ( code: Char('h'), modifiers: ( bits: 0,),),
+ focus_above: ( code: Char('k'), modifiers: ( bits: 0,),),
+ focus_below: ( code: Char('j'), modifiers: ( bits: 0,),),
+
+ exit: ( code: Char('Q'), modifiers: ( bits: 1,),),
+ exit_popup: ( code: Esc, modifiers: ( bits: 0,),),
+
+ open_commit: ( code: Char('c'), modifiers: ( bits: 0,),),
+ open_commit_editor: ( code: Char('E'), modifiers: ( bits: 1,),),
+ open_help: ( code: F(1), modifiers: ( bits: 0,),),
+
+ move_left: ( code: Char('h'), modifiers: ( bits: 0,),),
+ move_right: ( code: Char('l'), modifiers: ( bits: 0,),),
+ home: ( code: Home, modifiers: ( bits: 0,),),
+ end: ( code: End, modifiers: ( bits: 0,),),
+ move_up: ( code: Char('k'), modifiers: ( bits: 0,),),
+ move_down: ( code: Char('j'), modifiers: ( bits: 0,),),
+ page_up: ( code: Char('b'), modifiers: ( bits: 2,),),
+ page_down: ( code: Char('f'), modifiers: ( bits: 2,),),
+
+ shift_up: ( code: Char('K'), modifiers: ( bits: 1,),),
+ shift_down: ( code: Char('J'), modifiers: ( bits: 1,),),
+
+ enter: ( code: Enter, modifiers: ( bits: 0,),),
+
+ edit_file: ( code: Char('I'), modifiers: ( bits: 1,),),
+
+ status_stage_all: ( code: Char('a'), modifiers: ( bits: 0,),),
+
+ status_reset_item: ( code: Char('U'), modifiers: ( bits: 1,),),
+ status_ignore_file: ( code: Char('i'), modifiers: ( bits: 0,),),
+
+ stashing_save: ( code: Char('w'), modifiers: ( bits: 0,),),
+ stashing_toggle_untracked: ( code: Char('u'), modifiers: ( bits: 0,),),
+ stashing_toggle_index: ( code: Char('m'), modifiers: ( bits: 0,),),
+
+ stash_open: ( code: Char('l'), modifiers: ( bits: 0,),),
+ stash_drop: ( code: Char('D'), modifiers: ( bits: 1,),),
+
+ cmd_bar_toggle: ( code: Char('.'), modifiers: ( bits: 0,),),
+ log_tag_commit: ( code: Char('t'), modifiers: ( bits: 0,),),
+ commit_amend: ( code: Char('A'), modifiers: ( bits: 1,),),
+ copy: ( code: Char('y'), modifiers: ( bits: 0,),),
+ create_branch: ( code: Char('c'), modifiers: ( bits: 0,),),
+ rename_branch: ( code: Char('r'), modifiers: ( bits: 0,),),
+ select_branch: ( code: Char('b'), modifiers: ( bits: 0,),),
+ delete_branch: ( code: Char('D'), modifiers: ( bits: 1,),),
+ push: ( code: Char('p'), modifiers: ( bits: 0,),),
+ fetch: ( code: Char('f'), modifiers: ( bits: 0,),),
+
+ //removed in 0.11
+ //tab_toggle_reverse_windows: ( code: BackTab, modifiers: ( bits: 1,),),
+)
diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml
new file mode 100644
index 0000000..46594fa
--- /dev/null
+++ b/asyncgit/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "asyncgit"
+version = "0.11.0"
+authors = ["Stephan Dilly <dilly.stephan@gmail.com>"]
+edition = "2018"
+description = "allow using git2 in a asynchronous context"
+homepage = "https://github.com/extrawurst/gitui"
+repository = "https://github.com/extrawurst/gitui"
+readme = "README.md"
+license = "MIT"
+categories = ["concurrency","asynchronous"]
+keywords = ["git"]
+
+[dependencies]
+scopetime = { path = "../scopetime", version = "0.1" }
+git2 = { version = "0.13", features = ["vendored-openssl"] }
+rayon-core = "1.9"
+crossbeam-channel = "0.5"
+log = "0.4"
+thiserror = "1.0"
+url = "2.2"
+
+[dev-dependencies]
+tempfile = "3.1"
+invalidstring = { path = "../invalidstring", version = "0.1" }
+serial_test = "0.5.1" \ No newline at end of file
diff --git a/asyncgit/LICENSE.md b/asyncgit/LICENSE.md
new file mode 120000
index 0000000..7eabdb1
--- /dev/null
+++ b/asyncgit/LICENSE.md
@@ -0,0 +1 @@
+../LICENSE.md \ No newline at end of file
diff --git a/asyncgit/README.md b/asyncgit/README.md
new file mode 100644
index 0000000..492773c
--- /dev/null
+++ b/asyncgit/README.md
@@ -0,0 +1,12 @@
+# asyncgit
+
+*allow using git2 in an asynchronous context*
+
+This crate is designed as part of the [gitui](http://gitui.org) project.
+
+`asyncgit` provides the primary interface to interact with *git* repositories. It is split into the main module and a `sync` part. The latter provides convenience wrapper for typical usage patterns against git repositories.
+
+The primary goal however is to allow putting certain (potentially) long running [git2](https://github.com/rust-lang/git2-rs) calls onto a thread pool.[crossbeam-channel](https://github.com/crossbeam-rs/crossbeam) is then used to wait for a notification confirming the result.
+
+In `gitui` this allows the main-thread and therefore the *ui* to stay responsive.
+
diff --git a/asyncgit/src/cached/branchname.rs b/asyncgit/src/cached/branchname.rs
new file mode 100644
index 0000000..8464458
--- /dev/null
+++ b/asyncgit/src/cached/branchname.rs
@@ -0,0 +1,45 @@
+use crate::{error::Result, sync};
+use sync::Head;
+
+///
+pub struct BranchName {
+ last_result: Option<(Head, String)>,
+ repo_path: String,
+}
+
+impl BranchName {
+ ///
+ pub fn new(path: &str) -> Self {
+ Self {
+ repo_path: path.to_string(),
+ last_result: None,
+ }
+ }
+
+ ///
+ pub fn lookup(&mut self) -> Result<String> {
+ let current_head =
+ sync::get_head_tuple(self.repo_path.as_str())?;
+
+ if let Some((last_head, branch_name)) =
+ self.last_result.as_ref()
+ {
+ if *last_head == current_head {
+ return Ok(branch_name.clone());
+ }
+ }
+
+ self.fetch(current_head)
+ }
+
+ ///
+ pub fn last(&self) -> Option<String> {
+ self.last_result.as_ref().map(|last| last.1.clone())
+ }
+
+ fn fetch(&mut self, head: Head) -> Result<String> {
+ let name = sync::get_branch_name(self.repo_path.as_str())?;
+ self.last_result = Some((head, name.clone()));
+ Ok(name)
+ }
+}
diff --git a/asyncgit/src/cached/mod.rs b/asyncgit/src/cached/mod.rs
new file mode 100644
index 0000000..ea16edf
--- /dev/null
+++ b/asyncgit/src/cached/mod.rs
@@ -0,0 +1,7 @@
+//! cached lookups:
+//! parts of the sync api that might take longer
+//! to compute but change seldom so doing them async might be overkill
+
+mod branchname;
+
+pub use branchname::BranchName;
diff --git a/asyncgit/src/commit_files.rs b/asyncgit/src/commit_files.rs
new file mode 100644
index 0000000..265b38e
--- /dev/null
+++ b/asyncgit/src/commit_files.rs
@@ -0,0 +1,108 @@
+use crate::{
+ error::Result,
+ sync::{self, CommitId},
+ AsyncNotification, StatusItem, CWD,
+};
+use crossbeam_channel::Sender;
+use std::sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc, Mutex,
+};
+
+type ResultType = Vec<StatusItem>;
+struct Request<R, A>(R, A);
+
+///
+pub struct AsyncCommitFiles {
+ current: Arc<Mutex<Option<Request<CommitId, ResultType>>>>,
+ sender: Sender<AsyncNotification>,
+ pending: Arc<AtomicUsize>,
+}
+
+impl AsyncCommitFiles {
+ ///
+ pub fn new(sender: &Sender<AsyncNotification>) -> Self {
+ Self {
+ current: Arc::new(Mutex::new(None)),
+ sender: sender.clone(),
+ pending: Arc::new(AtomicUsize::new(0)),
+ }
+ }
+
+ ///
+ pub fn current(
+ &mut self,
+ ) -> Result<Option<(CommitId, ResultType)>> {
+ let c = self.current.lock()?;
+
+ if let Some(c) = c.as_ref() {
+ Ok(Some((c.0, c.1.clone())))
+ } else {
+ Ok(None)
+ }
+ }
+
+ ///
+ pub fn is_pending(&self) -> bool {
+ self.pending.load(Ordering::Relaxed) > 0
+ }
+
+ ///
+ pub fn fetch(&mut self, id: CommitId) -> Result<()> {
+ if self.is_pending() {
+ return Ok(());
+ }
+
+ log::trace!("request: {}", id.to_string());
+
+ {
+ let current = self.current.lock()?;
+ if let Some(c) = &*current {
+ if c.0 == id {
+ return Ok(());
+ }
+ }
+ }
+
+ let arc_current = Arc::clone(&self.current);
+ let sender = self.sender.clone();
+ let arc_pending = Arc::clone(&self.pending);
+
+ self.pending.fetch_add(1, Ordering::Relaxed);
+
+ rayon_core::spawn(move || {
+ Self::fetch_helper(id, arc_current)
+ .expect("failed to fetch");
+
+ arc_pending.fetch_sub(1, Ordering::Relaxed);
+
+ sender
+ .send(AsyncNotification::CommitFiles)
+ .expect("error sending");
+ });
+
+ Ok(())
+ }
+
+ fn fetch_helper(
+ id: CommitId,
+ arc_current: Arc<
+ Mutex<Option<Request<CommitId, ResultType>>>,
+ >,
+ ) -> Result<()> {
+ let res = sync::get_commit_files(CWD, id)?;
+
+ log::trace!(
+ "get_commit_files: {} ({})",
+ id.to_string(),
+ res.len()
+ );
+
+ {
+ let mut current = arc_current.lock()?;
+ *current = Some(Request(id, res));
+ }
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/diff.rs b/asyncgit/src/diff.rs
new file mode 100644
index 0000000..657bdf2
--- /dev/null
+++ b/asyncgit/src/diff.rs
@@ -0,0 +1,191 @@
+use crate::{
+ error::Result,
+ hash,
+ sync::{self, CommitId},
+ AsyncNotification, FileDiff, CWD,
+};
+use crossbeam_channel::Sender;
+use std::{
+ hash::Hash,
+ sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc, Mutex,
+ },
+};
+
+///
+#[derive(Hash, Clone, PartialEq)]
+pub enum DiffType {
+ /// diff in a given commit
+ Commit(CommitId),
+ /// diff against staged file
+ Stage,
+ /// diff against file in workdir
+ WorkDir,
+}
+
+///
+#[derive(Hash, Clone, PartialEq)]
+pub struct DiffParams {
+ /// path to the file to diff
+ pub path: String,
+ /// what kind of diff
+ pub diff_type: DiffType,
+}
+
+struct Request<R, A>(R, Option<A>);
+
+#[derive(Default, Clone)]
+struct LastResult<P, R> {
+ params: P,
+ hash: u64,
+ result: R,
+}
+
+///
+pub struct AsyncDiff {
+ current: Arc<Mutex<Request<u64, FileDiff>>>,
+ last: Arc<Mutex<Option<LastResult<DiffParams, FileDiff>>>>,
+ sender: Sender<AsyncNotification>,
+ pending: Arc<AtomicUsize>,
+}
+
+impl AsyncDiff {
+ ///
+ pub fn new(sender: &Sender<AsyncNotification>) -> Self {
+ Self {
+ current: Arc::new(Mutex::new(Request(0, None))),
+ last: Arc::new(Mutex::new(None)),
+ sender: sender.clone(),
+ pending: Arc::new(AtomicUsize::new(0)),
+ }
+ }
+
+ ///
+ pub fn last(&mut self) -> Result<Option<(DiffParams, FileDiff)>> {
+ let last = self.last.lock()?;
+
+ Ok(match last.clone() {
+ Some(res) => Some((res.params, res.result)),
+ None => None,
+ })
+ }
+
+ ///
+ pub fn refresh(&mut self) -> Result<()> {
+ if let Ok(Some(param)) = self.get_last_param() {
+ self.clear_current()?;
+ self.request(param)?;
+ }
+ Ok(())
+ }
+
+ ///
+ pub fn is_pending(&self) -> bool {
+ self.pending.load(Ordering::Relaxed) > 0
+ }
+
+ ///
+ pub fn request(
+ &mut self,
+ params: DiffParams,
+ ) -> Result<Option<FileDiff>> {
+ log::trace!("request");
+
+ let hash = hash(&params);
+
+ {
+ let mut current = self.current.lock()?;
+
+ if current.0 == hash {
+ return Ok(current.1.clone());
+ }
+
+ current.0 = hash;
+ current.1 = None;
+ }
+
+ let arc_current = Arc::clone(&self.current);
+ let arc_last = Arc::clone(&self.last);
+ let sender = self.sender.clone();
+ let arc_pending = Arc::clone(&self.pending);
+
+ self.pending.fetch_add(1, Ordering::Relaxed);
+
+ rayon_core::spawn(move || {
+ let notify = AsyncDiff::get_diff_helper(
+ params,
+ arc_last,
+ arc_current,
+ hash,
+ )
+ .expect("error getting diff");
+
+ arc_pending.fetch_sub(1, Ordering::Relaxed);
+
+ sender
+ .send(if notify {
+ AsyncNotification::Diff
+ } else {
+ AsyncNotification::FinishUnchanged
+ })
+ .expect("error sending diff");
+ });
+
+ Ok(None)
+ }
+
+ fn get_diff_helper(
+ params: DiffParams,
+ arc_last: Arc<
+ Mutex<Option<LastResult<DiffParams, FileDiff>>>,
+ >,
+ arc_current: Arc<Mutex<Request<u64, FileDiff>>>,
+ hash: u64,
+ ) -> Result<bool> {
+ let res = match params.diff_type {
+ DiffType::Stage => {
+ sync::diff::get_diff(CWD, params.path.clone(), true)?
+ }
+ DiffType::WorkDir => {
+ sync::diff::get_diff(CWD, params.path.clone(), false)?
+ }
+ DiffType::Commit(id) => sync::diff::get_diff_commit(
+ CWD,
+ id,
+ params.path.clone(),
+ )?,
+ };
+
+ let mut notify = false;
+ {
+ let mut current = arc_current.lock()?;
+ if current.0 == hash {
+ current.1 = Some(res.clone());
+ notify = true;
+ }
+ }
+
+ {
+ let mut last = arc_last.lock()?;
+ *last = Some(LastResult {
+ result: res,
+ hash,
+ params,
+ });
+ }
+
+ Ok(notify)
+ }
+
+ fn get_last_param(&self) -> Result<Option<DiffParams>> {
+ Ok(self.last.lock()?.clone().map(|e| e.params))
+ }
+
+ fn clear_current(&mut self) -> Result<()> {
+ let mut current = self.current.lock()?;
+ current.0 = 0;
+ current.1 = None;
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs
new file mode 100644
index 0000000..989a241
--- /dev/null
+++ b/asyncgit/src/error.rs
@@ -0,0 +1,34 @@
+use std::string::FromUtf8Error;
+use thiserror::Error;
+
+#[derive(Error, Debug)]
+pub enum Error {
+ #[error("`{0}`")]
+ Generic(String),
+
+ #[error("git: no head found")]
+ NoHead,
+
+ #[error("git: remote url not found")]
+ UnknownRemote,
+
+ #[error("git: work dir error")]
+ NoWorkDir,
+
+ #[error("io error:{0}")]
+ Io(#[from] std::io::Error),
+
+ #[error("git error:{0}")]
+ Git(#[from] git2::Error),
+
+ #[error("utf8 error:{0}")]
+ Utf8Error(#[from] FromUtf8Error),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+impl<T> From<std::sync::PoisonError<T>> for Error {
+ fn from(error: std::sync::PoisonError<T>) -> Self {
+ Error::Generic(format!("poison error: {}", error))
+ }
+}
diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs
new file mode 100644
index 0000000..3ca6d2a
--- /dev/null
+++ b/asyncgit/src/lib.rs
@@ -0,0 +1,67 @@
+//! asyncgit
+
+#![forbid(unsafe_code)]
+#![forbid(missing_docs)]
+#![deny(unused_imports)]
+#![deny(clippy::all)]
+#![deny(clippy::unwrap_used)]
+#![deny(clippy::panic)]
+#![deny(clippy::perf)]
+//TODO: get this in someday since expect still leads us to crashes sometimes
+// #![deny(clippy::expect_used)]
+
+pub mod cached;
+mod commit_files;
+mod diff;
+mod error;
+mod push;
+mod revlog;
+mod status;
+pub mod sync;
+mod tags;
+
+pub use crate::{
+ commit_files::AsyncCommitFiles,
+ diff::{AsyncDiff, DiffParams, DiffType},
+ push::{AsyncPush, PushProgress, PushProgressState, PushRequest},
+ revlog::{AsyncLog, FetchStatus},
+ status::{AsyncStatus, StatusParams},
+ sync::{
+ diff::{DiffLine, DiffLineType, FileDiff},
+ status::{StatusItem, StatusItemType},
+ },
+ tags::AsyncTags,
+};
+use std::{
+ collections::hash_map::DefaultHasher,
+ hash::{Hash, Hasher},
+};
+
+/// this type is used to communicate events back through the channel
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum AsyncNotification {
+ /// this indicates that no new state was fetched but that a async process finished
+ FinishUnchanged,
+ ///
+ Status,
+ ///
+ Diff,
+ ///
+ Log,
+ ///
+ CommitFiles,
+ ///
+ Tags,
+ ///
+ Push,
+}
+
+/// current working director `./`
+pub static CWD: &str = "./";
+
+/// helper function to calculate the hash of an arbitrary type that implements the `Hash` trait
+pub fn hash<T: Hash + ?Sized>(v: &T) -> u64 {
+ let mut hasher = DefaultHasher::new();
+ v.hash(&mut hasher);
+ hasher.finish()
+}
diff --git a/asyncgit/src/push.rs b/asyncgit/src/push.rs
new file mode 100644
index 0000000..de0f8b0
--- /dev/null
+++ b/asyncgit/src/push.rs
@@ -0,0 +1,302 @@
+use crate::sync::cred::BasicAuthCredential;
+use crate::{
+ error::{Error, Result},
+ sync, AsyncNotification, CWD,
+};
+use crossbeam_channel::{unbounded, Receiver, Sender};
+use git2::PackBuilderStage;
+use std::{
+ cmp,
+ sync::{Arc, Mutex},
+ thread,
+ time::Duration,
+};
+use sync::ProgressNotification;
+use thread::JoinHandle;
+
+///
+#[derive(Clone, Debug)]
+pub enum PushProgressState {
+ ///
+ PackingAddingObject,
+ ///
+ PackingDeltafiction,
+ ///
+ Pushing,
+}
+
+///
+#[derive(Clone, Debug)]
+pub struct PushProgress {
+ ///
+ pub state: PushProgressState,
+ ///
+ pub progress: u8,
+}
+
+impl PushProgress {
+ ///
+ pub fn new(
+ state: PushProgressState,
+ current: usize,
+ total: usize,
+ ) -> Self {
+ let total = cmp::max(current, total) as f32;
+ let progress = current as f32 / total * 100.0;
+ let progress = progress as u8;
+ Self { state, progress }
+ }
+}
+
+impl From<ProgressNotification> for PushProgress {
+ fn from(progress: ProgressNotification) -> Self {
+ match progress {
+ ProgressNotification::Packing {
+ stage,
+ current,
+ total,
+ } => match stage {
+ PackBuilderStage::AddingObjects => PushProgress::new(
+ PushProgressState::PackingAddingObject,
+ current,
+ total,
+ ),
+ PackBuilderStage::Deltafication => PushProgress::new(
+ PushProgressState::PackingDeltafiction,
+ current,
+ total,
+ ),
+ },
+ ProgressNotification::PushTransfer {
+ current,
+ total,
+ ..
+ } => PushProgress::new(
+ PushProgressState::Pushing,
+ current,
+ total,
+ ),
+ //ProgressNotification::Done |
+ _ => PushProgress::new(PushProgressState::Pushing, 1, 1),
+ }
+ }
+}
+
+///
+#[derive(Default, Clone, Debug)]
+pub struct PushRequest {
+ ///
+ pub remote: String,
+ ///
+ pub branch: String,
+ ///
+ pub basic_credential: Option<BasicAuthCredential>,
+}
+
+#[derive(Default, Clone, Debug)]
+struct PushState {
+ request: PushRequest,
+}
+
+///
+pub struct AsyncPush {
+ state: Arc<Mutex<Option<PushState>>>,
+ last_result: Arc<Mutex<Option<String>>>,
+ progress: Arc<Mutex<Option<ProgressNotification>>>,
+ sender: Sender<AsyncNotification>,
+}
+
+impl AsyncPush {
+ ///
+ pub fn new(sender: &Sender<AsyncNotification>) -> Self {
+ Self {
+ state: Arc::new(Mutex::new(None)),
+ last_result: Arc::new(Mutex::new(None)),
+ progress: Arc::new(Mutex::new(None)),
+ sender: sender.clone(),
+ }
+ }
+
+ ///
+ pub fn is_pending(&self) -> Result<bool> {
+ let state = self.state.lock()?;
+ Ok(state.is_some())
+ }
+
+ ///
+ pub fn last_result(&self) -> Result<Option<String>> {
+ let res = self.last_result.lock()?;
+ Ok(res.clone())
+ }
+
+ ///
+ pub fn progress(&self) -> Result<Option<PushProgress>> {
+ let res = self.progress.lock()?;
+ Ok(res.as_ref().map(|progress| progress.clone().into()))
+ }
+
+ ///
+ pub fn request(&mut self, params: PushRequest) -> Result<()> {
+ log::trace!("request");
+
+ if self.is_pending()? {
+ return Ok(());
+ }
+
+ self.set_request(&params)?;
+ Self::set_progress(self.progress.clone(), None)?;
+
+ let arc_state = Arc::clone(&self.state);
+ let arc_res = Arc::clone(&self.last_result);
+ let arc_progress = Arc::clone(&self.progress);
+ let sender = self.sender.clone();
+
+ thread::spawn(move || {
+ let (progress_sender, receiver) = unbounded();
+
+ let handle = Self::spawn_receiver_thread(
+ sender.clone(),
+ receiver,
+ arc_progress,
+ );
+
+ let res = sync::push(
+ CWD,
+ params.remote.as_str(),
+ params.branch.as_str(),
+ params.basic_credential,
+ progress_sender.clone(),
+ );
+
+ progress_sender
+ .send(ProgressNotification::Done)
+ .expect("closing send failed");
+
+ handle.join().expect("joining thread failed");
+
+ Self::set_result(arc_res, res).expect("result error");
+
+ Self::clear_request(arc_state).expect("clear error");
+
+ sender
+ .send(AsyncNotification::Push)
+ .expect("error sending push");
+ });
+
+ Ok(())
+ }
+
+ fn spawn_receiver_thread(
+ sender: Sender<AsyncNotification>,
+ receiver: Receiver<ProgressNotification>,
+ progress: Arc<Mutex<Option<ProgressNotification>>>,
+ ) -> JoinHandle<()> {
+ log::info!("push progress receiver spawned");
+
+ thread::spawn(move || loop {
+ let incoming = receiver.recv();
+ match incoming {
+ Ok(update) => {
+ Self::set_progress(
+ progress.clone(),
+ Some(update.clone()),
+ )
+ .expect("set prgoress failed");
+ sender
+ .send(AsyncNotification::Push)
+ .expect("error sending push");
+
+ //NOTE: for better debugging
+ thread::sleep(Duration::from_millis(300));
+
+ if let ProgressNotification::Done = update {
+ break;
+ }
+ }
+ Err(e) => {
+ log::error!(
+ "push progress receiver error: {}",
+ e
+ );
+ break;
+ }
+ }
+ })
+ }
+
+ fn set_request(&self, params: &PushRequest) -> Result<()> {
+ let mut state = self.state.lock()?;
+
+ if state.is_some() {
+ return Err(Error::Generic("pending request".into()));
+ }
+
+ *state = Some(PushState {
+ request: params.clone(),
+ });
+
+ Ok(())
+ }
+
+ fn clear_request(
+ state: Arc<Mutex<Option<PushState>>>,
+ ) -> Result<()> {
+ let mut state = state.lock()?;
+
+ *state = None;
+
+ Ok(())
+ }
+
+ fn set_progress(
+ progress: Arc<Mutex<Option<ProgressNotification>>>,
+ state: Option<ProgressNotification>,
+ ) -> Result<()> {
+ let simple_progress: Option<PushProgress> =
+ state.as_ref().map(|prog| prog.clone().into());
+ log::info!("push progress: {:?}", simple_progress);
+ let mut progress = progress.lock()?;
+
+ *progress = state;
+
+ Ok(())
+ }
+
+ fn set_result(
+ arc_result: Arc<Mutex<Option<String>>>,
+ res: Result<()>,
+ ) -> Result<()> {
+ let mut last_res = arc_result.lock()?;
+
+ *last_res = match res {
+ Ok(_) => None,
+ Err(e) => {
+ log::error!("push error: {}", e);
+ Some(e.to_string())
+ }
+ };
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_progress_zero_total() {
+ let prog =
+ PushProgress::new(PushProgressState::Pushing, 1, 0);
+
+ assert_eq!(prog.progress, 100);
+ }
+
+ #[test]
+ fn test_progress_rounding() {
+ let prog =
+ PushProgress::new(PushProgressState::Pushing, 2, 10);
+
+ assert_eq!(prog.progress, 20);
+ }
+}
diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs
new file mode 100644
index 0000000..ffb1121
--- /dev/null
+++ b/asyncgit/src/revlog.rs
@@ -0,0 +1,183 @@
+use crate::{
+ error::Result,
+ sync::{utils::repo, CommitId, LogWalker},
+ AsyncNotification, CWD,
+};
+use crossbeam_channel::Sender;
+use git2::Oid;
+use scopetime::scope_time;
+use std::{
+ sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc, Mutex,
+ },
+ thread,
+ time::Duration,
+};
+
+///
+#[derive(PartialEq)]
+pub enum FetchStatus {
+ /// previous fetch still running
+ Pending,
+ /// no change expected
+ NoChange,
+ /// new walk was started
+ Started,
+}
+
+///
+pub struct AsyncLog {
+ current: Arc<Mutex<Vec<CommitId>>>,
+ sender: Sender<AsyncNotification>,
+ pending: Arc<AtomicBool>,
+ background: Arc<AtomicBool>,
+}
+
+static LIMIT_COUNT: usize = 3000;
+static SLEEP_FOREGROUND: Duration = Duration::from_millis(2);
+static SLEEP_BACKGROUND: Duration = Duration::from_millis(1000);
+
+impl AsyncLog {
+ ///
+ pub fn new(sender: &Sender<AsyncNotification>) -> Self {
+ Self {
+ current: Arc::new(Mutex::new(Vec::new())),
+ sender: sender.clone(),
+ pending: Arc::new(AtomicBool::new(false)),
+ background: Arc::new(AtomicBool::new(false)),
+ }
+ }
+
+ ///
+ pub fn count(&mut self) -> Result<usize> {
+ Ok(self.current.lock()?.len())
+ }
+
+ ///
+ pub fn get_slice(
+ &self,
+ start_index: usize,
+ amount: usize,
+ ) -> Result<Vec<CommitId>> {
+ let list = self.current.lock()?;
+ let list_len = list.len();
+ let min = start_index.min(list_len);
+ let max = min + amount;
+ let max = max.min(list_len);
+ Ok(list[min..max].to_vec())
+ }
+
+ ///
+ pub fn is_pending(&self) -> bool {
+ self.pending.load(Ordering::Relaxed)
+ }
+
+ ///
+ pub fn set_background(&mut self) {
+ self.background.store(true, Ordering::Relaxed)
+ }
+
+ ///
+ fn current_head(&self) -> Result<CommitId> {
+ Ok(self
+ .current
+ .lock()?
+ .first()
+ .map_or(Oid::zero().into(), |f| *f))
+ }
+
+ ///
+ fn head_changed(&self) -> Result<bool> {
+ if let Ok(head) = repo(CWD)?.head() {
+ if let Some(head) = head.target() {
+ return Ok(head != self.current_head()?.into());
+ }
+ }
+ Ok(false)
+ }
+
+ ///
+ pub fn fetch(&mut self) -> Result<FetchStatus> {
+ self.background.store(false, Ordering::Relaxed);
+
+ if self.is_pending() {
+ return Ok(FetchStatus::Pending);
+ }
+
+ if !self.head_changed()? {
+ return Ok(FetchStatus::NoChange);
+ }
+
+ self.clear()?;
+
+ let arc_current = Arc::clone(&self.current);
+ let sender = self.sender.clone();
+ let arc_pending = Arc::clone(&self.pending);
+ let arc_background = Arc::clone(&self.background);
+
+ self.pending.store(true, Ordering::Relaxed);
+
+ rayon_core::spawn(move || {
+ scope_time!("async::revlog");
+
+ AsyncLog::fetch_helper(
+ arc_current,
+ arc_background,
+ &sender,
+ )
+ .expect("failed to fetch");
+
+ arc_pending.store(false, Ordering::Relaxed);
+
+ Self::notify(&sender);
+ });
+
+ Ok(FetchStatus::Started)
+ }
+
+ fn fetch_helper(
+ arc_current: Arc<Mutex<Vec<CommitId>>>,
+ arc_background: Arc<AtomicBool>,
+ sender: &Sender<AsyncNotification>,
+ ) -> Result<()> {
+ let mut entries = Vec::with_capacity(LIMIT_COUNT);
+ let r = repo(CWD)?;
+ let mut walker = LogWalker::new(&r);
+ loop {
+ entries.clear();
+ let res_is_err =
+ walker.read(&mut entries, LIMIT_COUNT).is_err();
+
+ if !res_is_err {
+ let mut current = arc_current.lock()?;
+ current.extend(entries.iter());
+ }
+
+ if res_is_err || entries.len() <= 1 {
+ break;
+ } else {
+ Self::notify(&sender);
+
+ let sleep_duration =
+ if arc_background.load(Ordering::Relaxed) {
+ SLEEP_BACKGROUND
+ } else {
+ SLEEP_FOREGROUND
+ };
+ thread::sleep(sleep_duration);
+ }
+ }
+
+ Ok(())
+ }
+
+ fn clear(&mut self) -> Result<()> {
+ self.current.lock()?.clear();
+ Ok(())
+ }
+
+ fn notify(sender: &Sender<AsyncNotification>) {
+ sender.send(AsyncNotification::Log).expect("error sending");
+ }
+}
diff --git a/asyncgit/src/status.rs b/asyncgit/src/status.rs
new file mode 100644
index 0000000..be37d35
--- /dev/null
+++ b/asyncgit/src/status.rs
@@ -0,0 +1,186 @@
+use crate::{
+ error::Result,
+ hash,
+ sync::{self, status::StatusType},
+ AsyncNotification, StatusItem, CWD,
+};
+use crossbeam_channel::Sender;
+use std::{
+ hash::Hash,
+ sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc, Mutex,
+ },
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+fn current_tick() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("time before unix epoch!")
+ .as_millis() as u64
+}
+
+#[derive(Default, Hash, Clone)]
+pub struct Status {
+ pub items: Vec<StatusItem>,
+}
+
+///
+#[derive(Default, Hash, Clone, PartialEq)]
+pub struct StatusParams {
+ tick: u64,
+ status_type: StatusType,
+ include_untracked: bool,
+}
+
+impl StatusParams {
+ ///
+ pub fn new(
+ status_type: StatusType,
+ include_untracked: bool,
+ ) -> Self {
+ Self {
+ tick: current_tick(),
+ status_type,
+ include_untracked,
+ }
+ }
+}
+
+struct Request<R, A>(R, Option<A>);
+
+///
+pub struct AsyncStatus {
+ current: Arc<Mutex<Request<u64, Status>>>,
+ last: Arc<Mutex<Status>>,
+ sender: Sender<AsyncNotification>,
+ pending: Arc<AtomicUsize>,
+}
+
+impl AsyncStatus {
+ ///
+ pub fn new(sender: Sender<AsyncNotification>) -> Self {
+ Self {
+ current: Arc::new(Mutex::new(Request(0, None))),
+ last: Arc::new(Mutex::new(Status::default())),
+ sender,
+ pending: Arc::new(AtomicUsize::new(0)),
+ }
+ }
+
+ ///
+ pub fn last(&mut self) -> Result<Status> {
+ let last = self.last.lock()?;
+ Ok(last.clone())
+ }
+
+ ///
+ pub fn is_pending(&self) -> bool {
+ self.pending.load(Ordering::Relaxed) > 0
+ }
+
+ ///
+ pub fn fetch(
+ &mut self,
+ params: StatusParams,
+ ) -> Result<Option<Status>> {
+ if self.is_pending() {
+ log::trace!("request blocked, still pending");
+ return Ok(None);
+ }
+
+ let hash_request = hash(&params);
+
+ log::trace!(
+ "request: [hash: {}] (type: {:?}, untracked: {})",
+ hash_request,
+ params.status_type,
+ params.include_untracked,
+ );
+
+ {
+ let mut current = self.current.lock()?;
+
+ if current.0 == hash_request {
+ return Ok(current.1.clone());
+ }
+
+ current.0 = hash_request;
+ current.1 = None;
+ }
+
+ let arc_current = Arc::clone(&self.current);
+ let arc_last = Arc::clone(&self.last);
+ let sender = self.sender.clone();
+ let arc_pending = Arc::clone(&self.pending);
+ let status_type = params.status_type;
+ let include_untracked = params.include_untracked;
+
+ self.pending.fetch_add(1, Ordering::Relaxed);
+
+ rayon_core::spawn(move || {
+ let ok = Self::fetch_helper(
+ status_type,
+ include_untracked,
+ hash_request,
+ arc_current,
+ arc_last,
+ )
+ .is_ok();
+
+ arc_pending.fetch_sub(1, Ordering::Relaxed);
+
+ if ok {
+ sender
+ .send(AsyncNotification::Status)
+ .expect("error sending status");
+ }
+ });
+
+ Ok(None)
+ }
+
+ fn fetch_helper(
+ status_type: StatusType,
+ include_untracked: bool,
+ hash_request: u64,
+ arc_current: Arc<Mutex<Request<u64, Status>>>,
+ arc_last: Arc<Mutex<Status>>,
+ ) -> Result<()> {
+ let res = Self::get_status(status_type, include_untracked)?;
+ log::trace!(
+ "status fetched: {} (type: {:?}, untracked: {})",
+ hash_request,
+ status_type,
+ include_untracked
+ );
+
+ {
+ let mut current = arc_current.lock()?;
+ if current.0 == hash_request {
+ current.1 = Some(res.clone());
+ }
+ }
+
+ {
+ let mut last = arc_last.lock()?;
+ *last = res;
+ }
+
+ Ok(())
+ }
+
+ fn get_status(
+ status_type: StatusType,
+ include_untracked: bool,
+ ) -> Result<Status> {
+ Ok(Status {
+ items: sync::status::get_status(
+ CWD,
+ status_type,
+ include_untracked,
+ )?,
+ })
+ }
+}
diff --git a/asyncgit/src/sync/branch.rs b/asyncgit/src/sync/branch.rs
new file mode 100644
index 0000000..871958b
--- /dev/null
+++ b/asyncgit/src/sync/branch.rs
@@ -0,0 +1,454 @@
+//!
+
+use crate::{
+ error::{Error, Result},
+ sync::{utils, CommitId},
+};
+use git2::{BranchType, Repository};
+use scopetime::scope_time;
+use utils::get_head_repo;
+
+use super::utils::bytes2string;
+
+/// returns the branch-name head is currently pointing to
+/// this might be expensive, see `cached::BranchName`
+pub(crate) fn get_branch_name(repo_path: &str) -> Result<String> {
+ scope_time!("get_branch_name");
+
+ let repo = utils::repo(repo_path)?;
+
+ let iter = repo.branches(None)?;
+
+ for b in iter {
+ let b = b?;
+
+ if b.0.is_head() {
+ let name = b.0.name()?.unwrap_or("");
+ return Ok(name.into());
+ }
+ }
+
+ Err(Error::NoHead)
+}
+
+///
+pub struct BranchForDisplay {
+ ///
+ pub name: String,
+ ///
+ pub reference: String,
+ ///
+ pub top_commit_message: String,
+ ///
+ pub top_commit: CommitId,
+ ///
+ pub is_head: bool,
+ ///
+ pub has_upstream: bool,
+}
+
+/// Used to return only the nessessary information for displaying a branch
+/// rather than an iterator over the actual branches
+pub fn get_branches_to_display(
+ repo_path: &str,
+) -> Result<Vec<BranchForDisplay>> {
+ scope_time!("get_branches_to_display");
+
+ let cur_repo = utils::repo(repo_path)?;
+ let branches_for_display = cur_repo
+ .branches(Some(BranchType::Local))?
+ .map(|b| {
+ let branch = b?.0;
+ let top_commit = branch.get().peel_to_commit()?;
+
+ Ok(BranchForDisplay {
+ name: bytes2string(branch.name_bytes()?)?,
+ reference: bytes2string(branch.get().name_bytes())?,
+ top_commit_message: bytes2string(
+ top_commit.summary_bytes().unwrap_or_default(),
+ )?,
+ top_commit: top_commit.id().into(),
+ is_head: branch.is_head(),
+ has_upstream: branch.upstream().is_ok(),
+ })
+ })
+ .filter_map(Result::ok)
+ .collect();
+
+ Ok(branches_for_display)
+}
+
+///
+#[derive(Debug, Default)]
+pub struct BranchCompare {
+ ///
+ pub ahead: usize,
+ ///
+ pub behind: usize,
+}
+
+///
+pub(crate) fn branch_set_upstream(
+ repo: &Repository,
+ branch_name: &str,
+) -> Result<()> {
+ scope_time!("branch_set_upstream");
+
+ let mut branch =
+ repo.find_branch(branch_name, BranchType::Local)?;
+
+ if branch.upstream().is_err() {
+ //TODO: what about other remote names
+ let upstream_name = format!("origin/{}", branch_name);
+ branch.set_upstream(Some(upstream_name.as_str()))?;
+ }
+
+ Ok(())
+}
+
+///
+pub fn branch_compare_upstream(
+ repo_path: &str,
+ branch: &str,
+) -> Result<BranchCompare> {
+ scope_time!("branch_compare_upstream");
+
+ let repo = utils::repo(repo_path)?;
+
+ let branch = repo.find_branch(branch, BranchType::Local)?;
+
+ let upstream = branch.upstream()?;
+
+ let branch_commit =
+ branch.into_reference().peel_to_commit()?.id();
+
+ let upstream_commit =
+ upstream.into_reference().peel_to_commit()?.id();
+
+ let (ahead, behind) =
+ repo.graph_ahead_behind(branch_commit, upstream_commit)?;
+
+ Ok(BranchCompare { ahead, behind })
+}
+
+/// Modify HEAD to point to a branch then checkout head, does not work if there are uncommitted changes
+pub fn checkout_branch(
+ repo_path: &str,
+ branch_ref: &str,
+) -> Result<()> {
+ scope_time!("checkout_branch");
+
+ // This defaults to a safe checkout, so don't delete anything that
+ // hasn't been committed or stashed, in this case it will Err
+ let repo = utils::repo(repo_path)?;
+ let cur_ref = repo.head()?;
+ let statuses = repo.statuses(Some(
+ git2::StatusOptions::new().include_ignored(false),
+ ))?;
+
+ if statuses.is_empty() {
+ repo.set_head(branch_ref)?;
+
+ if let Err(e) = repo.checkout_head(Some(
+ git2::build::CheckoutBuilder::new().force(),
+ )) {
+ // This is safe beacuse cur_ref was just found
+ repo.set_head(
+ bytes2string(cur_ref.name_bytes())?.as_str(),
+ )?;
+ return Err(Error::Git(e));
+ }
+ Ok(())
+ } else {
+ Err(Error::Generic(
+ format!("Cannot change branch. There are unstaged/staged changes which have not been committed/stashed. There is {:?} changes preventing checking out a different branch.", statuses.len()),
+ ))
+ }
+}
+
+/// The user must not be on the branch for the branch to be deleted
+pub fn delete_branch(
+ repo_path: &str,
+ branch_ref: &str,
+) -> Result<()> {
+ scope_time!("delete_branch");
+
+ let repo = utils::repo(repo_path)?;
+ let branch_as_ref = repo.find_reference(branch_ref)?;
+ let mut branch = git2::Branch::wrap(branch_as_ref);
+ if !branch.is_head() {
+ branch.delete()?;
+ } else {
+ return Err(Error::Generic("You cannot be on the branch you want to delete, switch branch, then delete this branch".to_string()));
+ }
+ Ok(())
+}
+
+/// Rename the branch reference
+pub fn rename_branch(
+ repo_path: &str,
+ branch_ref: &str,
+ new_name: &str,
+) -> Result<()> {
+ scope_time!("delete_branch");
+
+ let repo = utils::repo(repo_path)?;
+ let branch_as_ref = repo.find_reference(branch_ref)?;
+ let mut branch = git2::Branch::wrap(branch_as_ref);
+ branch.rename(new_name, true)?;
+
+ Ok(())
+}
+
+/// creates a new branch pointing to current HEAD commit and updating HEAD to new branch
+pub fn create_branch(repo_path: &str, name: &str) -> Result<()> {
+ scope_time!("create_branch");
+
+ let repo = utils::repo(repo_path)?;
+
+ let head_id = get_head_repo(&repo)?;
+ let head_commit = repo.find_commit(head_id.into())?;
+
+ let branch = repo.branch(name, &head_commit, false)?;
+ let branch_ref = branch.into_reference();
+ let branch_ref_name = bytes2string(branch_ref.name_bytes())?;
+ repo.set_head(branch_ref_name.as_str())?;
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests_branch_name {
+ use super::*;
+ use crate::sync::tests::{repo_init, repo_init_empty};
+
+ #[test]
+ fn test_smoke() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(
+ get_branch_name(repo_path).unwrap().as_str(),
+ "master"
+ );
+ }
+
+ #[test]
+ fn test_empty_repo() {
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert!(matches!(
+ get_branch_name(repo_path),
+ Err(Error::NoHead)
+ ));
+ }
+}
+
+#[cfg(test)]
+mod tests_create_branch {
+ use super::*;
+ use crate::sync::tests::repo_init;
+
+ #[test]
+ fn test_smoke() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ create_branch(repo_path, "branch1").unwrap();
+
+ assert_eq!(
+ get_branch_name(repo_path).unwrap().as_str(),
+ "branch1"
+ );
+ }
+}
+
+#[cfg(test)]
+mod tests_branch_compare {
+ use super::*;
+ use crate::sync::tests::repo_init;
+
+ #[test]
+ fn test_smoke() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ create_branch(repo_path, "test").unwrap();
+
+ let res = branch_compare_upstream(repo_path, "test");
+
+ assert_eq!(res.is_err(), true);
+ }
+}
+
+#[cfg(test)]
+mod tests_branches {
+ use super::*;
+ use crate::sync::tests::repo_init;
+
+ #[test]
+ fn test_smoke() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(
+ get_branches_to_display(repo_path)
+ .unwrap()
+ .iter()
+ .map(|b| b.name.clone())
+ .collect::<Vec<_>>(),
+ vec!["master"]
+ );
+ }
+
+ #[test]
+ fn test_multiple() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ create_branch(repo_path, "test").unwrap();
+
+ assert_eq!(
+ get_branches_to_display(repo_path)
+ .unwrap()
+ .iter()
+ .map(|b| b.name.clone())
+ .collect::<Vec<_>>(),
+ vec!["master", "test"]
+ );
+ }
+}
+
+#[cfg(test)]
+mod tests_checkout {
+ use super::*;
+ use crate::sync::tests::repo_init;
+
+ #[test]
+ fn test_smoke() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert!(
+ checkout_branch(repo_path, "refs/heads/master").is_ok()
+ );
+ assert!(
+ checkout_branch(repo_path, "refs/heads/foobar").is_err()
+ );
+ }
+
+ #[test]
+ fn test_multiple() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ create_branch(repo_path, "test").unwrap();
+
+ assert!(checkout_branch(repo_path, "refs/heads/test").is_ok());
+ assert!(
+ checkout_branch(repo_path, "refs/heads/master").is_ok()
+ );
+ assert!(checkout_branch(repo_path, "refs/heads/test").is_ok());
+ }
+}
+
+#[cfg(test)]
+mod test_delete_branch {
+ use super::*;
+ use crate::sync::tests::repo_init;
+
+ #[test]
+ fn test_delete_branch() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ create_branch(repo_path, "branch1").unwrap();
+ create_branch(repo_path, "branch2").unwrap();
+
+ checkout_branch(repo_path, "refs/heads/branch1").unwrap();
+
+ assert_eq!(
+ repo.branches(None)
+ .unwrap()
+ .nth(1)
+ .unwrap()
+ .unwrap()
+ .0
+ .name()
+ .unwrap()
+ .unwrap(),
+ "branch2"
+ );
+
+ delete_branch(repo_path, "refs/heads/branch2").unwrap();
+
+ assert_eq!(
+ repo.branches(None)
+ .unwrap()
+ .nth(1)
+ .unwrap()
+ .unwrap()
+ .0
+ .name()
+ .unwrap()
+ .unwrap(),
+ "master"
+ );
+ }
+}
+
+#[cfg(test)]
+mod test_rename_branch {
+ use super::*;
+ use crate::sync::tests::repo_init;
+
+ #[test]
+ fn test_rename_branch() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ create_branch(repo_path, "branch1").unwrap();
+
+ checkout_branch(repo_path, "refs/heads/branch1").unwrap();
+
+ assert_eq!(
+ repo.branches(None)
+ .unwrap()
+ .nth(0)
+ .unwrap()
+ .unwrap()
+ .0
+ .name()
+ .unwrap()
+ .unwrap(),
+ "branch1"
+ );
+
+ rename_branch(repo_path, "refs/heads/branch1", "AnotherName")
+ .unwrap();
+
+ assert_eq!(
+ repo.branches(None)
+ .unwrap()
+ .nth(0)
+ .unwrap()
+ .unwrap()
+ .0
+ .name()
+ .unwrap()
+ .unwrap(),
+ "AnotherName"
+ );
+ }
+}
diff --git a/asyncgit/src/sync/commit.rs b/asyncgit/src/sync/commit.rs
new file mode 100644
index 0000000..de96c8b
--- /dev/null
+++ b/asyncgit/src/sync/commit.rs
@@ -0,0 +1,248 @@
+use super::{get_head, utils::repo, CommitId};
+use crate::error::Result;
+use git2::{ErrorCode, ObjectType, Repository, Signature};
+use scopetime::scope_time;
+
+///
+pub fn amend(
+ repo_path: &str,
+ id: CommitId,
+ msg: &str,
+) -> Result<CommitId> {
+ scope_time!("amend");
+
+ let repo = repo(repo_path)?;
+ let commit = repo.find_commit(id.into())?;
+
+ let mut index = repo.index()?;
+ let tree_id = index.write_tree()?;
+ let tree = repo.find_tree(tree_id)?;
+
+ let new_id = commit.amend(
+ Some("HEAD"),
+ None,
+ None,
+ None,
+ Some(msg),
+ Some(&tree),
+ )?;
+
+ Ok(CommitId::new(new_id))
+}
+
+/// Wrap Repository::signature to allow unknown user.name.
+///
+/// See <https://github.com/extrawurst/gitui/issues/79>.
+fn signature_allow_undefined_name(
+ repo: &Repository,
+) -> std::result::Result<Signature<'_>, git2::Error> {
+ match repo.signature() {
+ Err(e) if e.code() == ErrorCode::NotFound => {
+ let config = repo.config()?;
+ Signature::now(
+ config.get_str("user.name").unwrap_or("unknown"),
+ config.get_str("user.email")?,
+ )
+ }
+
+ v => v,
+ }
+}
+
+/// this does not run any git hooks
+pub fn commit(repo_path: &str, msg: &str) -> Result<CommitId> {
+ scope_time!("commit");
+
+ let repo = repo(repo_path)?;
+
+ let signature = signature_allow_undefined_name(&repo)?;
+ let mut index = repo.index()?;
+ let tree_id = index.write_tree()?;
+ let tree = repo.find_tree(tree_id)?;
+
+ let parents = if let Ok(id) = get_head(repo_path) {
+ vec![repo.find_commit(id.into())?]
+ } else {
+ Vec::new()
+ };
+
+ let parents = parents.iter().collect::<Vec<_>>();
+
+ Ok(repo
+ .commit(
+ Some("HEAD"),
+ &signature,
+ &signature,
+ msg,
+ &tree,
+ parents.as_slice(),
+ )?
+ .into())
+}
+
+/// Tag a commit.
+///
+/// This function will return an `Err(…)` variant if the tag’s name is refused
+/// by git or if the tag already exists.
+pub fn tag(
+ repo_path: &str,
+ commit_id: &CommitId,
+ tag: &str,
+) -> Result<CommitId> {
+ scope_time!("tag");
+
+ let repo = repo(repo_path)?;
+
+ let signature = signature_allow_undefined_name(&repo)?;
+ let object_id = commit_id.get_oid();
+ let target =
+ repo.find_object(object_id, Some(ObjectType::Commit))?;
+
+ Ok(repo.tag(tag, &target, &signature, "", false)?.into())
+}
+
+#[cfg(test)]
+mod tests {
+
+ use crate::error::Result;
+ use crate::sync::{
+ commit, get_commit_details, get_commit_files, stage_add_file,
+ tags::get_tags,
+ tests::{get_statuses, repo_init, repo_init_empty},
+ utils::get_head,
+ LogWalker,
+ };
+ use commit::{amend, tag};
+ use git2::Repository;
+ use std::{fs::File, io::Write, path::Path};
+
+ fn count_commits(repo: &Repository, max: usize) -> usize {
+ let mut items = Vec::new();
+ let mut walk = LogWalker::new(&repo);
+ walk.read(&mut items, max).unwrap();
+ items.len()
+ }
+
+ #[test]
+ fn test_commit() {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 1));
+
+ commit(repo_path, "commit msg").unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+ }
+
+ #[test]
+ fn test_commit_in_empty_repo() {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+
+ File::create(&root.join(file_path))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 1));
+
+ commit(repo_path, "commit msg").unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+ }
+
+ #[test]
+ fn test_amend() -> Result<()> {
+ let file_path1 = Path::new("foo");
+ let file_path2 = Path::new("foo2");
+ let (_td, repo) = repo_init_empty()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path1))?.write_all(b"test1")?;
+
+ stage_add_file(repo_path, file_path1)?;
+ let id = commit(repo_path, "commit msg")?;
+
+ assert_eq!(count_commits(&repo, 10), 1);
+
+ File::create(&root.join(file_path2))?.write_all(b"test2")?;
+
+ stage_add_file(repo_path, file_path2)?;
+
+ let new_id = amend(repo_path, id, "amended")?;
+
+ assert_eq!(count_commits(&repo, 10), 1);
+
+ let details = get_commit_details(repo_path, new_id)?;
+ assert_eq!(details.message.unwrap().subject, "amended");
+
+ let files = get_commit_files(repo_path, new_id)?;
+
+ assert_eq!(files.len(), 2);
+
+ let head = get_head(repo_path)?;
+
+ assert_eq!(head, new_id);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_tag() -> Result<()> {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?
+ .write_all(b"test\nfoo")?;
+
+ stage_add_file(repo_path, file_path)?;
+
+ let new_id = commit(repo_path, "commit msg")?;
+
+ tag(repo_path, &new_id, "tag")?;
+
+ assert_eq!(
+ get_tags(repo_path).unwrap()[&new_id],
+ vec!["tag"]
+ );
+
+ assert!(matches!(tag(repo_path, &new_id, "tag"), Err(_)));
+
+ assert_eq!(
+ get_tags(repo_path).unwrap()[&new_id],
+ vec!["tag"]
+ );
+
+ tag(repo_path, &new_id, "second-tag")?;
+
+ assert_eq!(
+ get_tags(repo_path).unwrap()[&new_id],
+ vec!["second-tag", "tag"]
+ );
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/sync/commit_details.rs b/asyncgit/src/sync/commit_details.rs
new file mode 100644
index 0000000..28a851d
--- /dev/null
+++ b/asyncgit/src/sync/commit_details.rs
@@ -0,0 +1,161 @@
+use super::{commits_info::get_message, utils::repo, CommitId};
+use crate::error::Result;
+use git2::Signature;
+use scopetime::scope_time;
+
+///
+#[derive(Debug, PartialEq)]
+pub struct CommitSignature {
+ ///
+ pub name: String,
+ ///
+ pub email: String,
+ /// time in secs since Unix epoch
+ pub time: i64,
+}
+
+impl CommitSignature {
+ /// convert from git2-rs `Signature`
+ pub fn from(s: Signature<'_>) -> Self {
+ Self {
+ name: s.name().unwrap_or("").to_string(),
+ email: s.email().unwrap_or("").to_string(),
+
+ time: s.when().seconds(),
+ }
+ }
+}
+
+///
+pub struct CommitMessage {
+ /// first line
+ pub subject: String,
+ /// remaining lines if more than one
+ pub body: Option<String>,
+}
+
+impl CommitMessage {
+ ///
+ pub fn from(s: &str) -> Self {
+ let mut lines = s.lines();
+ let subject = if let Some(subject) = lines.next() {
+ subject.to_string()
+ } else {
+ String::new()
+ };
+
+ let body: Vec<String> =
+ lines.map(|line| line.to_string()).collect();
+
+ Self {
+ subject,
+ body: if body.is_empty() {
+ None
+ } else {
+ Some(body.join("\n"))
+ },
+ }
+ }
+
+ ///
+ pub fn combine(self) -> String {
+ if let Some(body) = self.body {
+ format!("{}{}", self.subject, body)
+ } else {
+ self.subject
+ }
+ }
+}
+
+///
+pub struct CommitDetails {
+ ///
+ pub author: CommitSignature,
+ /// committer when differs to `author` otherwise None
+ pub committer: Option<CommitSignature>,
+ ///
+ pub message: Option<CommitMessage>,
+ ///
+ pub hash: String,
+}
+
+///
+pub fn get_commit_details(
+ repo_path: &str,
+ id: CommitId,
+) -> Result<CommitDetails> {
+ scope_time!("get_commit_details");
+
+ let repo = repo(repo_path)?;
+
+ let commit = repo.find_commit(id.into())?;
+
+ let author = CommitSignature::from(commit.author());
+ let committer = CommitSignature::from(commit.committer());
+ let committer = if author == committer {
+ None
+ } else {
+ Some(committer)
+ };
+
+ let msg =
+ CommitMessage::from(get_message(&commit, None).as_str());
+
+ let details = CommitDetails {
+ author,
+ committer,
+ message: Some(msg),
+ hash: id.to_string(),
+ };
+
+ Ok(details)
+}
+
+#[cfg(test)]
+mod tests {
+
+ use super::{get_commit_details, CommitMessage};
+ use crate::error::Result;
+ use crate::sync::{
+ commit, stage_add_file, tests::repo_init_empty,
+ };
+ use std::{fs::File, io::Write, path::Path};
+
+ #[test]
+ fn test_msg_invalid_utf8() -> Result<()> {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"a")?;
+ stage_add_file(repo_path, file_path).unwrap();
+
+ let msg = invalidstring::invalid_utf8("test msg");
+ let id = commit(repo_path, msg.as_str()).unwrap();
+
+ let res = get_commit_details(repo_path, id).unwrap();
+
+ dbg!(&res.message.as_ref().unwrap().subject);
+ assert_eq!(
+ res.message
+ .as_ref()
+ .unwrap()
+ .subject
+ .starts_with("test msg"),
+ true
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_msg_linefeeds() -> Result<()> {
+ let msg = CommitMessage::from("foo\nbar\r\ntest");
+
+ assert_eq!(msg.subject, String::from("foo"),);
+ assert_eq!(msg.body, Some(String::from("bar\ntest")),);
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/sync/commit_files.rs b/asyncgit/src/sync/commit_files.rs
new file mode 100644
index 0000000..f988a3d
--- /dev/null
+++ b/asyncgit/src/sync/commit_files.rs
@@ -0,0 +1,174 @@
+use super::{stash::is_stash_commit, utils::repo, CommitId};
+use crate::{
+ error::Error, error::Result, StatusItem, StatusItemType,
+};
+use git2::{Diff, DiffDelta, DiffOptions, Repository};
+use scopetime::scope_time;
+
+/// get all files that are part of a commit
+pub fn get_commit_files(
+ repo_path: &str,
+ id: CommitId,
+) -> Result<Vec<StatusItem>> {
+ scope_time!("get_commit_files");
+
+ let repo = repo(repo_path)?;
+
+ let diff = get_commit_diff(&repo, id, None)?;
+
+ let mut res = Vec::new();
+
+ diff.foreach(
+ &mut |delta: DiffDelta<'_>, _progress| {
+ res.push(StatusItem {
+ path: delta
+ .new_file()
+ .path()
+ .map(|p| p.to_str().unwrap_or("").to_string())
+ .unwrap_or_default(),
+ status: StatusItemType::from(delta.status()),
+ });
+ true
+ },
+ None,
+ None,
+ None,
+ )?;
+
+ Ok(res)
+}
+
+///
+pub(crate) fn get_commit_diff(
+ repo: &Repository,
+ id: CommitId,
+ pathspec: Option<String>,
+) -> Result<Diff<'_>> {
+ // scope_time!("get_commit_diff");
+
+ let commit = repo.find_commit(id.into())?;
+ let commit_tree = commit.tree()?;
+ let parent = if commit.parent_count() > 0 {
+ Some(repo.find_commit(commit.parent_id(0)?)?.tree()?)
+ } else {
+ None
+ };
+
+ let mut opt = pathspec.as_ref().map(|p| {
+ let mut opts = DiffOptions::new();
+ opts.pathspec(p);
+ opts.show_binary(true);
+ opts
+ });
+
+ let mut diff = repo.diff_tree_to_tree(
+ parent.as_ref(),
+ Some(&commit_tree),
+ opt.as_mut(),
+ )?;
+
+ if is_stash_commit(
+ repo.path().to_str().map_or_else(
+ || Err(Error::Generic("repo path utf8 err".to_owned())),
+ Ok,
+ )?,
+ &id,
+ )? {
+ if let Ok(untracked_commit) = commit.parent_id(2) {
+ let untracked_diff = get_commit_diff(
+ repo,
+ CommitId::new(untracked_commit),
+ pathspec,
+ )?;
+
+ diff.merge(&untracked_diff)?;
+ }
+ }
+
+ Ok(diff)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::get_commit_files;
+ use crate::{
+ error::Result,
+ sync::{
+ commit, stage_add_file, stash_save,
+ tests::{get_statuses, repo_init},
+ },
+ StatusItemType,
+ };
+ use std::{fs::File, io::Write, path::Path};
+
+ #[test]
+ fn test_smoke() -> Result<()> {
+ let file_path = Path::new("file1.txt");
+ let (_td, repo) = repo_init()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?
+ .write_all(b"test file1 content")?;
+
+ stage_add_file(repo_path, file_path)?;
+
+ let id = commit(repo_path, "commit msg")?;
+
+ let diff = get_commit_files(repo_path, id)?;
+
+ assert_eq!(diff.len(), 1);
+ assert_eq!(diff[0].status, StatusItemType::New);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_stashed_untracked() -> Result<()> {
+ let file_path = Path::new("file1.txt");
+ let (_td, repo) = repo_init()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?
+ .write_all(b"test file1 content")?;
+
+ let id = stash_save(repo_path, None, true, false)?;
+
+ let diff = get_commit_files(repo_path, id)?;
+
+ assert_eq!(diff.len(), 1);
+ assert_eq!(diff[0].status, StatusItemType::New);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_stashed_untracked_and_modified() -> Result<()> {
+ let file_path1 = Path::new("file1.txt");
+ let file_path2 = Path::new("file2.txt");
+ let (_td, repo) = repo_init()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path1))?.write_all(b"test")?;
+ stage_add_file(repo_path, file_path1)?;
+ commit(repo_path, "c1")?;
+
+ File::create(&root.join(file_path1))?
+ .write_all(b"modified")?;
+ File::create(&root.join(file_path2))?.write_all(b"new")?;
+
+ assert_eq!(get_statuses(repo_path), (2, 0));
+
+ let id = stash_save(repo_path, None, true, false)?;
+
+ let diff = get_commit_files(repo_path, id)?;
+
+ assert_eq!(diff.len(), 2);
+ assert_eq!(diff[0].status, StatusItemType::Modified);
+ assert_eq!(diff[1].status, StatusItemType::New);
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs
new file mode 100644
index 0000000..75f5066
--- /dev/null
+++ b/asyncgit/src/sync/commits_info.rs
@@ -0,0 +1,193 @@
+use super::utils::repo;
+use crate::error::Result;
+use git2::{Commit, Error, Oid};
+use scopetime::scope_time;
+
+/// identifies a single commit
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
+pub struct CommitId(Oid);
+
+impl CommitId {
+ /// create new CommitId
+ pub fn new(id: Oid) -> Self {
+ Self(id)
+ }
+
+ ///
+ pub(crate) fn get_oid(self) -> Oid {
+ self.0
+ }
+
+ ///
+ pub fn get_short_string(&self) -> String {
+ self.to_string().chars().take(7).collect()
+ }
+}
+
+impl ToString for CommitId {
+ fn to_string(&self) -> String {
+ self.0.to_string()
+ }
+}
+
+impl Into<Oid> for CommitId {
+ fn into(self) -> Oid {
+ self.0
+ }
+}
+
+impl From<Oid> for CommitId {
+ fn from(id: Oid) -> Self {
+ Self::new(id)
+ }
+}
+
+///
+#[derive(Debug)]
+pub struct CommitInfo {
+ ///
+ pub message: String,
+ ///
+ pub time: i64,
+ ///
+ pub author: String,
+ ///
+ pub id: CommitId,
+}
+
+///
+pub fn get_commits_info(
+ repo_path: &str,
+ ids: &[CommitId],
+ message_length_limit: usize,
+) -> Result<Vec<CommitInfo>> {
+ scope_time!("get_commits_info");
+
+ let repo = repo(repo_path)?;
+
+ let commits = ids
+ .iter()
+ .map(|id| repo.find_commit((*id).into()))
+ .collect::<std::result::Result<Vec<Commit>, Error>>()?
+ .into_iter();
+
+ let res = commits
+ .map(|c: Commit| {
+ let message = get_message(&c, Some(message_length_limit));
+ let author = if let Some(name) = c.author().name() {
+ String::from(name)
+ } else {
+ String::from("<unknown>")
+ };
+ CommitInfo {
+ message,
+ author,
+ time: c.time().seconds(),
+ id: CommitId(c.id()),
+ }
+ })
+ .collect::<Vec<_>>();
+
+ Ok(res)
+}
+
+///
+pub fn get_message(
+ c: &Commit,
+ message_length_limit: Option<usize>,
+) -> String {
+ let msg = String::from_utf8_lossy(c.message_bytes());
+ let msg = msg.trim_start();
+
+ if let Some(limit) = message_length_limit {
+ limit_str(msg, limit).to_string()
+ } else {
+ msg.to_string()
+ }
+}
+
+#[inline]
+fn limit_str(s: &str, limit: usize) -> &str {
+ if let Some(first) = s.lines().next() {
+ let mut limit = limit.min(first.len());
+ while !first.is_char_boundary(limit) {
+ limit += 1
+ }
+ &first[0..limit]
+ } else {
+ ""
+ }
+}
+
+#[cfg(test)]
+mod tests {
+
+ use super::{get_commits_info, limit_str};
+ use crate::error::Result;
+ use crate::sync::{
+ commit, stage_add_file, tests::repo_init_empty,
+ utils::get_head_repo,
+ };
+ use std::{fs::File, io::Write, path::Path};
+
+ #[test]
+ fn test_log() -> Result<()> {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"a")?;
+ stage_add_file(repo_path, file_path).unwrap();
+ let c1 = commit(repo_path, "commit1").unwrap();
+ File::create(&root.join(file_path))?.write_all(b"a")?;
+ stage_add_file(repo_path, file_path).unwrap();
+ let c2 = commit(repo_path, "commit2").unwrap();
+
+ let res =
+ get_commits_info(repo_path, &vec![c2, c1], 50).unwrap();
+
+ assert_eq!(res.len(), 2);
+ assert_eq!(res[0].message.as_str(), "commit2");
+ assert_eq!(res[0].author.as_str(), "name");
+ assert_eq!(res[1].message.as_str(), "commit1");
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_invalid_utf8() -> Result<()> {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"a")?;
+ stage_add_file(repo_path, file_path).unwrap();
+
+ let msg = invalidstring::invalid_utf8("test msg");
+ commit(repo_path, msg.as_str()).unwrap();
+
+ let res = get_commits_info(
+ repo_path,
+ &vec![get_head_repo(&repo).unwrap().into()],
+ 50,
+ )
+ .unwrap();
+
+ assert_eq!(res.len(), 1);
+ dbg!(&res[0].message);
+ assert_eq!(res[0].message.starts_with("test msg"), true);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_limit_string_utf8() {
+ assert_eq!(limit_str("里里", 1), "里");
+
+ let test_src = "导入按钮由选文件改为选目录,因为整个过程中要用到多个mdb文件,这些文件是在程序里写死的,暂且这么来做,有时间了后 再做调整";
+ let test_dst = "导入按钮由选文";
+ assert_eq!(limit_str(test_src, 20), test_dst);
+ }
+}
diff --git a/asyncgit/src/sync/cred.rs b/asyncgit/src/sync/cred.rs
new file mode 100644
index 0000000..fbd2304
--- /dev/null
+++ b/asyncgit/src/sync/cred.rs
@@ -0,0 +1,257 @@
+//! credentials git helper
+
+use git2::{Config, CredentialHelper};
+
+use crate::error::{Error, Result};
+use crate::CWD;
+
+/// basic Authentication Credentials
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct BasicAuthCredential {
+ ///
+ pub username: Option<String>,
+ ///
+ pub password: Option<String>,
+}
+
+impl BasicAuthCredential {
+ ///
+ pub fn is_complete(&self) -> bool {
+ self.username.is_some() && self.password.is_some()
+ }
+ ///
+ pub fn new(
+ username: Option<String>,
+ password: Option<String>,
+ ) -> Self {
+ BasicAuthCredential { username, password }
+ }
+}
+
+/// know if username and password are needed for this url
+pub fn need_username_password(remote: &str) -> Result<bool> {
+ let repo = crate::sync::utils::repo(CWD)?;
+ let url = repo
+ .find_remote(remote)?
+ .url()
+ .ok_or(Error::UnknownRemote)?
+ .to_owned();
+ let is_http = url.starts_with("http");
+ Ok(is_http)
+}
+
+/// extract username and password
+pub fn extract_username_password(
+ remote: &str,
+) -> Result<BasicAuthCredential> {
+ let repo = crate::sync::utils::repo(CWD)?;
+ let url = repo
+ .find_remote(remote)?
+ .url()
+ .ok_or(Error::UnknownRemote)?
+ .to_owned();
+ let mut helper = CredentialHelper::new(&url);
+
+ if let Ok(config) = Config::open_default() {
+ helper.config(&config);
+ }
+ Ok(match helper.execute() {
+ Some((username, password)) => {
+ BasicAuthCredential::new(Some(username), Some(password))
+ }
+ None => extract_cred_from_url(&url),
+ })
+}
+
+/// extract credentials from url
+pub fn extract_cred_from_url(url: &str) -> BasicAuthCredential {
+ if let Ok(url) = url::Url::parse(url) {
+ BasicAuthCredential::new(
+ if url.username() == "" {
+ None
+ } else {
+ Some(url.username().to_owned())
+ },
+ url.password().map(|pwd| pwd.to_owned()),
+ )
+ } else {
+ BasicAuthCredential::new(None, None)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::sync::cred::{
+ extract_cred_from_url, extract_username_password,
+ need_username_password, BasicAuthCredential,
+ };
+ use crate::sync::tests::repo_init;
+ use crate::sync::DEFAULT_REMOTE_NAME;
+ use serial_test::serial;
+ use std::env;
+
+ #[test]
+ fn test_credential_complete() {
+ assert_eq!(
+ BasicAuthCredential::new(
+ Some("username".to_owned()),
+ Some("password".to_owned())
+ )
+ .is_complete(),
+ true
+ );
+ }
+
+ #[test]
+ fn test_credential_not_complete() {
+ assert_eq!(
+ BasicAuthCredential::new(
+ None,
+ Some("password".to_owned())
+ )
+ .is_complete(),
+ false
+ );
+ assert_eq!(
+ BasicAuthCredential::new(
+ Some("username".to_owned()),
+ None
+ )
+ .is_complete(),
+ false
+ );
+ assert_eq!(
+ BasicAuthCredential::new(None, None).is_complete(),
+ false
+ );
+ }
+
+ #[test]
+ fn test_extract_username_from_url() {
+ assert_eq!(
+ extract_cred_from_url("https://user@github.com"),
+ BasicAuthCredential::new(Some("user".to_owned()), None)
+ );
+ }
+
+ #[test]
+ fn test_extract_username_password_from_url() {
+ assert_eq!(
+ extract_cred_from_url("https://user:pwd@github.com"),
+ BasicAuthCredential::new(
+ Some("user".to_owned()),
+ Some("pwd".to_owned())
+ )
+ );
+ }
+
+ #[test]
+ fn test_extract_nothing_from_url() {
+ assert_eq!(
+ extract_cred_from_url("https://github.com"),
+ BasicAuthCredential::new(None, None)
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn test_need_username_password_if_https() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ env::set_current_dir(repo_path).unwrap();
+ repo.remote(DEFAULT_REMOTE_NAME, "http://user@github.com")
+ .unwrap();
+
+ assert_eq!(
+ need_username_password(DEFAULT_REMOTE_NAME).unwrap(),
+ true
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn test_dont_need_username_password_if_ssh() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ env::set_current_dir(repo_path).unwrap();
+ repo.remote(DEFAULT_REMOTE_NAME, "git@github.com:user/repo")
+ .unwrap();
+
+ assert_eq!(
+ need_username_password(DEFAULT_REMOTE_NAME).unwrap(),
+ false
+ );
+ }
+
+ #[test]
+ #[serial]
+ #[should_panic]
+ fn test_error_if_no_remote_when_trying_to_retrieve_if_need_username_password(
+ ) {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ env::set_current_dir(repo_path).unwrap();
+
+ need_username_password(DEFAULT_REMOTE_NAME).unwrap();
+ }
+
+ #[test]
+ #[serial]
+ fn test_extract_username_password_from_repo() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ env::set_current_dir(repo_path).unwrap();
+ repo.remote(
+ DEFAULT_REMOTE_NAME,
+ "http://user:pass@github.com",
+ )
+ .unwrap();
+
+ assert_eq!(
+ extract_username_password(DEFAULT_REMOTE_NAME).unwrap(),
+ BasicAuthCredential::new(
+ Some("user".to_owned()),
+ Some("pass".to_owned())
+ )
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn test_extract_username_from_repo() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ env::set_current_dir(repo_path).unwrap();
+ repo.remote(DEFAULT_REMOTE_NAME, "http://user@github.com")
+ .unwrap();
+
+ assert_eq!(
+ extract_username_password(DEFAULT_REMOTE_NAME).unwrap(),
+ BasicAuthCredential::new(Some("user".to_owned()), None)
+ );
+ }
+
+ #[test]
+ #[serial]
+ #[should_panic]
+ fn test_error_if_no_remote_when_trying_to_extract_username_password(
+ ) {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ env::set_current_dir(repo_path).unwrap();
+
+ extract_username_password(DEFAULT_REMOTE_NAME).unwrap();
+ }
+}
diff --git a/asyncgit/src/sync/diff.rs b/asyncgit/src/sync/diff.rs
new file mode 100644
index 0000000..88c43ca
--- /dev/null
+++ b/asyncgit/src/sync/diff.rs
@@ -0,0 +1,555 @@
+//! sync git api for fetching a diff
+
+use super::{
+ commit_files::get_commit_diff,
+ utils::{self, get_head_repo, work_dir},
+ CommitId,
+};
+use crate::{error::Error, error::Result, hash};
+use git2::{
+ Delta, Diff, DiffDelta, DiffFormat, DiffHunk, DiffOptions, Patch,
+ Repository,
+};
+use scopetime::scope_time;
+use std::{cell::RefCell, fs, path::Path, rc::Rc};
+
+/// type of diff of a single line
+#[derive(Copy, Clone, PartialEq, Hash, Debug)]
+pub enum DiffLineType {
+ /// just surrounding line, no change
+ None,
+ /// header of the hunk
+ Header,
+ /// line added
+ Add,
+ /// line deleted
+ Delete,
+}
+
+impl Default for DiffLineType {
+ fn default() -> Self {
+ DiffLineType::None
+ }
+}
+
+///
+#[derive(Default, Clone, Hash, Debug)]
+pub struct DiffLine {
+ ///
+ pub content: String,
+ ///
+ pub line_type: DiffLineType,
+}
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Hash)]
+pub(crate) struct HunkHeader {
+ old_start: u32,
+ old_lines: u32,
+ new_start: u32,
+ new_lines: u32,
+}
+
+impl From<DiffHunk<'_>> for HunkHeader {
+ fn from(h: DiffHunk) -> Self {
+ Self {
+ old_start: h.old_start(),
+ old_lines: h.old_lines(),
+ new_start: h.new_start(),
+ new_lines: h.new_lines(),
+ }
+ }
+}
+
+/// single diff hunk
+#[derive(Default, Clone, Hash, Debug)]
+pub struct Hunk {
+ /// hash of the hunk header
+ pub header_hash: u64,
+ /// list of `DiffLine`s
+ pub lines: Vec<DiffLine>,
+}
+
+/// collection of hunks, sum of all diff lines
+#[derive(Default, Clone, Hash, Debug)]
+pub struct FileDiff {
+ /// list of hunks
+ pub hunks: Vec<Hunk>,
+ /// lines total summed up over hunks
+ pub lines: usize,
+ ///
+ pub untracked: bool,
+ /// old and new file size in bytes
+ pub sizes: (u64, u64),
+ /// size delta in bytes
+ pub size_delta: i64,
+}
+
+pub(crate) fn get_diff_raw<'a>(
+ repo: &'a Repository,
+ p: &str,
+ stage: bool,
+ reverse: bool,
+) -> Result<Diff<'a>> {
+ // scope_time!("get_diff_raw");
+
+ let mut opt = DiffOptions::new();
+ opt.pathspec(p);
+ opt.reverse(reverse);
+
+ let diff = if stage {
+ // diff against head
+ if let Ok(id) = get_head_repo(&repo) {
+ let parent = repo.find_commit(id.into())?;
+
+ let tree = parent.tree()?;
+ repo.diff_tree_to_index(
+ Some(&tree),
+ Some(&repo.index()?),
+ Some(&mut opt),
+ )?
+ } else {
+ repo.diff_tree_to_index(
+ None,
+ Some(&repo.index()?),
+ Some(&mut opt),
+ )?
+ }
+ } else {
+ opt.include_untracked(true);
+ opt.recurse_untracked_dirs(true);
+ repo.diff_index_to_workdir(None, Some(&mut opt))?
+ };
+
+ Ok(diff)
+}
+
+/// returns diff of a specific file either in `stage` or workdir
+pub fn get_diff(
+ repo_path: &str,
+ p: String,
+ stage: bool,
+) -> Result<FileDiff> {
+ scope_time!("get_diff");
+
+ let repo = utils::repo(repo_path)?;
+ let work_dir = work_dir(&repo)?;
+ let diff = get_diff_raw(&repo, &p, stage, false)?;
+
+ raw_diff_to_file_diff(&diff, work_dir)
+}
+
+/// returns diff of a specific file inside a commit
+/// see `get_commit_diff`
+pub fn get_diff_commit(
+ repo_path: &str,
+ id: CommitId,
+ p: String,
+) -> Result<FileDiff> {
+ scope_time!("get_diff_commit");
+
+ let repo = utils::repo(repo_path)?;
+ let work_dir = work_dir(&repo)?;
+ let diff = get_commit_diff(&repo, id, Some(p))?;
+
+ raw_diff_to_file_diff(&diff, work_dir)
+}
+
+///
+fn raw_diff_to_file_diff<'a>(
+ diff: &'a Diff,
+ work_dir: &Path,
+) -> Result<FileDiff> {
+ let res = Rc::new(RefCell::new(FileDiff::default()));
+ {
+ let mut current_lines = Vec::new();
+ let mut current_hunk: Option<HunkHeader> = None;
+
+ let res_cell = Rc::clone(&res);
+ let adder = move |header: &HunkHeader,
+ lines: &Vec<DiffLine>| {
+ let mut res = res_cell.borrow_mut();
+ res.hunks.push(Hunk {
+ header_hash: hash(header),
+ lines: lines.clone(),
+ });
+ res.lines += lines.len();
+ };
+
+ let res_cell = Rc::clone(&res);
+ let mut put = |delta: DiffDelta,
+ hunk: Option<DiffHunk>,
+ line: git2::DiffLine| {
+ {
+ let mut res = res_cell.borrow_mut();
+ res.sizes = (
+ delta.old_file().size(),
+ delta.new_file().size(),
+ );
+ res.size_delta = (res.sizes.1 as i64)
+ .saturating_sub(res.sizes.0 as i64);
+ }
+ if let Some(hunk) = hunk {
+ let hunk_header = HunkHeader::from(hunk);
+
+ match current_hunk {
+ None => current_hunk = Some(hunk_header),
+ Some(h) if h != hunk_header => {
+ adder(&h, &current_lines);
+ current_lines.clear();
+ current_hunk = Some(hunk_header)
+ }
+ _ => (),
+ }
+
+ let line_type = match line.origin() {
+ 'H' => DiffLineType::Header,
+ '<' | '-' => DiffLineType::Delete,
+ '>' | '+' => DiffLineType::Add,
+ _ => DiffLineType::None,
+ };
+
+ let diff_line = DiffLine {
+ content: String::from_utf8_lossy(line.content())
+ .to_string(),
+ line_type,
+ };
+
+ current_lines.push(diff_line);
+ }
+ };
+
+ let new_file_diff = if diff.deltas().len() == 1 {
+ if let Some(delta) = diff.deltas().next() {
+ if delta.status() == Delta::Untracked {
+ let relative_path =
+ delta.new_file().path().ok_or_else(|| {
+ Error::Generic(
+ "new file path is unspecified."
+ .to_string(),
+ )
+ })?;
+
+ let newfile_path = work_dir.join(relative_path);
+
+ if let Some(newfile_content) =
+ new_file_content(&newfile_path)
+ {
+ let mut patch = Patch::from_buffers(
+ &[],
+ None,
+ newfile_content.as_slice(),
+ Some(&newfile_path),
+ None,
+ )?;
+
+ patch
+ .print(&mut |delta, hunk:Option<DiffHunk>, line: git2::DiffLine| {
+ put(delta,hunk,line);
+ true
+ })?;
+
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ } else {
+ false
+ };
+
+ if !new_file_diff {
+ diff.print(
+ DiffFormat::Patch,
+ move |delta, hunk, line: git2::DiffLine| {
+ put(delta, hunk, line);
+ true
+ },
+ )?;
+ }
+
+ if !current_lines.is_empty() {
+ adder(
+ &current_hunk.map_or_else(
+ || Err(Error::Generic("invalid hunk".to_owned())),
+ Ok,
+ )?,
+ &current_lines,
+ );
+ }
+
+ if new_file_diff {
+ res.borrow_mut().untracked = true;
+ }
+ }
+ let res = Rc::try_unwrap(res)
+ .map_err(|_| Error::Generic("rc unwrap error".to_owned()))?;
+ Ok(res.into_inner())
+}
+
+fn new_file_content(path: &Path) -> Option<Vec<u8>> {
+ if let Ok(meta) = fs::symlink_metadata(path) {
+ if meta.file_type().is_symlink() {
+ if let Ok(path) = fs::read_link(path) {
+ return Some(
+ path.to_str()?.to_string().as_bytes().into(),
+ );
+ }
+ } else if meta.file_type().is_file() {
+ if let Ok(content) = fs::read(path) {
+ return Some(content);
+ }
+ }
+ }
+
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{get_diff, get_diff_commit};
+ use crate::error::Result;
+ use crate::sync::{
+ commit, stage_add_file,
+ status::{get_status, StatusType},
+ tests::{get_statuses, repo_init, repo_init_empty},
+ };
+ use std::{
+ fs::{self, File},
+ io::Write,
+ path::Path,
+ };
+
+ #[test]
+ fn test_untracked_subfolder() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+
+ fs::create_dir(&root.join("foo")).unwrap();
+ File::create(&root.join("foo/bar.txt"))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ let diff =
+ get_diff(repo_path, "foo/bar.txt".to_string(), false)
+ .unwrap();
+
+ assert_eq!(diff.hunks.len(), 1);
+ assert_eq!(diff.hunks[0].lines[1].content, "test\n");
+ }
+
+ #[test]
+ fn test_empty_repo() {
+ let file_path = Path::new("foo.txt");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+
+ File::create(&root.join(file_path))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 1));
+
+ let diff = get_diff(
+ repo_path,
+ String::from(file_path.to_str().unwrap()),
+ true,
+ )
+ .unwrap();
+
+ assert_eq!(diff.hunks.len(), 1);
+ }
+
+ static HUNK_A: &str = r"
+1 start
+2
+3
+4
+5
+6 middle
+7
+8
+9
+0
+1 end";
+
+ static HUNK_B: &str = r"
+1 start
+2 newa
+3
+4
+5
+6 middle
+7
+8
+9
+0 newb
+1 end";
+
+ #[test]
+ fn test_hunks() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+
+ let file_path = root.join("bar.txt");
+
+ {
+ File::create(&file_path)
+ .unwrap()
+ .write_all(HUNK_A.as_bytes())
+ .unwrap();
+ }
+
+ let res = get_status(repo_path, StatusType::WorkingDir, true)
+ .unwrap();
+ assert_eq!(res.len(), 1);
+ assert_eq!(res[0].path, "bar.txt");
+
+ stage_add_file(repo_path, Path::new("bar.txt")).unwrap();
+ assert_eq!(get_statuses(repo_path), (0, 1));
+
+ // overwrite with next content
+ {
+ File::create(&file_path)
+ .unwrap()
+ .write_all(HUNK_B.as_bytes())
+ .unwrap();
+ }
+
+ assert_eq!(get_statuses(repo_path), (1, 1));
+
+ let res = get_diff(repo_path, "bar.txt".to_string(), false)
+ .unwrap();
+
+ assert_eq!(res.hunks.len(), 2)
+ }
+
+ #[test]
+ fn test_diff_newfile_in_sub_dir_current_dir() {
+ let file_path = Path::new("foo/foo.txt");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+
+ let sub_path = root.join("foo/");
+
+ fs::create_dir_all(&sub_path).unwrap();
+ File::create(&root.join(file_path))
+ .unwrap()
+ .write_all(b"test")
+ .unwrap();
+
+ let diff = get_diff(
+ sub_path.to_str().unwrap(),
+ String::from(file_path.to_str().unwrap()),
+ false,
+ )
+ .unwrap();
+
+ assert_eq!(diff.hunks[0].lines[1].content, "test");
+ }
+
+ #[test]
+ fn test_diff_delta_size() -> Result<()> {
+ let file_path = Path::new("bar");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"\x00")?;
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ commit(repo_path, "commit").unwrap();
+
+ File::create(&root.join(file_path))?
+ .write_all(b"\x00\x02")?;
+
+ let diff = get_diff(
+ repo_path,
+ String::from(file_path.to_str().unwrap()),
+ false,
+ )
+ .unwrap();
+
+ dbg!(&diff);
+ assert_eq!(diff.sizes, (1, 2));
+ assert_eq!(diff.size_delta, 1);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_binary_diff_delta_size_untracked() -> Result<()> {
+ let file_path = Path::new("bar");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?
+ .write_all(b"\x00\xc7")?;
+
+ let diff = get_diff(
+ repo_path,
+ String::from(file_path.to_str().unwrap()),
+ false,
+ )
+ .unwrap();
+
+ dbg!(&diff);
+ assert_eq!(diff.sizes, (0, 2));
+ assert_eq!(diff.size_delta, 2);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_diff_delta_size_commit() -> Result<()> {
+ let file_path = Path::new("bar");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"\x00")?;
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ commit(repo_path, "").unwrap();
+
+ File::create(&root.join(file_path))?
+ .write_all(b"\x00\x02")?;
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ let id = commit(repo_path, "").unwrap();
+
+ let diff =
+ get_diff_commit(repo_path, id, String::new()).unwrap();
+
+ dbg!(&diff);
+ assert_eq!(diff.sizes, (1, 2));
+ assert_eq!(diff.size_delta, 1);
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/sync/hooks.rs b/asyncgit/src/sync/hooks.rs
new file mode 100644
index 0000000..b81bca1
--- /dev/null
+++ b/asyncgit/src/sync/hooks.rs
@@ -0,0 +1,388 @@
+use super::utils::{repo, work_dir};
+use crate::error::{Error, Result};
+use scopetime::scope_time;
+use std::{
+ fs::File,
+ io::{Read, Write},
+ path::{Path, PathBuf},
+ process::Command,
+};
+
+const HOOK_POST_COMMIT: &str = ".git/hooks/post-commit";
+const HOOK_PRE_COMMIT: &str = ".git/hooks/pre-commit";
+const HOOK_COMMIT_MSG: &str = ".git/hooks/commit-msg";
+const HOOK_COMMIT_MSG_TEMP_FILE: &str = ".git/COMMIT_EDITMSG";
+
+/// this hook is documented here https://git-scm.com/docs/githooks#_commit_msg
+/// we use the same convention as other git clients to create a temp file containing
+/// the commit message at `.git/COMMIT_EDITMSG` and pass it's relative path as the only
+/// parameter to the hook script.
+pub fn hooks_commit_msg(
+ repo_path: &str,
+ msg: &mut String,
+) -> Result<HookResult> {
+ scope_time!("hooks_commit_msg");
+
+ let work_dir = work_dir_as_string(repo_path)?;
+
+ if hook_runable(work_dir.as_str(), HOOK_COMMIT_MSG) {
+ let temp_file = Path::new(work_dir.as_str())
+ .join(HOOK_COMMIT_MSG_TEMP_FILE);
+ File::create(&temp_file)?.write_all(msg.as_bytes())?;
+
+ let res = run_hook(
+ work_dir.as_str(),
+ HOOK_COMMIT_MSG,
+ &[HOOK_COMMIT_MSG_TEMP_FILE],
+ )?;
+
+ // load possibly altered msg
+ msg.clear();
+ File::open(temp_file)?.read_to_string(msg)?;
+
+ Ok(res)
+ } else {
+ Ok(HookResult::Ok)
+ }
+}
+
+/// this hook is documented here https://git-scm.com/docs/githooks#_pre_commit
+///
+pub fn hooks_pre_commit(repo_path: &str) -> Result<HookResult> {
+ scope_time!("hooks_pre_commit");
+
+ let work_dir = work_dir_as_string(repo_path)?;
+
+ if hook_runable(work_dir.as_str(), HOOK_PRE_COMMIT) {
+ Ok(run_hook(work_dir.as_str(), HOOK_PRE_COMMIT, &[])?)
+ } else {
+ Ok(HookResult::Ok)
+ }
+}
+///
+pub fn hooks_post_commit(repo_path: &str) -> Result<HookResult> {
+ scope_time!("hooks_post_commit");
+
+ let work_dir = work_dir_as_string(repo_path)?;
+ let work_dir_str = work_dir.as_str();
+
+ if hook_runable(work_dir_str, HOOK_POST_COMMIT) {
+ Ok(run_hook(work_dir_str, HOOK_POST_COMMIT, &[])?)
+ } else {
+ Ok(HookResult::Ok)
+ }
+}
+
+fn work_dir_as_string(repo_path: &str) -> Result<String> {
+ let repo = repo(repo_path)?;
+ work_dir(&repo)?.to_str().map(|s| s.to_string()).ok_or_else(
+ || {
+ Error::Generic(
+ "workdir contains invalid utf8".to_string(),
+ )
+ },
+ )
+}
+
+fn hook_runable(path: &str, hook: &str) -> bool {
+ let path = Path::new(path);
+ let path = path.join(hook);
+
+ path.exists() && is_executable(path)
+}
+
+///
+#[derive(Debug, PartialEq)]
+pub enum HookResult {
+ /// Everything went fine
+ Ok,
+ /// Hook returned error
+ NotOk(String),
+}
+
+/// this function calls hook scripts based on conventions documented here
+/// https://git-scm.com/docs/githooks
+fn run_hook(
+ path: &str,
+ hook_script: &str,
+ args: &[&str],
+) -> Result<HookResult> {
+ let arg_str = format!("{} {}", hook_script, args.join(" "));
+ let bash_args = vec!["-c".to_string(), arg_str];
+
+ let output = Command::new("bash")
+ .args(bash_args)
+ .current_dir(path)
+ // This call forces Command to handle the Path environment correctly on windows,
+ // the specific env set here does not matter
+ // see https://github.com/rust-lang/rust/issues/37519
+ .env(
+ "DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS",
+ "FixPathHandlingOnWindows",
+ )
+ .output()?;
+
+ if output.status.success() {
+ Ok(HookResult::Ok)
+ } else {
+ let err = String::from_utf8_lossy(&output.stderr);
+ let out = String::from_utf8_lossy(&output.stdout);
+ let formatted = format!("{}{}", out, err);
+
+ Ok(HookResult::NotOk(formatted))
+ }
+}
+
+#[cfg(not(windows))]
+fn is_executable(path: PathBuf) -> bool {
+ use std::os::unix::fs::PermissionsExt;
+ let metadata = match path.metadata() {
+ Ok(metadata) => metadata,
+ Err(_) => return false,
+ };
+
+ let permissions = metadata.permissions();
+ permissions.mode() & 0o111 != 0
+}
+
+#[cfg(windows)]
+/// windows does not consider bash scripts to be executable so we consider everything
+/// to be executable (which is not far from the truth for windows platform.)
+fn is_executable(_: PathBuf) -> bool {
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sync::tests::repo_init;
+ use std::fs::{self, File};
+
+ #[test]
+ fn test_smoke() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let mut msg = String::from("test");
+ let res = hooks_commit_msg(repo_path, &mut msg).unwrap();
+
+ assert_eq!(res, HookResult::Ok);
+
+ let res = hooks_post_commit(repo_path).unwrap();
+
+ assert_eq!(res, HookResult::Ok);
+ }
+
+ fn create_hook(path: &Path, hook_path: &str, hook_script: &[u8]) {
+ File::create(&path.join(hook_path))
+ .unwrap()
+ .write_all(hook_script)
+ .unwrap();
+
+ #[cfg(not(windows))]
+ {
+ Command::new("chmod")
+ .args(&["+x", hook_path])
+ .current_dir(path)
+ .output()
+ .unwrap();
+ }
+ }
+
+ #[test]
+ fn test_hooks_commit_msg_ok() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let hook = b"#!/bin/sh
+exit 0
+ ";
+
+ create_hook(root, HOOK_COMMIT_MSG, hook);
+
+ let mut msg = String::from("test");
+ let res = hooks_commit_msg(repo_path, &mut msg).unwrap();
+
+ assert_eq!(res, HookResult::Ok);
+
+ assert_eq!(msg, String::from("test"));
+ }
+
+ #[test]
+ fn test_pre_commit_sh() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let hook = b"#!/bin/sh
+exit 0
+ ";
+
+ create_hook(root, HOOK_PRE_COMMIT, hook);
+ let res = hooks_pre_commit(repo_path).unwrap();
+ assert_eq!(res, HookResult::Ok);
+ }
+
+ #[test]
+ fn test_pre_commit_fail_sh() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let hook = b"#!/bin/sh
+echo 'rejected'
+exit 1
+ ";
+
+ create_hook(root, HOOK_PRE_COMMIT, hook);
+ let res = hooks_pre_commit(repo_path).unwrap();
+ assert!(res != HookResult::Ok);
+ }
+
+ #[test]
+ fn test_pre_commit_py() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ // mirror how python pre-commmit sets itself up
+ #[cfg(not(windows))]
+ let hook = b"#!/usr/bin/env python
+import sys
+sys.exit(0)
+ ";
+ #[cfg(windows)]
+ let hook = b"#!/bin/env python.exe
+import sys
+sys.exit(0)
+ ";
+
+ create_hook(root, HOOK_PRE_COMMIT, hook);
+ let res = hooks_pre_commit(repo_path).unwrap();
+ assert_eq!(res, HookResult::Ok);
+ }
+
+ #[test]
+ fn test_pre_commit_fail_py() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ // mirror how python pre-commmit sets itself up
+ #[cfg(not(windows))]
+ let hook = b"#!/usr/bin/env python
+import sys
+sys.exit(1)
+ ";
+ #[cfg(windows)]
+ let hook = b"#!/bin/env python.exe
+import sys
+sys.exit(1)
+ ";
+
+ create_hook(root, HOOK_PRE_COMMIT, hook);
+ let res = hooks_pre_commit(repo_path).unwrap();
+ assert!(res != HookResult::Ok);
+ }
+
+ #[test]
+ fn test_hooks_commit_msg_reject() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let hook = b"#!/bin/sh
+echo 'msg' > $1
+echo 'rejected'
+exit 1
+ ";
+
+ create_hook(root, HOOK_COMMIT_MSG, hook);
+
+ let mut msg = String::from("test");
+ let res = hooks_commit_msg(repo_path, &mut msg).unwrap();
+
+ assert_eq!(
+ res,
+ HookResult::NotOk(String::from("rejected\n"))
+ );
+
+ assert_eq!(msg, String::from("msg\n"));
+ }
+
+ #[test]
+ fn test_hooks_commit_msg_reject_in_subfolder() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ // let repo_path = root.as_os_str().to_str().unwrap();
+
+ let hook = b"#!/bin/sh
+echo 'msg' > $1
+echo 'rejected'
+exit 1
+ ";
+
+ create_hook(root, HOOK_COMMIT_MSG, hook);
+
+ let subfolder = root.join("foo/");
+ fs::create_dir_all(&subfolder).unwrap();
+
+ let mut msg = String::from("test");
+ let res =
+ hooks_commit_msg(subfolder.to_str().unwrap(), &mut msg)
+ .unwrap();
+
+ assert_eq!(
+ res,
+ HookResult::NotOk(String::from("rejected\n"))
+ );
+
+ assert_eq!(msg, String::from("msg\n"));
+ }
+
+ #[test]
+ fn test_commit_msg_no_block_but_alter() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let hook = b"#!/bin/sh
+echo 'msg' > $1
+exit 0
+ ";
+
+ create_hook(root, HOOK_COMMIT_MSG, hook);
+
+ let mut msg = String::from("test");
+ let res = hooks_commit_msg(repo_path, &mut msg).unwrap();
+
+ assert_eq!(res, HookResult::Ok);
+ assert_eq!(msg, String::from("msg\n"));
+ }
+
+ #[test]
+ fn test_post_commit_hook_reject_in_subfolder() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+
+ let hook = b"#!/bin/sh
+echo 'rejected'
+exit 1
+ ";
+
+ create_hook(root, HOOK_POST_COMMIT, hook);
+
+ let subfolder = root.join("foo/");
+ fs::create_dir_all(&subfolder).unwrap();
+
+ let res =
+ hooks_post_commit(subfolder.to_str().unwrap()).unwrap();
+
+ assert_eq!(
+ res,
+ HookResult::NotOk(String::from("rejected\n"))
+ );
+ }
+}
diff --git a/asyncgit/src/sync/hunks.rs b/asyncgit/src/sync/hunks.rs
new file mode 100644
index 0000000..19cda65
--- /dev/null
+++ b/asyncgit/src/sync/hunks.rs
@@ -0,0 +1,189 @@
+use super::{
+ diff::{get_diff_raw, HunkHeader},
+ utils::repo,
+};
+use crate::{
+ error::{Error, Result},
+ hash,
+};
+use git2::{ApplyLocation, ApplyOptions, Diff};
+use scopetime::scope_time;
+
+///
+pub fn stage_hunk(
+ repo_path: &str,
+ file_path: String,
+ hunk_hash: u64,
+) -> Result<()> {
+ scope_time!("stage_hunk");
+
+ let repo = repo(repo_path)?;
+
+ let diff = get_diff_raw(&repo, &file_path, false, false)?;
+
+ let mut opt = ApplyOptions::new();
+ opt.hunk_callback(|hunk| {
+ if let Some(hunk) = hunk {
+ let header = HunkHeader::from(hunk);
+ hash(&header) == hunk_hash
+ } else {
+ false
+ }
+ });
+
+ repo.apply(&diff, ApplyLocation::Index, Some(&mut opt))?;
+
+ Ok(())
+}
+
+/// this will fail for an all untracked file
+pub fn reset_hunk(
+ repo_path: &str,
+ file_path: String,
+ hunk_hash: u64,
+) -> Result<()> {
+ scope_time!("reset_hunk");
+
+ let repo = repo(repo_path)?;
+
+ let diff = get_diff_raw(&repo, &file_path, false, false)?;
+
+ let hunk_index = find_hunk_index(&diff, hunk_hash);
+ if let Some(hunk_index) = hunk_index {
+ let mut hunk_idx = 0;
+ let mut opt = ApplyOptions::new();
+ opt.hunk_callback(|_hunk| {
+ let res = hunk_idx == hunk_index;
+ hunk_idx += 1;
+ res
+ });
+
+ let diff = get_diff_raw(&repo, &file_path, false, true)?;
+
+ repo.apply(&diff, ApplyLocation::WorkDir, Some(&mut opt))?;
+
+ Ok(())
+ } else {
+ Err(Error::Generic("hunk not found".to_string()))
+ }
+}
+
+fn find_hunk_index(diff: &Diff, hunk_hash: u64) -> Option<usize> {
+ let mut result = None;
+
+ let mut hunk_count = 0;
+
+ let foreach_result = diff.foreach(
+ &mut |_, _| true,
+ None,
+ Some(&mut |_, hunk| {
+ let header = HunkHeader::from(hunk);
+ if hash(&header) == hunk_hash {
+ result = Some(hunk_count);
+ }
+ hunk_count += 1;
+ true
+ }),
+ None,
+ );
+
+ if foreach_result.is_ok() {
+ result
+ } else {
+ None
+ }
+}
+
+///
+pub fn unstage_hunk(
+ repo_path: &str,
+ file_path: String,
+ hunk_hash: u64,
+) -> Result<bool> {
+ scope_time!("revert_hunk");
+
+ let repo = repo(repo_path)?;
+
+ let diff = get_diff_raw(&repo, &file_path, true, false)?;
+ let diff_count_positive = diff.deltas().len();
+
+ let hunk_index = find_hunk_index(&diff, hunk_hash);
+ let hunk_index = hunk_index.map_or_else(
+ || Err(Error::Generic("hunk not found".to_string())),
+ Ok,
+ )?;
+
+ let diff = get_diff_raw(&repo, &file_path, true, true)?;
+
+ if diff.deltas().len() != diff_count_positive {
+ return Err(Error::Generic(format!(
+ "hunk error: {}!={}",
+ diff.deltas().len(),
+ diff_count_positive
+ )));
+ }
+
+ let mut count = 0;
+ {
+ let mut hunk_idx = 0;
+ let mut opt = ApplyOptions::new();
+ opt.hunk_callback(|_hunk| {
+ let res = if hunk_idx == hunk_index {
+ count += 1;
+ true
+ } else {
+ false
+ };
+
+ hunk_idx += 1;
+
+ res
+ });
+
+ repo.apply(&diff, ApplyLocation::Index, Some(&mut opt))?;
+ }
+
+ Ok(count == 1)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ error::Result,
+ sync::{diff::get_diff, tests::repo_init_empty},
+ };
+ use std::{
+ fs::{self, File},
+ io::Write,
+ path::Path,
+ };
+
+ #[test]
+ fn reset_untracked_file_which_will_not_find_hunk() -> Result<()> {
+ let file_path = Path::new("foo/foo.txt");
+ let (_td, repo) = repo_init_empty()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let sub_path = root.join("foo/");
+
+ fs::create_dir_all(&sub_path)?;
+ File::create(&root.join(file_path))?.write_all(b"test")?;
+
+ let diff = get_diff(
+ sub_path.to_str().unwrap(),
+ String::from(file_path.to_str().unwrap()),
+ false,
+ )?;
+
+ assert!(reset_hunk(
+ repo_path,
+ String::from(file_path.to_str().unwrap()),
+ diff.hunks[0].header_hash,
+ )
+ .is_err());
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/sync/ignore.rs b/asyncgit/src/sync/ignore.rs
new file mode 100644
index 0000000..62e383e
--- /dev/null
+++ b/asyncgit/src/sync/ignore.rs
@@ -0,0 +1,129 @@
+use super::utils::{repo, work_dir};
+use crate::error::Result;
+use scopetime::scope_time;
+use std::{
+ fs::{File, OpenOptions},
+ io::{Read, Seek, SeekFrom, Write},
+ path::PathBuf,
+};
+
+static GITIGNORE: &str = ".gitignore";
+
+/// add file or path to root ignore file
+pub fn add_to_ignore(
+ repo_path: &str,
+ path_to_ignore: &str,
+) -> Result<()> {
+ scope_time!("add_to_ignore");
+
+ let repo = repo(repo_path)?;
+
+ let ignore_file = work_dir(&repo)?.join(GITIGNORE);
+
+ let optional_newline = ignore_file.exists()
+ && !file_ends_with_newline(&ignore_file)?;
+
+ let mut file = OpenOptions::new()
+ .append(true)
+ .create(true)
+ .open(ignore_file)?;
+
+ writeln!(
+ file,
+ "{}{}",
+ if optional_newline { "\n" } else { "" },
+ path_to_ignore
+ )?;
+
+ Ok(())
+}
+
+fn file_ends_with_newline(file: &PathBuf) -> Result<bool> {
+ let mut file = File::open(file)?;
+ let size = file.metadata()?.len();
+
+ file.seek(SeekFrom::Start(size.saturating_sub(1)))?;
+ let mut last_char = String::with_capacity(1);
+ file.read_to_string(&mut last_char)?;
+
+ dbg!(&last_char);
+
+ Ok(last_char == "\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sync::tests::repo_init;
+ use io::BufRead;
+ use std::{fs::File, io, path::Path};
+
+ #[test]
+ fn test_empty() -> Result<()> {
+ let ignore_file_path = Path::new(".gitignore");
+ let file_path = Path::new("foo.txt");
+ let (_td, repo) = repo_init()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"test")?;
+
+ assert_eq!(root.join(ignore_file_path).exists(), false);
+ add_to_ignore(repo_path, file_path.to_str().unwrap())?;
+ assert_eq!(root.join(ignore_file_path).exists(), true);
+
+ Ok(())
+ }
+
+ fn read_lines<P>(
+ filename: P,
+ ) -> io::Result<io::Lines<io::BufReader<File>>>
+ where
+ P: AsRef<Path>,
+ {
+ let file = File::open(filename)?;
+ Ok(io::BufReader::new(file).lines())
+ }
+
+ #[test]
+ fn test_append() -> Result<()> {
+ let ignore_file_path = Path::new(".gitignore");
+ let file_path = Path::new("foo.txt");
+ let (_td, repo) = repo_init()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"test")?;
+ File::create(&root.join(ignore_file_path))?
+ .write_all(b"foo\n")?;
+
+ add_to_ignore(repo_path, file_path.to_str().unwrap())?;
+
+ let mut lines =
+ read_lines(&root.join(ignore_file_path)).unwrap();
+ assert_eq!(&lines.nth(1).unwrap().unwrap(), "foo.txt");
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_append_no_newline_at_end() -> Result<()> {
+ let ignore_file_path = Path::new(".gitignore");
+ let file_path = Path::new("foo.txt");
+ let (_td, repo) = repo_init()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"test")?;
+ File::create(&root.join(ignore_file_path))?
+ .write_all(b"foo")?;
+
+ add_to_ignore(repo_path, file_path.to_str().unwrap())?;
+
+ let mut lines =
+ read_lines(&root.join(ignore_file_path)).unwrap();
+ assert_eq!(&lines.nth(1).unwrap().unwrap(), "foo.txt");
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs
new file mode 100644
index 0000000..bd25965
--- /dev/null
+++ b/asyncgit/src/sync/logwalker.rs
@@ -0,0 +1,115 @@
+use super::CommitId;
+use crate::error::Result;
+use git2::{Repository, Revwalk};
+
+///
+pub struct LogWalker<'a> {
+ repo: &'a Repository,
+ revwalk: Option<Revwalk<'a>>,
+}
+
+impl<'a> LogWalker<'a> {
+ ///
+ pub fn new(repo: &'a Repository) -> Self {
+ Self {
+ repo,
+ revwalk: None,
+ }
+ }
+
+ ///
+ pub fn read(
+ &mut self,
+ out: &mut Vec<CommitId>,
+ limit: usize,
+ ) -> Result<usize> {
+ let mut count = 0_usize;
+
+ if self.revwalk.is_none() {
+ let mut walk = self.repo.revwalk()?;
+ walk.push_head()?;
+ self.revwalk = Some(walk);
+ }
+
+ if let Some(ref mut walk) = self.revwalk {
+ for id in walk {
+ if let Ok(id) = id {
+ out.push(id.into());
+ count += 1;
+
+ if count == limit {
+ break;
+ }
+ }
+ }
+ }
+
+ Ok(count)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sync::{
+ commit, get_commits_info, stage_add_file,
+ tests::repo_init_empty,
+ };
+ use std::{fs::File, io::Write, path::Path};
+
+ #[test]
+ fn test_limit() -> Result<()> {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"a")?;
+ stage_add_file(repo_path, file_path).unwrap();
+ commit(repo_path, "commit1").unwrap();
+ File::create(&root.join(file_path))?.write_all(b"a")?;
+ stage_add_file(repo_path, file_path).unwrap();
+ let oid2 = commit(repo_path, "commit2").unwrap();
+
+ let mut items = Vec::new();
+ let mut walk = LogWalker::new(&repo);
+ walk.read(&mut items, 1).unwrap();
+
+ assert_eq!(items.len(), 1);
+ assert_eq!(items[0], oid2.into());
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_logwalker() -> Result<()> {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))?.write_all(b"a")?;
+ stage_add_file(repo_path, file_path).unwrap();
+ commit(repo_path, "commit1").unwrap();
+ File::create(&root.join(file_path))?.write_all(b"a")?;
+ stage_add_file(repo_path, file_path).unwrap();
+ let oid2 = commit(repo_path, "commit2").unwrap();
+
+ let mut items = Vec::new();
+ let mut walk = LogWalker::new(&repo);
+ walk.read(&mut items, 100).unwrap();
+
+ let info = get_commits_info(repo_path, &items, 50).unwrap();
+ dbg!(&info);
+
+ assert_eq!(items.len(), 2);
+ assert_eq!(items[0], oid2.into());
+
+ let mut items = Vec::new();
+ walk.read(&mut items, 100).unwrap();
+
+ assert_eq!(items.len(), 0);
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs
new file mode 100644
index 0000000..653248c
--- /dev/null
+++ b/asyncgit/src/sync/mod.rs
@@ -0,0 +1,151 @@
+//! sync git api
+
+//TODO: remove once we have this activated on the toplevel
+#![deny(clippy::expect_used)]
+
+mod branch;
+mod commit;
+mod commit_details;
+mod commit_files;
+mod commits_info;
+pub mod cred;
+pub mod diff;
+mod hooks;
+mod hunks;
+mod ignore;
+mod logwalker;
+mod remotes;
+mod reset;
+mod stash;
+pub mod status;
+mod tags;
+pub mod utils;
+
+pub(crate) use branch::get_branch_name;
+pub use branch::{
+ branch_compare_upstream, checkout_branch, create_branch,
+ delete_branch, get_branches_to_display, rename_branch,
+ BranchCompare, BranchForDisplay,
+};
+pub use commit::{amend, commit, tag};
+pub use commit_details::{
+ get_commit_details, CommitDetails, CommitMessage,
+};
+pub use commit_files::get_commit_files;
+pub use commits_info::{get_commits_info, CommitId, CommitInfo};
+pub use diff::get_diff_commit;
+pub use hooks::{
+ hooks_commit_msg, hooks_post_commit, hooks_pre_commit, HookResult,
+};
+pub use hunks::{reset_hunk, stage_hunk, unstage_hunk};
+pub use ignore::add_to_ignore;
+pub use logwalker::LogWalker;
+pub use remotes::{
+ fetch_origin, get_remotes, push, ProgressNotification,
+ DEFAULT_REMOTE_NAME,
+};
+pub use reset::{reset_stage, reset_workdir};
+pub use stash::{get_stashes, stash_apply, stash_drop, stash_save};
+pub use tags::{get_tags, CommitTags, Tags};
+pub use utils::{
+ get_head, get_head_tuple, is_bare_repo, is_repo, stage_add_all,
+ stage_add_file, stage_addremoved, Head,
+};
+
+#[cfg(test)]
+mod tests {
+ use super::status::{get_status, StatusType};
+ use crate::error::Result;
+ use git2::Repository;
+ use std::process::Command;
+ use tempfile::TempDir;
+
+ ///
+ pub fn repo_init_empty() -> Result<(TempDir, Repository)> {
+ let td = TempDir::new()?;
+ let repo = Repository::init(td.path())?;
+ {
+ let mut config = repo.config()?;
+ config.set_str("user.name", "name")?;
+ config.set_str("user.email", "email")?;
+ }
+ Ok((td, repo))
+ }
+
+ ///
+ pub fn repo_init() -> Result<(TempDir, Repository)> {
+ let td = TempDir::new()?;
+ let repo = Repository::init(td.path())?;
+ {
+ let mut config = repo.config()?;
+ config.set_str("user.name", "name")?;
+ config.set_str("user.email", "email")?;
+
+ let mut index = repo.index()?;
+ let id = index.write_tree()?;
+
+ let tree = repo.find_tree(id)?;
+ let sig = repo.signature()?;
+ repo.commit(
+ Some("HEAD"),
+ &sig,
+ &sig,
+ "initial",
+ &tree,
+ &[],
+ )?;
+ }
+ Ok((td, repo))
+ }
+
+ /// helper returning amount of files with changes in the (wd,stage)
+ pub fn get_statuses(repo_path: &str) -> (usize, usize) {
+ (
+ get_status(repo_path, StatusType::WorkingDir, true)
+ .unwrap()
+ .len(),
+ get_status(repo_path, StatusType::Stage, true)
+ .unwrap()
+ .len(),
+ )
+ }
+
+ ///
+ pub fn debug_cmd_print(path: &str, cmd: &str) {
+ let cmd = debug_cmd(path, cmd);
+ eprintln!("\n----\n{}", cmd);
+ }
+
+ fn debug_cmd(path: &str, cmd: &str) -> String {
+ let output = if cfg!(target_os = "windows") {
+ Command::new("cmd")
+ .args(&["/C", cmd])
+ .current_dir(path)
+ .output()
+ .unwrap()
+ } else {
+ Command::new("sh")
+ .arg("-c")
+ .arg(cmd)
+ .current_dir(path)
+ .output()
+ .unwrap()
+ };
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ format!(
+ "{}{}",
+ if stdout.is_empty() {
+ String::new()
+ } else {
+ format!("out:\n{}", stdout)
+ },
+ if stderr.is_empty() {
+ String::new()
+ } else {
+ format!("err:\n{}", stderr)
+ }
+ )
+ }
+}
diff --git a/asyncgit/src/sync/remotes.rs b/asyncgit/src/sync/remotes.rs
new file mode 100644
index 0000000..c2134b0
--- /dev/null
+++ b/asyncgit/src/sync/remotes.rs
@@ -0,0 +1,254 @@
+//!
+
+use super::{branch::branch_set_upstream, CommitId};
+use crate::{
+ error::Result, sync::cred::BasicAuthCredential, sync::utils,
+};
+use crossbeam_channel::Sender;
+use git2::{
+ Cred, Error as GitError, FetchOptions, PackBuilderStage,
+ PushOptions, RemoteCallbacks,
+};
+use scopetime::scope_time;
+
+///
+#[derive(Debug, Clone)]
+pub enum ProgressNotification {
+ ///
+ UpdateTips {
+ ///
+ name: String,
+ ///
+ a: CommitId,
+ ///
+ b: CommitId,
+ },
+ ///
+ Transfer {
+ ///
+ objects: usize,
+ ///
+ total_objects: usize,
+ },
+ ///
+ PushTransfer {
+ ///
+ current: usize,
+ ///
+ total: usize,
+ ///
+ bytes: usize,
+ },
+ ///
+ Packing {
+ ///
+ stage: PackBuilderStage,
+ ///
+ total: usize,
+ ///
+ current: usize,
+ },
+ ///
+ Done,
+}
+
+///
+pub const DEFAULT_REMOTE_NAME: &str = "origin";
+
+///
+pub fn get_remotes(repo_path: &str) -> Result<Vec<String>> {
+ scope_time!("get_remotes");
+
+ let repo = utils::repo(repo_path)?;
+ let remotes = repo.remotes()?;
+ let remotes: Vec<String> =
+ remotes.iter().filter_map(|s| s).map(String::from).collect();
+
+ Ok(remotes)
+}
+
+///
+pub fn fetch_origin(repo_path: &str, branch: &str) -> Result<usize> {
+ scope_time!("fetch_origin");
+
+ let repo = utils::repo(repo_path)?;
+ let mut remote = repo.find_remote(DEFAULT_REMOTE_NAME)?;
+
+ let mut options = FetchOptions::new();
+ options.remote_callbacks(remote_callbacks(None, None));
+
+ remote.fetch(&[branch], Some(&mut options), None)?;
+
+ Ok(remote.stats().received_bytes())
+}
+
+///
+pub fn push(
+ repo_path: &str,
+ remote: &str,
+ branch: &str,
+ basic_credential: Option<BasicAuthCredential>,
+ progress_sender: Sender<ProgressNotification>,
+) -> Result<()> {
+ scope_time!("push");
+
+ let repo = utils::repo(repo_path)?;
+ let mut remote = repo.find_remote(remote)?;
+
+ let mut options = PushOptions::new();
+
+ options.remote_callbacks(remote_callbacks(
+ Some(progress_sender),
+ basic_credential,
+ ));
+ options.packbuilder_parallelism(0);
+
+ let branch_name = format!("refs/heads/{}", branch);
+
+ remote.push(&[branch_name.as_str()], Some(&mut options))?;
+
+ branch_set_upstream(&repo, branch)?;
+
+ Ok(())
+}
+
+fn remote_callbacks<'a>(
+ sender: Option<Sender<ProgressNotification>>,
+ basic_credential: Option<BasicAuthCredential>,
+) -> RemoteCallbacks<'a> {
+ let mut callbacks = RemoteCallbacks::new();
+ let sender_clone = sender.clone();
+ callbacks.push_transfer_progress(move |current, total, bytes| {
+ log::debug!("progress: {}/{} ({} B)", current, total, bytes,);
+
+ sender_clone.clone().map(|sender| {
+ sender.send(ProgressNotification::PushTransfer {
+ current,
+ total,
+ bytes,
+ })
+ });
+ });
+
+ let sender_clone = sender.clone();
+ callbacks.update_tips(move |name, a, b| {
+ log::debug!("update tips: '{}' [{}] [{}]", name, a, b);
+
+ sender_clone.clone().map(|sender| {
+ sender.send(ProgressNotification::UpdateTips {
+ name: name.to_string(),
+ a: a.into(),
+ b: b.into(),
+ })
+ });
+ true
+ });
+
+ let sender_clone = sender.clone();
+ callbacks.transfer_progress(move |p| {
+ log::debug!(
+ "transfer: {}/{}",
+ p.received_objects(),
+ p.total_objects()
+ );
+
+ sender_clone.clone().map(|sender| {
+ sender.send(ProgressNotification::Transfer {
+ objects: p.received_objects(),
+ total_objects: p.total_objects(),
+ })
+ });
+ true
+ });
+
+ callbacks.pack_progress(move |stage, current, total| {
+ log::debug!("packing: {:?} - {}/{}", stage, current, total);
+
+ sender.clone().map(|sender| {
+ sender.send(ProgressNotification::Packing {
+ stage,
+ total,
+ current,
+ })
+ });
+ });
+
+ let mut first_call_to_credentials = true;
+ // This boolean is used to avoid multiple calls to credentials callback.
+ // If credentials are bad, we don't ask the user to re-fill their creds. We push an error and they will be able to restart their action (for example a push) and retype their creds.
+ // This behavior is explained in a issue on git2-rs project : https://github.com/rust-lang/git2-rs/issues/347
+ // An implementation reference is done in cargo : https://github.com/rust-lang/cargo/blob/9fb208dddb12a3081230a5fd8f470e01df8faa25/src/cargo/sources/git/utils.rs#L588
+ // There is also a guide about libgit2 authentication : https://libgit2.org/docs/guides/authentication/
+ callbacks.credentials(
+ move |url, username_from_url, allowed_types| {
+ log::debug!(
+ "creds: '{}' {:?} ({:?})",
+ url,
+ username_from_url,
+ allowed_types
+ );
+ if first_call_to_credentials {
+ first_call_to_credentials = false;
+ } else {
+ return Err(GitError::from_str("Bad credentials."));
+ }
+
+ match &basic_credential {
+ _ if allowed_types.is_ssh_key() => {
+ match username_from_url {
+ Some(username) => {
+ Cred::ssh_key_from_agent(username)
+ }
+ None => Err(GitError::from_str(
+ " Couldn't extract username from url.",
+ )),
+ }
+ }
+ Some(BasicAuthCredential {
+ username: Some(user),
+ password: Some(pwd),
+ }) if allowed_types.is_user_pass_plaintext() => {
+ Cred::userpass_plaintext(&user, &pwd)
+ }
+ Some(BasicAuthCredential {
+ username: Some(user),
+ password: _,
+ }) if allowed_types.is_username() => {
+ Cred::username(user)
+ }
+ _ if allowed_types.is_default() => Cred::default(),
+ _ => Err(GitError::from_str(
+ "Couldn't find credentials",
+ )),
+ }
+ },
+ );
+
+ callbacks
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sync::tests::debug_cmd_print;
+ use tempfile::TempDir;
+
+ #[test]
+ fn test_smoke() {
+ let td = TempDir::new().unwrap();
+
+ debug_cmd_print(
+ td.path().as_os_str().to_str().unwrap(),
+ "git clone https://github.com/extrawurst/brewdump.git",
+ );
+
+ let repo_path = td.path().join("brewdump");
+ let repo_path = repo_path.as_os_str().to_str().unwrap();
+
+ let remotes = get_remotes(repo_path).unwrap();
+
+ assert_eq!(remotes, vec![String::from(DEFAULT_REMOTE_NAME)]);
+
+ fetch_origin(repo_path, "master").unwrap();
+ }
+}
diff --git a/asyncgit/src/sync/reset.rs b/asyncgit/src/sync/reset.rs
new file mode 100644
index 0000000..93c6eb0
--- /dev/null
+++ b/asyncgit/src/sync/reset.rs
@@ -0,0 +1,314 @@
+use super::utils::{get_head_repo, repo};
+use crate::error::Result;
+use git2::{build::CheckoutBuilder, ObjectType};
+use scopetime::scope_time;
+
+///
+pub fn reset_stage(repo_path: &str, path: &str) -> Result<()> {
+ scope_time!("reset_stage");
+
+ let repo = repo(repo_path)?;
+
+ if let Ok(id) = get_head_repo(&repo) {
+ let obj =
+ repo.find_object(id.into(), Some(ObjectType::Commit))?;
+
+ repo.reset_default(Some(&obj), &[path])?;
+ } else {
+ repo.reset_default(None, &[path])?;
+ }
+
+ Ok(())
+}
+
+///
+pub fn reset_workdir(repo_path: &str, path: &str) -> Result<()> {
+ scope_time!("reset_workdir");
+
+ let repo = repo(repo_path)?;
+
+ let mut checkout_opts = CheckoutBuilder::new();
+ checkout_opts
+ .update_index(true) // windows: needs this to be true WTF?!
+ .remove_untracked(true)
+ .force()
+ .path(path);
+
+ repo.checkout_index(None, Some(&mut checkout_opts))?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{reset_stage, reset_workdir};
+ use crate::error::Result;
+ use crate::sync::{
+ commit,
+ status::{get_status, StatusType},
+ tests::{
+ debug_cmd_print, get_statuses, repo_init, repo_init_empty,
+ },
+ utils::{stage_add_all, stage_add_file},
+ };
+ use std::{
+ fs::{self, File},
+ io::Write,
+ path::Path,
+ };
+
+ static HUNK_A: &str = r"
+1 start
+2
+3
+4
+5
+6 middle
+7
+8
+9
+0
+1 end";
+
+ static HUNK_B: &str = r"
+1 start
+2 newa
+3
+4
+5
+6 middle
+7
+8
+9
+0 newb
+1 end";
+
+ #[test]
+ fn test_reset_only_unstaged() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let res = get_status(repo_path, StatusType::WorkingDir, true)
+ .unwrap();
+ assert_eq!(res.len(), 0);
+
+ let file_path = root.join("bar.txt");
+
+ {
+ File::create(&file_path)
+ .unwrap()
+ .write_all(HUNK_A.as_bytes())
+ .unwrap();
+ }
+
+ debug_cmd_print(repo_path, "git status");
+
+ stage_add_file(repo_path, Path::new("bar.txt")).unwrap();
+
+ debug_cmd_print(repo_path, "git status");
+
+ // overwrite with next content
+ {
+ File::create(&file_path)
+ .unwrap()
+ .write_all(HUNK_B.as_bytes())
+ .unwrap();
+ }
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (1, 1));
+
+ reset_workdir(repo_path, "bar.txt").unwrap();
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (0, 1));
+ }
+
+ #[test]
+ fn test_reset_untracked_in_subdir() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ {
+ fs::create_dir(&root.join("foo")).unwrap();
+ File::create(&root.join("foo/bar.txt"))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+ }
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ reset_workdir(repo_path, "foo/bar.txt").unwrap();
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+ }
+
+ #[test]
+ fn test_reset_folder() -> Result<()> {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ {
+ fs::create_dir(&root.join("foo"))?;
+ File::create(&root.join("foo/file1.txt"))?
+ .write_all(b"file1")?;
+ File::create(&root.join("foo/file2.txt"))?
+ .write_all(b"file1")?;
+ File::create(&root.join("file3.txt"))?
+ .write_all(b"file3")?;
+ }
+
+ stage_add_all(repo_path, "*").unwrap();
+ commit(repo_path, "msg").unwrap();
+
+ {
+ File::create(&root.join("foo/file1.txt"))?
+ .write_all(b"file1\nadded line")?;
+ fs::remove_file(&root.join("foo/file2.txt"))?;
+ File::create(&root.join("foo/file4.txt"))?
+ .write_all(b"file4")?;
+ File::create(&root.join("foo/file5.txt"))?
+ .write_all(b"file5")?;
+ File::create(&root.join("file3.txt"))?
+ .write_all(b"file3\nadded line")?;
+ }
+
+ assert_eq!(get_statuses(repo_path), (5, 0));
+
+ stage_add_file(repo_path, Path::new("foo/file5.txt"))
+ .unwrap();
+
+ assert_eq!(get_statuses(repo_path), (4, 1));
+
+ reset_workdir(repo_path, "foo").unwrap();
+
+ assert_eq!(get_statuses(repo_path), (1, 1));
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_reset_untracked_in_subdir_and_index() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+ let file = "foo/bar.txt";
+
+ {
+ fs::create_dir(&root.join("foo")).unwrap();
+ File::create(&root.join(file))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+ }
+
+ debug_cmd_print(repo_path, "git status");
+
+ debug_cmd_print(repo_path, "git add .");
+
+ debug_cmd_print(repo_path, "git status");
+
+ {
+ File::create(&root.join(file))
+ .unwrap()
+ .write_all(b"test\nfoo\nnewend")
+ .unwrap();
+ }
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (1, 1));
+
+ reset_workdir(repo_path, file).unwrap();
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (0, 1));
+ }
+
+ #[test]
+ fn unstage_in_empty_repo() {
+ let file_path = Path::new("foo.txt");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ assert_eq!(get_statuses(repo_path), (0, 1));
+
+ reset_stage(repo_path, file_path.to_str().unwrap()).unwrap();
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+ }
+
+ #[test]
+ fn test_reset_untracked_in_subdir_with_cwd_in_subdir() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ {
+ fs::create_dir(&root.join("foo")).unwrap();
+ File::create(&root.join("foo/bar.txt"))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+ }
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ reset_workdir(
+ &root.join("foo").as_os_str().to_str().unwrap(),
+ "foo/bar.txt",
+ )
+ .unwrap();
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+ }
+
+ #[test]
+ fn test_reset_untracked_subdir() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ {
+ fs::create_dir_all(&root.join("foo/bar")).unwrap();
+ File::create(&root.join("foo/bar/baz.txt"))
+ .unwrap()
+ .write_all(b"test\nfoo")
+ .unwrap();
+ }
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ reset_workdir(repo_path, "foo/bar").unwrap();
+
+ debug_cmd_print(repo_path, "git status");
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+ }
+}
diff --git a/asyncgit/src/sync/stash.rs b/asyncgit/src/sync/stash.rs
new file mode 100644
index 0000000..fbd268f
--- /dev/null
+++ b/asyncgit/src/sync/stash.rs
@@ -0,0 +1,214 @@
+use super::{utils::repo, CommitId};
+use crate::error::{Error, Result};
+use git2::{Oid, Repository, StashFlags};
+use scopetime::scope_time;
+
+///
+pub fn get_stashes(repo_path: &str) -> Result<Vec<CommitId>> {
+ scope_time!("get_stashes");
+
+ let mut repo = repo(repo_path)?;
+
+ let mut list = Vec::new();
+
+ repo.stash_foreach(|_index, _msg, id| {
+ list.push((*id).into());
+ true
+ })?;
+
+ Ok(list)
+}
+
+/// checks whether a given commit is a stash commit.
+pub fn is_stash_commit(
+ repo_path: &str,
+ id: &CommitId,
+) -> Result<bool> {
+ let stashes = get_stashes(repo_path)?;
+ Ok(stashes.contains(&id))
+}
+
+///
+pub fn stash_drop(repo_path: &str, stash_id: CommitId) -> Result<()> {
+ scope_time!("stash_drop");
+
+ let mut repo = repo(repo_path)?;
+
+ let index = get_stash_index(&mut repo, stash_id.into())?;
+
+ repo.stash_drop(index)?;
+
+ Ok(())
+}
+
+///
+pub fn stash_apply(
+ repo_path: &str,
+ stash_id: CommitId,
+) -> Result<()> {
+ scope_time!("stash_apply");
+
+ let mut repo = repo(repo_path)?;
+
+ let index = get_stash_index(&mut repo, stash_id.get_oid())?;
+
+ repo.stash_apply(index, None)?;
+
+ Ok(())
+}
+
+fn get_stash_index(
+ repo: &mut Repository,
+ stash_id: Oid,
+) -> Result<usize> {
+ let mut idx = None;
+
+ repo.stash_foreach(|index, _msg, id| {
+ if *id == stash_id {
+ idx = Some(index);
+ false
+ } else {
+ true
+ }
+ })?;
+
+ idx.ok_or_else(|| {
+ Error::Generic("stash commit not found".to_string())
+ })
+}
+
+///
+pub fn stash_save(
+ repo_path: &str,
+ message: Option<&str>,
+ include_untracked: bool,
+ keep_index: bool,
+) -> Result<CommitId> {
+ scope_time!("stash_save");
+
+ let mut repo = repo(repo_path)?;
+
+ let sig = repo.signature()?;
+
+ let mut options = StashFlags::DEFAULT;
+
+ if include_untracked {
+ options.insert(StashFlags::INCLUDE_UNTRACKED);
+ }
+ if keep_index {
+ options.insert(StashFlags::KEEP_INDEX)
+ }
+
+ let id = repo.stash_save2(&sig, message, Some(options))?;
+
+ Ok(CommitId::new(id))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sync::{
+ commit, get_commit_files, get_commits_info, stage_add_file,
+ tests::{debug_cmd_print, get_statuses, repo_init},
+ };
+ use std::{fs::File, io::Write, path::Path};
+
+ #[test]
+ fn test_smoke() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(
+ stash_save(repo_path, None, true, false).is_ok(),
+ false
+ );
+
+ assert_eq!(get_stashes(repo_path).unwrap().is_empty(), true);
+ }
+
+ #[test]
+ fn test_stashing() -> Result<()> {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join("foo.txt"))?
+ .write_all(b"test\nfoo")?;
+
+ assert_eq!(get_statuses(repo_path), (1, 0));
+
+ stash_save(repo_path, None, true, false)?;
+
+ assert_eq!(get_statuses(repo_path), (0, 0));
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_stashes() -> Result<()> {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join("foo.txt"))?
+ .write_all(b"test\nfoo")?;
+
+ stash_save(repo_path, Some("foo"), true, false)?;
+
+ let res = get_stashes(repo_path)?;
+
+ assert_eq!(res.len(), 1);
+
+ let infos =
+ get_commits_info(repo_path, &[res[0]], 100).unwrap();
+
+ assert_eq!(infos[0].message, "On master: foo");
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_stash_nothing_untracked() -> Result<()> {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join("foo.txt"))?
+ .write_all(b"test\nfoo")?;
+
+ assert!(
+ stash_save(repo_path, Some("foo"), false, false).is_err()
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_stash_without_2nd_parent() -> Result<()> {
+ let file_path1 = Path::new("file1.txt");
+ let (_td, repo) = repo_init()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path1))?.write_all(b"test")?;
+ stage_add_file(repo_path, file_path1)?;
+ commit(repo_path, "c1")?;
+
+ File::create(&root.join(file_path1))?
+ .write_all(b"modified")?;
+
+ //NOTE: apparently `libgit2` works differently to git stash in
+ //always creating the third parent for untracked files while the
+ //cli skips that step when no new files exist
+ debug_cmd_print(repo_path, "git stash");
+
+ let stash = get_stashes(repo_path)?[0];
+
+ let diff = get_commit_files(repo_path, stash)?;
+
+ assert_eq!(diff.len(), 1);
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs
new file mode 100644
index 0000000..0aad1cf
--- /dev/null
+++ b/asyncgit/src/sync/status.rs
@@ -0,0 +1,142 @@
+//! sync git api for fetching a status
+
+use crate::{error::Error, error::Result, sync::utils};
+use git2::{Delta, Status, StatusOptions, StatusShow};
+use scopetime::scope_time;
+use std::path::Path;
+
+///
+#[derive(Copy, Clone, Hash, PartialEq, Debug)]
+pub enum StatusItemType {
+ ///
+ New,
+ ///
+ Modified,
+ ///
+ Deleted,
+ ///
+ Renamed,
+ ///
+ Typechange,
+}
+
+impl From<Status> for StatusItemType {
+ fn from(s: Status) -> Self {
+ if s.is_index_new() || s.is_wt_new() {
+ Self::New
+ } else if s.is_index_deleted() || s.is_wt_deleted() {
+ Self::Deleted
+ } else if s.is_index_renamed() || s.is_wt_renamed() {
+ Self::Renamed
+ } else if s.is_index_typechange() || s.is_wt_typechange() {
+ Self::Typechange
+ } else {
+ Self::Modified
+ }
+ }
+}
+
+impl From<Delta> for StatusItemType {
+ fn from(d: Delta) -> Self {
+ match d {
+ Delta::Added => StatusItemType::New,
+ Delta::Deleted => StatusItemType::Deleted,
+ Delta::Renamed => StatusItemType::Renamed,
+ Delta::Typechange => StatusItemType::Typechange,
+ _ => StatusItemType::Modified,
+ }
+ }
+}
+
+///
+#[derive(Clone, Hash, PartialEq, Debug)]
+pub struct StatusItem {
+ ///
+ pub path: String,
+ ///
+ pub status: StatusItemType,
+}
+
+///
+#[derive(Copy, Clone, Hash, PartialEq, Debug)]
+pub enum StatusType {
+ ///
+ WorkingDir,
+ ///
+ Stage,
+ ///
+ Both,
+}
+
+impl Default for StatusType {
+ fn default() -> Self {
+ StatusType::WorkingDir
+ }
+}
+
+impl Into<StatusShow> for StatusType {
+ fn into(self) -> StatusShow {
+ match self {
+ StatusType::WorkingDir => StatusShow::Workdir,
+ StatusType::Stage => StatusShow::Index,
+ StatusType::Both => StatusShow::IndexAndWorkdir,
+ }
+ }
+}
+
+///
+pub fn get_status(
+ repo_path: &str,
+ status_type: StatusType,
+ include_untracked: bool,
+) -> Result<Vec<StatusItem>> {
+ scope_time!("get_status");
+
+ let repo = utils::repo(repo_path)?;
+
+ let statuses = repo.statuses(Some(
+ StatusOptions::default()
+ .show(status_type.into())
+ .update_index(true)
+ .include_untracked(include_untracked)
+ .renames_head_to_index(true)
+ .recurse_untracked_dirs(true),
+ ))?;
+
+ let mut res = Vec::with_capacity(statuses.len());
+
+ for e in statuses.iter() {
+ let status: Status = e.status();
+
+ let path = match e.head_to_index() {
+ Some(diff) => diff
+ .new_file()
+ .path()
+ .and_then(|x| x.to_str())
+ .map(String::from)
+ .ok_or_else(|| {
+ Error::Generic(
+ "failed to get path to diff's new file."
+ .to_string(),
+ )
+ })?,
+ None => e.path().map(String::from).ok_or_else(|| {
+ Error::Generic(
+ "failed to get the path to indexed file."
+ .to_string(),
+ )
+ })?,
+ };
+
+ res.push(StatusItem {
+ path,
+ status: StatusItemType::from(status),
+ });
+ }
+
+ res.sort_by(|a, b| {
+ Path::new(a.path.as_str()).cmp(Path::new(b.path.as_str()))
+ });
+
+ Ok(res)
+}
diff --git a/asyncgit/src/sync/tags.rs b/asyncgit/src/sync/tags.rs
new file mode 100644
index 0000000..ebe7a27
--- /dev/null
+++ b/asyncgit/src/sync/tags.rs
@@ -0,0 +1,86 @@
+use super::{utils::repo, CommitId};
+use crate::error::Result;
+use scopetime::scope_time;
+use std::collections::BTreeMap;
+
+/// all tags pointing to a single commit
+pub type CommitTags = Vec<String>;
+/// hashmap of tag target commit hash to tag names
+pub type Tags = BTreeMap<CommitId, CommitTags>;
+
+/// returns `Tags` type filled with all tags found in repo
+pub fn get_tags(repo_path: &str) -> Result<Tags> {
+ scope_time!("get_tags");
+
+ let mut res = Tags::new();
+ let mut adder = |key, value: String| {
+ if let Some(key) = res.get_mut(&key) {
+ key.push(value)
+ } else {
+ res.insert(key, vec![value]);
+ }
+ };
+
+ let repo = repo(repo_path)?;
+
+ repo.tag_foreach(|id, name| {
+ if let Ok(name) =
+ // skip the `refs/tags/` part
+ String::from_utf8(name[10..name.len()].into())
+ {
+ //NOTE: find_tag (git_tag_lookup) only works on annotated tags
+ // lightweight tags `id` already points to the target commit
+ // see https://github.com/libgit2/libgit2/issues/5586
+ if let Ok(tag) = repo.find_tag(id) {
+ adder(CommitId::new(tag.target_id()), name);
+ } else if repo.find_commit(id).is_ok() {
+ adder(CommitId::new(id), name);
+ }
+
+ return true;
+ }
+ false
+ })?;
+
+ Ok(res)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sync::tests::repo_init;
+ use git2::ObjectType;
+
+ #[test]
+ fn test_smoke() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(get_tags(repo_path).unwrap().is_empty(), true);
+ }
+
+ #[test]
+ fn test_multitags() {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let sig = repo.signature().unwrap();
+ let head_id = repo.head().unwrap().target().unwrap();
+ let target = repo
+ .find_object(
+ repo.head().unwrap().target().unwrap(),
+ Some(ObjectType::Commit),
+ )
+ .unwrap();
+
+ repo.tag("a", &target, &sig, "", false).unwrap();
+ repo.tag("b", &target, &sig, "", false).unwrap();
+
+ assert_eq!(
+ get_tags(repo_path).unwrap()[&CommitId::new(head_id)],
+ vec!["a", "b"]
+ );
+ }
+}
diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs
new file mode 100644
index 0000000..f1ecc4f
--- /dev/null
+++ b/asyncgit/src/sync/utils.rs
@@ -0,0 +1,357 @@
+//! sync git api (various methods)
+
+use super::CommitId;
+use crate::error::{Error, Result};
+use git2::{IndexAddOption, Repository, RepositoryOpenFlags};
+use scopetime::scope_time;
+use std::path::Path;
+
+///
+#[derive(PartialEq, Debug, Clone)]
+pub struct Head {
+ ///
+ pub name: String,
+ ///
+ pub id: CommitId,
+}
+
+///
+pub fn is_repo(repo_path: &str) -> bool {
+ Repository::open_ext(
+ repo_path,
+ RepositoryOpenFlags::empty(),
+ Vec::<&Path>::new(),
+ )
+ .is_ok()
+}
+
+/// checks if the git repo at path `repo_path` is a bare repo
+pub fn is_bare_repo(repo_path: &str) -> Result<bool> {
+ let repo = Repository::open_ext(
+ repo_path,
+ RepositoryOpenFlags::empty(),
+ Vec::<&Path>::new(),
+ )?;
+
+ Ok(repo.is_bare())
+}
+
+///
+pub(crate) fn repo(repo_path: &str) -> Result<Repository> {
+ let repo = Repository::open_ext(
+ repo_path,
+ RepositoryOpenFlags::empty(),
+ Vec::<&Path>::new(),
+ )?;
+
+ if repo.is_bare() {
+ return Err(Error::Generic("bare repo".to_string()));
+ }
+
+ Ok(repo)
+}
+
+///
+pub(crate) fn work_dir(repo: &Repository) -> Result<&Path> {
+ repo.workdir().map_or(Err(Error::NoWorkDir), |dir| Ok(dir))
+}
+
+///
+pub fn repo_work_dir(repo_path: &str) -> Result<String> {
+ let repo = repo(repo_path)?;
+ if let Some(workdir) = work_dir(&repo)?.to_str() {
+ Ok(workdir.to_string())
+ } else {
+ Err(Error::Generic("invalid workdir".to_string()))
+ }
+}
+
+///
+pub fn get_head(repo_path: &str) -> Result<CommitId> {
+ let repo = repo(repo_path)?;
+ get_head_repo(&repo)
+}
+
+///
+pub fn get_head_tuple(repo_path: &str) -> Result<Head> {
+ let repo = repo(repo_path)?;
+ let id = get_head_repo(&repo)?;
+ let name = get_head_refname(&repo)?;
+
+ Ok(Head { name, id })
+}
+
+///
+pub fn get_head_refname(repo: &Repository) -> Result<String> {
+ let head = repo.head()?;
+ let ref_name = bytes2string(head.name_bytes())?;
+
+ Ok(ref_name)
+}
+
+///
+pub fn get_head_repo(repo: &Repository) -> Result<CommitId> {
+ scope_time!("get_head_repo");
+
+ let head = repo.head()?.target();
+
+ if let Some(head_id) = head {
+ Ok(head_id.into())
+ } else {
+ Err(Error::NoHead)
+ }
+}
+
+/// add a file diff from workingdir to stage (will not add removed files see `stage_addremoved`)
+pub fn stage_add_file(repo_path: &str, path: &Path) -> Result<()> {
+ scope_time!("stage_add_file");
+
+ let repo = repo(repo_path)?;
+
+ let mut index = repo.index()?;
+
+ index.add_path(path)?;
+ index.write()?;
+
+ Ok(())
+}
+
+/// like `stage_add_file` but uses a pattern to match/glob multiple files/folders
+pub fn stage_add_all(repo_path: &str, pattern: &str) -> Result<()> {
+ scope_time!("stage_add_all");
+
+ let repo = repo(repo_path)?;
+
+ let mut index = repo.index()?;
+
+ index.add_all(vec![pattern], IndexAddOption::DEFAULT, None)?;
+ index.write()?;
+
+ Ok(())
+}
+
+/// stage a removed file
+pub fn stage_addremoved(repo_path: &str, path: &Path) -> Result<()> {
+ scope_time!("stage_addremoved");
+
+ let repo = repo(repo_path)?;
+
+ let mut index = repo.index()?;
+
+ index.remove_path(path)?;
+ index.write()?;
+
+ Ok(())
+}
+
+/// get string from config
+pub fn get_config_string(
+ repo_path: &str,
+ key: &str,
+) -> Result<Option<String>> {
+ let repo = repo(repo_path)?;
+ let cfg = repo.config()?;
+
+ // this code doesnt match what the doc says regarding what
+ // gets returned when but it actually works
+ let entry_res = cfg.get_entry(key);
+
+ let entry = match entry_res {
+ Ok(ent) => ent,
+ Err(_) => return Ok(None),
+ };
+
+ if !entry.has_value() {
+ Ok(None)
+ } else {
+ Ok(entry.value().map(|s| s.to_string()))
+ }
+}
+/// helper function
+pub(crate) fn bytes2string(bytes: &[u8]) -> Result<String> {
+ Ok(String::from_utf8(bytes.to_vec())?)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sync::{
+ commit,
+ status::{get_status, StatusType},
+ tests::{
+ debug_cmd_print, get_statuses, repo_init, repo_init_empty,
+ },
+ };
+ use std::{
+ fs::{self, remove_file, File},
+ io::Write,
+ path::Path,
+ };
+
+ #[test]
+ fn test_stage_add_smoke() {
+ let file_path = Path::new("foo");
+ let (_td, repo) = repo_init_empty().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(
+ stage_add_file(repo_path, file_path).is_ok(),
+ false
+ );
+ }
+ #[test]
+ fn test_get_config() {
+ let bad_dir_cfg =
+ get_config_string("oodly_noodly", "this.doesnt.exist");
+ assert!(bad_dir_cfg.is_err());
+
+ let (_td, repo) = repo_init().unwrap();
+ let path = repo.path();
+ let rpath = path.as_os_str().to_str().unwrap();
+ let bad_cfg = get_config_string(rpath, "this.doesnt.exist");
+ assert!(bad_cfg.is_ok());
+ assert!(bad_cfg.unwrap().is_none());
+ // repo init sets user.name
+ let good_cfg = get_config_string(rpath, "user.name");
+ assert!(good_cfg.is_ok());
+ assert!(good_cfg.unwrap().is_some());
+ }
+ #[test]
+ fn test_staging_one_file() {
+ let file_path = Path::new("file1.txt");
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ File::create(&root.join(file_path))
+ .unwrap()
+ .write_all(b"test file1 content")
+ .unwrap();
+
+ File::create(&root.join(Path::new("file2.txt")))
+ .unwrap()
+ .write_all(b"test file2 content")
+ .unwrap();
+
+ assert_eq!(get_statuses(repo_path), (2, 0));
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ assert_eq!(get_statuses(repo_path), (1, 1));
+ }
+
+ #[test]
+ fn test_staging_folder() -> Result<()> {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let status_count = |s: StatusType| -> usize {
+ get_status(repo_path, s, true).unwrap().len()
+ };
+
+ fs::create_dir_all(&root.join("a/d"))?;
+ File::create(&root.join(Path::new("a/d/f1.txt")))?
+ .write_all(b"foo")?;
+ File::create(&root.join(Path::new("a/d/f2.txt")))?
+ .write_all(b"foo")?;
+ File::create(&root.join(Path::new("a/f3.txt")))?
+ .write_all(b"foo")?;
+
+ assert_eq!(status_count(StatusType::WorkingDir), 3);
+
+ stage_add_all(repo_path, "a/d").unwrap();
+
+ assert_eq!(status_count(StatusType::WorkingDir), 1);
+ assert_eq!(status_count(StatusType::Stage), 2);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_staging_deleted_file() {
+ let file_path = Path::new("file1.txt");
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let status_count = |s: StatusType| -> usize {
+ get_status(repo_path, s, true).unwrap().len()
+ };
+
+ let full_path = &root.join(file_path);
+
+ File::create(full_path)
+ .unwrap()
+ .write_all(b"test file1 content")
+ .unwrap();
+
+ stage_add_file(repo_path, file_path).unwrap();
+
+ commit(repo_path, "commit msg").unwrap();
+
+ // delete the file now
+ assert_eq!(remove_file(full_path).is_ok(), true);
+
+ // deleted file in diff now
+ assert_eq!(status_count(StatusType::WorkingDir), 1);
+
+ stage_addremoved(repo_path, file_path).unwrap();
+
+ assert_eq!(status_count(StatusType::WorkingDir), 0);
+ assert_eq!(status_count(StatusType::Stage), 1);
+ }
+
+ // see https://github.com/extrawurst/gitui/issues/108
+ #[test]
+ fn test_staging_sub_git_folder() -> Result<()> {
+ let (_td, repo) = repo_init().unwrap();
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ let status_count = |s: StatusType| -> usize {
+ get_status(repo_path, s, true).unwrap().len()
+ };
+
+ let sub = &root.join("sub");
+
+ fs::create_dir_all(sub)?;
+
+ debug_cmd_print(sub.to_str().unwrap(), "git init subgit");
+
+ File::create(sub.join("subgit/foo.txt"))
+ .unwrap()
+ .write_all(b"content")
+ .unwrap();
+
+ assert_eq!(status_count(StatusType::WorkingDir), 1);
+
+ //expect to fail
+ assert!(stage_add_all(repo_path, "sub").is_err());
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_head_empty() -> Result<()> {
+ let (_td, repo) = repo_init_empty()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(get_head(repo_path).is_ok(), false);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_head() -> Result<()> {
+ let (_td, repo) = repo_init()?;
+ let root = repo.path().parent().unwrap();
+ let repo_path = root.as_os_str().to_str().unwrap();
+
+ assert_eq!(get_head(repo_path).is_ok(), true);
+
+ Ok(())
+ }
+}
diff --git a/asyncgit/src/tags.rs b/asyncgit/src/tags.rs
new file mode 100644
index 0000000..f23b54a
--- /dev/null
+++ b/asyncgit/src/tags.rs
@@ -0,0 +1,128 @@
+use crate::{
+ error::Result,
+ hash,
+ sync::{self},
+ AsyncNotification, CWD,
+};
+use crossbeam_channel::Sender;
+use std::{
+ sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc, Mutex,
+ },
+ time::{Duration, Instant},
+};
+use sync::Tags;
+
+///
+#[derive(Default, Clone)]
+struct TagsResult {
+ hash: u64,
+ tags: Tags,
+}
+
+///
+pub struct AsyncTags {
+ last: Arc<Mutex<Option<(Instant, TagsResult)>>>,
+ sender: Sender<AsyncNotification>,
+ pending: Arc<AtomicUsize>,
+}
+
+impl AsyncTags {
+ ///
+ pub fn new(sender: &Sender<AsyncNotification>) -> Self {
+ Self {
+ last: Arc::new(Mutex::new(None)),
+ sender: sender.clone(),
+ pending: Arc::new(AtomicUsize::new(0)),
+ }
+ }
+
+ /// last fetched result
+ pub fn last(&mut self) -> Result<Option<Tags>> {
+ let last = self.last.lock()?;
+
+ Ok(last.clone().map(|last| last.1.tags))
+ }
+
+ ///
+ pub fn is_pending(&self) -> bool {
+ self.pending.load(Ordering::Relaxed) > 0
+ }
+
+ fn is_outdated(&self, dur: Duration) -> Result<bool> {
+ let last = self.last.lock()?;
+
+ Ok(last
+ .as_ref()
+ .map(|(last_time, _)| last_time.elapsed() > dur)
+ .unwrap_or(true))
+ }
+
+ ///
+ pub fn request(
+ &mut self,
+ dur: Duration,
+ force: bool,
+ ) -> Result<()> {
+ log::trace!("request");
+
+ if !force && (self.is_pending() || !self.is_outdated(dur)?) {
+ return Ok(());
+ }
+
+ let arc_last = Arc::clone(&self.last);
+ let sender = self.sender.clone();
+ let arc_pending = Arc::clone(&self.pending);
+
+ self.pending.fetch_add(1, Ordering::Relaxed);
+
+ rayon_core::spawn(move || {
+ let notify = AsyncTags::getter(arc_last)
+ .expect("error getting tags");
+
+ arc_pending.fetch_sub(1, Ordering::Relaxed);
+
+ sender
+ .send(if notify {
+ AsyncNotification::Tags
+ } else {
+ AsyncNotification::FinishUnchanged
+ })
+ .expect("error sending notify");
+ });
+
+ Ok(())
+ }
+
+ fn getter(
+ arc_last: Arc<Mutex<Option<(Instant, TagsResult)>>>,
+ ) -> Result<bool> {
+ let tags = sync::get_tags(CWD)?;
+
+ let hash = hash(&tags);
+
+ if Self::last_hash(arc_last.clone())
+ .map(|last| last == hash)
+ .unwrap_or_default()
+ {
+ return Ok(false);
+ }
+
+ {
+ let mut last = arc_last.lock()?;
+ let now = Instant::now();
+ *last = Some((now, TagsResult { tags, hash }));
+ }
+
+ Ok(true)
+ }
+
+ fn last_hash(
+ last: Arc<Mutex<Option<(Instant, TagsResult)>>>,
+ ) -> Option<u64> {
+ last.lock()
+ .ok()
+ .and_then(|last| last.as_ref().map(|(_, last)| last.hash))
+ }
+}
diff --git a/invalidstring/Cargo.toml b/invalidstring/Cargo.toml
new file mode 100644
index 0000000..fb30649
--- /dev/null
+++ b/invalidstring/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "invalidstring"
+version = "0.1.2"
+authors = ["Stephan Dilly <dilly.stephan@gmail.com>"]
+edition = "2018"
+description = "just for testing invalid string data"
+homepage = "https://github.com/extrawurst/gitui"
+repository = "https://github.com/extrawurst/gitui"
+readme = "README.md"
+license = "MIT"
+categories = ["development-tools","development-tools::testing","encoding"]
+keywords = ["string"]
+
+[dependencies]
diff --git a/invalidstring/LICENSE.md b/invalidstring/LICENSE.md
new file mode 120000
index 0000000..7eabdb1
--- /dev/null
+++ b/invalidstring/LICENSE.md
@@ -0,0 +1 @@
+../LICENSE.md \ No newline at end of file
diff --git a/invalidstring/README.md b/invalidstring/README.md
new file mode 100644
index 0000000..eea0615
--- /dev/null
+++ b/invalidstring/README.md
@@ -0,0 +1,5 @@
+# invalidstring
+
+*just for testing invalid string data*
+
+This crate is part of the [gitui](http://gitui.org) project. We need this to be a seperate crate so that `asyncgit` can remain forbidding `unsafe`. \ No newline at end of file
diff --git a/invalidstring/src/lib.rs b/invalidstring/src/lib.rs
new file mode 100644
index 0000000..be24141
--- /dev/null
+++ b/invalidstring/src/lib.rs
@@ -0,0 +1,8 @@
+/// uses unsafe to postfix the string with invalid utf8 data
+pub fn invalid_utf8(prefix: &str) -> String {
+ let bytes = b"\xc3\x73";
+
+ unsafe {
+ format!("{}{}", prefix, std::str::from_utf8_unchecked(bytes))
+ }
+}
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..a3ae007
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1 @@
+max_width=70 \ No newline at end of file
diff --git a/scopetime/Cargo.toml b/scopetime/Cargo.toml
new file mode 100644
index 0000000..d98e4ac
--- /dev/null
+++ b/scopetime/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "scopetime"
+version = "0.1.1"
+authors = ["Stephan Dilly <dilly.stephan@gmail.com>"]
+edition = "2018"
+description = "log runtime of arbitrary scope"
+homepage = "https://github.com/extrawurst/gitui"
+repository = "https://github.com/extrawurst/gitui"
+license = "MIT"
+readme = "README.md"
+categories = ["development-tools::profiling"]
+keywords = ["profiling","logging"]
+
+[dependencies]
+log = "0.4"
+
+[features]
+default=[]
+enabled=[] \ No newline at end of file
diff --git a/scopetime/LICENSE.md b/scopetime/LICENSE.md
new file mode 120000
index 0000000..7eabdb1
--- /dev/null
+++ b/scopetime/LICENSE.md
@@ -0,0 +1 @@
+../LICENSE.md \ No newline at end of file
diff --git a/scopetime/README.md b/scopetime/README.md
new file mode 100644
index 0000000..b0ccbd1
--- /dev/null
+++ b/scopetime/README.md
@@ -0,0 +1,25 @@
+# scopetime
+
+*log runtime of arbitrary scope*
+
+This crate is part of the [gitui](http://gitui.org) project and can be used to annotate arbitrary scopes to `trace` their execution times via `log`:
+
+in your crate:
+```
+[dependencies]
+scopetime = "0.1"
+```
+
+in your code:
+```rust
+fn foo(){
+ scope_time!("foo");
+
+ // ... do something u wanna measure
+}
+```
+
+the resulting log looks someting like this:
+```
+19:45:00 [TRACE] (7) scopetime: [scopetime/src/lib.rs:34] scopetime: 2 ms [my_crate::foo] @my_crate/src/bar.rs:5
+```
diff --git a/scopetime/src/lib.rs b/scopetime/src/lib.rs
new file mode 100644
index 0000000..24fb13e
--- /dev/null
+++ b/scopetime/src/lib.rs
@@ -0,0 +1,72 @@
+//! simple macro to insert a scope based runtime measure that logs the result
+
+#![forbid(unsafe_code)]
+#![forbid(missing_docs)]
+#![deny(unused_imports)]
+#![deny(clippy::unwrap_used)]
+#![deny(clippy::perf)]
+
+use std::time::Instant;
+
+///
+pub struct ScopeTimeLog<'a> {
+ title: &'a str,
+ mod_path: &'a str,
+ file: &'a str,
+ line: u32,
+ time: Instant,
+}
+
+///
+impl<'a> ScopeTimeLog<'a> {
+ ///
+ pub fn new(
+ mod_path: &'a str,
+ title: &'a str,
+ file: &'a str,
+ line: u32,
+ ) -> Self {
+ Self {
+ title,
+ mod_path,
+ file,
+ line,
+ time: Instant::now(),
+ }
+ }
+}
+
+impl<'a> Drop for ScopeTimeLog<'a> {
+ fn drop(&mut self) {
+ log::trace!(
+ "scopetime: {:?} ms [{}::{}] @{}:{}",
+ self.time.elapsed().as_millis(),
+ self.mod_path,
+ self.title,
+ self.file,
+ self.line,
+ );
+ }
+}
+
+///
+#[cfg(feature = "enabled")]
+#[macro_export]
+macro_rules! scope_time {
+ ($target:literal) => {
+ #[allow(unused_variables)]
+ let time = $crate::ScopeTimeLog::new(
+ module_path!(),
+ $target,
+ file!(),
+ line!(),
+ );
+ };
+}
+
+#[doc(hidden)]
+#[cfg(not(feature = "enabled"))]
+#[macro_export]
+macro_rules! scope_time {
+ ($target:literal) => {};
+}
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..5045cf2
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,666 @@
+use crate::{
+ accessors,
+ cmdbar::CommandBar,
+ components::{
+ event_pump, CommandBlocking, CommandInfo, CommitComponent,
+ Component, CreateBranchComponent, DrawableComponent,
+ ExternalEditorComponent, HelpComponent,
+ InspectCommitComponent, MsgComponent, PushComponent,
+ RenameBranchComponent, ResetComponent, SelectBranchComponent,
+ StashMsgComponent, TagCommitComponent,
+ },
+ input::{Input, InputEvent, InputState},
+ keys::{KeyConfig, SharedKeyConfig},
+ queue::{Action, InternalEvent, NeedsUpdate, Queue},
+ strings::{self, order},
+ tabs::{Revlog, StashList, Stashing, Status},
+ ui::style::{SharedTheme, Theme},
+};
+use anyhow::{bail, Result};
+use asyncgit::{sync, AsyncNotification, CWD};
+use crossbeam_channel::Sender;
+use crossterm::event::{Event, KeyEvent};
+use std::{
+ cell::{Cell, RefCell},
+ path::Path,
+ rc::Rc,
+};
+use tui::{
+ backend::Backend,
+ layout::{Constraint, Direction, Layout, Margin, Rect},
+ text::{Span, Spans},
+ widgets::{Block, Borders, Tabs},
+ Frame,
+};
+
+///
+pub struct App {
+ do_quit: bool,
+ help: HelpComponent,
+ msg: MsgComponent,
+ reset: ResetComponent,
+ commit: CommitComponent,
+ stashmsg_popup: StashMsgComponent,
+ inspect_commit_popup: InspectCommitComponent,
+ external_editor_popup: ExternalEditorComponent,
+ push_popup: PushComponent,
+ tag_commit_popup: TagCommitComponent,
+ create_branch_popup: CreateBranchComponent,
+ rename_branch_popup: RenameBranchComponent,
+ select_branch_popup: SelectBranchComponent,
+ cmdbar: RefCell<CommandBar>,
+ tab: usize,
+ revlog: Revlog,
+ status_tab: Status,
+ stashing_tab: Stashing,
+ stashlist_tab: StashList,
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ input: Input,
+
+ // "Flags"
+ requires_redraw: Cell<bool>,
+ file_to_open: Option<String>,
+}
+
+// public interface
+impl App {
+ ///
+ pub fn new(
+ sender: &Sender<AsyncNotification>,
+ input: Input,
+ ) -> Self {
+ let queue = Queue::default();
+
+ let theme = Rc::new(Theme::init());
+ let key_config = Rc::new(KeyConfig::init());
+
+ Self {
+ input,
+ reset: ResetComponent::new(
+ queue.clone(),
+ theme.clone(),
+ key_config.clone(),
+ ),
+ commit: CommitComponent::new(
+ queue.clone(),
+ theme.clone(),
+ key_config.clone(),
+ ),
+ stashmsg_popup: StashMsgComponent::new(
+ queue.clone(),
+ theme.clone(),
+ key_config.clone(),
+ ),
+ inspect_commit_popup: InspectCommitComponent::new(
+ &queue,
+ sender,
+ theme.clone(),
+ key_config.clone(),
+ ),
+ external_editor_popup: ExternalEditorComponent::new(
+ theme.clone(),
+ key_config.clone(),
+ ),
+ push_popup: PushComponent::new(
+ &queue,
+ sender,
+ theme.clone(),
+ key_config.clone(),
+ ),
+ tag_commit_popup: TagCommitComponent::new(
+ queue.clone(),
+ theme.clone(),
+ key_config.clone(),
+ ),
+ create_branch_popup: CreateBranchComponent::new(
+ queue.clone(),
+ theme.clone(),
+ key_config.clone(),
+ ),
+ rename_branch_popup: RenameBranchComponent::new(
+ queue.clone(),
+ theme.clone(),
+ key_config.clone(),
+ ),
+ select_branch_popup: SelectBranchComponent::new(
+ queue.clone(),
+ theme.clone(),
+ key_config.clone(),
+ ),
+ do_quit: false,
+ cmdbar: RefCell::new(CommandBar::new(
+ theme.clone(),
+ key_config.clone(),
+ )),
+ help: HelpComponent::new(
+ theme.clone(),
+ key_config.clone(),
+ ),
+ msg: MsgComponent::new(theme.clone(), key_config.clone()),
+ tab: 0,
+ revlog: Revlog::new(
+ &queue,
+ sender,
+ theme.clone(),
+ key_config.clone(),
+ ),
+ status_tab: Status::new(
+ &queue,
+ sender,
+ theme.clone(),
+ key_config.clone(),
+ ),
+ stashing_tab: Stashing::new(
+ sender,
+ &queue,
+ theme.clone(),
+ key_config.clone(),
+ ),
+ stashlist_tab: StashList::new(
+ &queue,
+ theme.clone(),
+ key_config.clone(),
+ ),
+ queue,
+ theme,
+ key_config,
+ requires_redraw: Cell::new(false),
+ file_to_open: None,
+ }
+ }
+
+ ///
+ pub fn draw<B: Backend>(&self, f: &mut Frame<B>) -> Result<()> {
+ let fsize = f.size();
+
+ self.cmdbar.borrow_mut().refresh_width(fsize.width);
+
+ let chunks_main = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(
+ [
+ Constraint::Length(2),
+ Constraint::Min(2),
+ Constraint::Length(self.cmdbar.borrow().height()),
+ ]
+ .as_ref(),
+ )
+ .split(fsize);
+
+ self.cmdbar.borrow().draw(f, chunks_main[2]);
+
+ self.draw_tabs(f, chunks_main[0]);
+
+ //TODO: macro because of generic draw call
+ match self.tab {
+ 0 => self.status_tab.draw(f, chunks_main[1])?,
+ 1 => self.revlog.draw(f, chunks_main[1])?,
+ 2 => self.stashing_tab.draw(f, chunks_main[1])?,
+ 3 => self.stashlist_tab.draw(f, chunks_main[1])?,
+ _ => bail!("unknown tab"),
+ };
+
+ self.draw_popups(f)?;
+
+ Ok(())
+ }
+
+ ///
+ pub fn event(&mut self, ev: InputEvent) -> Result<()> {
+ log::trace!("event: {:?}", ev);
+
+ if let InputEvent::Input(ev) = ev {
+ if self.check_quit_key(ev) {
+ return Ok(());
+ }
+
+ let mut flags = NeedsUpdate::empty();
+
+ if event_pump(ev, self.components_mut().as_mut_slice())? {
+ flags.insert(NeedsUpdate::COMMANDS);
+ } else if let Event::Key(k) = ev {
+ let new_flags = if k == self.key_config.tab_toggle {
+ self.toggle_tabs(false)?;
+ NeedsUpdate::COMMANDS
+ } else if k == self.key_config.tab_toggle_reverse {
+ self.toggle_tabs(true)?;
+ NeedsUpdate::COMMANDS
+ } else if k == self.key_config.tab_status
+ || k == self.key_config.tab_log
+ || k == self.key_config.tab_stashing
+ || k == self.key_config.tab_stashes
+ {
+ self.switch_tab(k)?;
+ NeedsUpdate::COMMANDS
+ } else if k == self.key_config.cmd_bar_toggle {
+ self.cmdbar.borrow_mut().toggle_more();
+ NeedsUpdate::empty()
+ } else {
+ NeedsUpdate::empty()
+ };
+
+ flags.insert(new_flags);
+ }
+
+ self.process_queue(flags)?;
+ } else if let InputEvent::State(polling_state) = ev {
+ self.external_editor_popup.hide();
+ if let InputState::Paused = polling_state {
+ let result = match self.file_to_open.take() {
+ Some(path) => {
+ ExternalEditorComponent::open_file_in_editor(
+ Path::new(&path),
+ )
+ }
+ None => self.commit.show_editor(),
+ };
+
+ if let Err(e) = result {
+ let msg =
+ format!("failed to launch editor:\n{}", e);
+ log::error!("{}", msg.as_str());
+ self.msg.show_error(msg.as_str())?;
+ }
+
+ self.requires_redraw.set(true);
+ self.input.set_polling(true);
+ }
+ }
+
+ Ok(())
+ }
+
+ //TODO: do we need this?
+ /// forward ticking to components that require it
+ pub fn update(&mut self) -> Result<()> {
+ log::trace!("update");
+
+ self.status_tab.update()?;
+ self.revlog.update()?;
+ self.stashing_tab.update()?;
+ self.stashlist_tab.update()?;
+
+ self.update_commands();
+
+ Ok(())
+ }
+
+ ///
+ pub fn update_git(
+ &mut self,
+ ev: AsyncNotification,
+ ) -> Result<()> {
+ log::trace!("update_git: {:?}", ev);
+
+ self.status_tab.update_git(ev)?;
+ self.stashing_tab.update_git(ev)?;
+ self.revlog.update_git(ev)?;
+ self.inspect_commit_popup.update_git(ev)?;
+ self.push_popup.update_git(ev)?;
+
+ //TODO: better system for this
+ // can we simply process the queue here and everyone just uses the queue to schedule a cmd update?
+ self.process_queue(NeedsUpdate::COMMANDS)?;
+
+ Ok(())
+ }
+
+ ///
+ pub const fn is_quit(&self) -> bool {
+ self.do_quit
+ }
+
+ ///
+ pub fn any_work_pending(&self) -> bool {
+ self.status_tab.anything_pending()
+ || self.revlog.any_work_pending()
+ || self.stashing_tab.anything_pending()
+ || self.inspect_commit_popup.any_work_pending()
+ || self.input.is_state_changing()
+ }
+
+ ///
+ pub fn requires_redraw(&self) -> bool {
+ if self.requires_redraw.get() {
+ self.requires_redraw.set(false);
+ true
+ } else {
+ false
+ }
+ }
+}
+
+// private impls
+impl App {
+ accessors!(
+ self,
+ [
+ msg,
+ reset,
+ commit,
+ stashmsg_popup,
+ inspect_commit_popup,
+ external_editor_popup,
+ push_popup,
+ tag_commit_popup,
+ create_branch_popup,
+ rename_branch_popup,
+ select_branch_popup,
+ help,
+ revlog,
+ status_tab,
+ stashing_tab,
+ stashlist_tab
+ ]
+ );
+
+ fn check_quit_key(&mut self, ev: Event) -> bool {
+ if let Event::Key(e) = ev {
+ if e == self.key_config.exit {
+ self.do_quit = true;
+ return true;
+ }
+ }
+ false
+ }
+
+ fn get_tabs(&mut self) -> Vec<&mut dyn Component> {
+ vec![
+ &mut self.status_tab,
+ &mut self.revlog,
+ &mut self.stashing_tab,
+ &mut self.stashlist_tab,
+ ]
+ }
+
+ fn toggle_tabs(&mut self, reverse: bool) -> Result<()> {
+ let tabs_len = self.get_tabs().len();
+ let new_tab = if reverse {
+ self.tab.wrapping_sub(1).min(tabs_len.saturating_sub(1))
+ } else {
+ self.tab.saturating_add(1) % tabs_len
+ };
+
+ self.set_tab(new_tab)
+ }
+
+ fn switch_tab(&mut self, k: KeyEvent) -> Result<()> {
+ if k == self.key_config.tab_status {
+ self.set_tab(0)?
+ } else if k == self.key_config.tab_log {
+ self.set_tab(1)?
+ } else if k == self.key_config.tab_stashing {
+ self.set_tab(2)?
+ } else if k == self.key_config.tab_stashes {
+ self.set_tab(3)?
+ }
+
+ Ok(())
+ }
+
+ fn set_tab(&mut self, tab: usize) -> Result<()> {
+ let tabs = self.get_tabs();
+ for (i, t) in tabs.into_iter().enumerate() {
+ if tab == i {
+ t.show()?;
+ } else {
+ t.hide();
+ }
+ }
+
+ self.tab = tab;
+
+ Ok(())
+ }
+
+ fn update_commands(&mut self) {
+ self.help.set_cmds(self.commands(true));
+ self.cmdbar.borrow_mut().set_cmds(self.commands(false));
+ }
+
+ fn process_queue(&mut self, flags: NeedsUpdate) -> Result<()> {
+ let mut flags = flags;
+ let new_flags = self.process_internal_events()?;
+ flags.insert(new_flags);
+
+ if flags.contains(NeedsUpdate::ALL) {
+ self.update()?;
+ }
+ //TODO: make this a queue event?
+ //NOTE: set when any tree component changed selection
+ if flags.contains(NeedsUpdate::DIFF) {
+ self.status_tab.update_diff()?;
+ self.inspect_commit_popup.update_diff()?;
+ }
+ if flags.contains(NeedsUpdate::COMMANDS) {
+ self.update_commands();
+ }
+
+ Ok(())
+ }
+
+ fn process_internal_events(&mut self) -> Result<NeedsUpdate> {
+ let mut flags = NeedsUpdate::empty();
+
+ loop {
+ let front = self.queue.borrow_mut().pop_front();
+ if let Some(e) = front {
+ flags.insert(self.process_internal_event(e)?);
+ } else {
+ break;
+ }
+ }
+ self.queue.borrow_mut().clear();
+
+ Ok(flags)
+ }
+
+ fn process_internal_event(
+ &mut self,
+ ev: InternalEvent,
+ ) -> Result<NeedsUpdate> {
+ let mut flags = NeedsUpdate::empty();
+ match ev {
+ InternalEvent::ConfirmedAction(action) => match action {
+ Action::Reset(r) => {
+ if self.status_tab.reset(&r) {
+ flags.insert(NeedsUpdate::ALL);
+ }
+ }
+ Action::StashDrop(s) => {
+ if StashList::drop(s) {
+ flags.insert(NeedsUpdate::ALL);
+ }
+ }
+ Action::ResetHunk(path, hash) => {
+ sync::reset_hunk(CWD, path, hash)?;
+ flags.insert(NeedsUpdate::ALL);
+ }
+ Action::DeleteBranch(branch_ref) => {
+ if let Err(e) =
+ sync::delete_branch(CWD, &branch_ref)
+ {
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(
+ e.to_string(),
+ ),
+ )
+ } else {
+ flags.insert(NeedsUpdate::ALL);
+ self.select_branch_popup.hide();
+ }
+ }
+ },
+ InternalEvent::ConfirmAction(action) => {
+ self.reset.open(action)?;
+ flags.insert(NeedsUpdate::COMMANDS);
+ }
+ InternalEvent::ShowErrorMsg(msg) => {
+ self.msg.show_error(msg.as_str())?;
+ flags
+ .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS);
+ }
+ InternalEvent::Update(u) => flags.insert(u),
+ InternalEvent::OpenCommit => self.commit.show()?,
+ InternalEvent::PopupStashing(opts) => {
+ self.stashmsg_popup.options(opts);
+ self.stashmsg_popup.show()?
+ }
+ InternalEvent::TagCommit(id) => {
+ self.tag_commit_popup.open(id)?;
+ }
+ InternalEvent::CreateBranch => {
+ self.create_branch_popup.open()?;
+ }
+ InternalEvent::RenameBranch(branch_ref, cur_name) => {
+ self.rename_branch_popup
+ .open(branch_ref, cur_name)?;
+ }
+ InternalEvent::SelectBranch => {
+ self.select_branch_popup.open()?;
+ }
+ InternalEvent::TabSwitch => self.set_tab(0)?,
+ InternalEvent::InspectCommit(id, tags) => {
+ self.inspect_commit_popup.open(id, tags)?;
+ flags.insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS)
+ }
+ InternalEvent::OpenExternalEditor(path) => {
+ self.input.set_polling(false);
+ self.external_editor_popup.show()?;
+ self.file_to_open = path;
+ flags.insert(NeedsUpdate::COMMANDS)
+ }
+ InternalEvent::Push(branch) => {
+ self.push_popup.push(branch)?;
+ flags.insert(NeedsUpdate::ALL)
+ }
+ };
+
+ Ok(flags)
+ }
+
+ fn commands(&self, force_all: bool) -> Vec<CommandInfo> {
+ let mut res = Vec::new();
+
+ for c in self.components() {
+ if c.commands(&mut res, force_all)
+ != CommandBlocking::PassingOn
+ && !force_all
+ {
+ break;
+ }
+ }
+
+ res.push(
+ CommandInfo::new(
+ strings::commands::toggle_tabs(&self.key_config),
+ true,
+ !self.any_popup_visible(),
+ )
+ .order(order::NAV),
+ );
+ res.push(
+ CommandInfo::new(
+ strings::commands::toggle_tabs_direct(
+ &self.key_config,
+ ),
+ true,
+ !self.any_popup_visible(),
+ )
+ .order(order::NAV),
+ );
+
+ res.push(
+ CommandInfo::new(
+ strings::commands::quit(&self.key_config),
+ true,
+ !self.any_popup_visible(),
+ )
+ .order(100),
+ );
+
+ res
+ }
+
+ //TODO: make this automatic, i keep forgetting to add popups here
+ fn any_popup_visible(&self) -> bool {
+ self.commit.is_visible()
+ || self.help.is_visible()
+ || self.reset.is_visible()
+ || self.msg.is_visible()
+ || self.stashmsg_popup.is_visible()
+ || self.inspect_commit_popup.is_visible()
+ || self.external_editor_popup.is_visible()
+ || self.tag_commit_popup.is_visible()
+ || self.create_branch_popup.is_visible()
+ || self.push_popup.is_visible()
+ || self.select_branch_popup.is_visible()
+ || self.rename_branch_popup.is_visible()
+ }
+
+ fn draw_popups<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ ) -> Result<()> {
+ let size = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(
+ [
+ Constraint::Min(1),
+ Constraint::Length(self.cmdbar.borrow().height()),
+ ]
+ .as_ref(),
+ )
+ .split(f.size())[0];
+
+ self.commit.draw(f, size)?;
+ self.stashmsg_popup.draw(f, size)?;
+ self.help.draw(f, size)?;
+ self.inspect_commit_popup.draw(f, size)?;
+ self.external_editor_popup.draw(f, size)?;
+ self.tag_commit_popup.draw(f, size)?;
+ self.select_branch_popup.draw(f, size)?;
+ self.create_branch_popup.draw(f, size)?;
+ self.rename_branch_popup.draw(f, size)?;
+ self.push_popup.draw(f, size)?;
+ self.reset.draw(f, size)?;
+ self.msg.draw(f, size)?;
+
+ Ok(())
+ }
+
+ //TODO: make this dynamic
+ fn draw_tabs<B: Backend>(&self, f: &mut Frame<B>, r: Rect) {
+ let r = r.inner(&Margin {
+ vertical: 0,
+ horizontal: 1,
+ });
+
+ let tabs = [
+ Span::raw(strings::tab_status(&self.key_config)),
+ Span::raw(strings::tab_log(&self.key_config)),
+ Span::raw(strings::tab_stashing(&self.key_config)),
+ Span::raw(strings::tab_stashes(&self.key_config)),
+ ]
+ .iter()
+ .cloned()
+ .map(Spans::from)
+ .collect();
+
+ f.render_widget(
+ Tabs::new(tabs)
+ .block(
+ Block::default()
+ .borders(Borders::BOTTOM)
+ .border_style(self.theme.block(false)),
+ )
+ .style(self.theme.tab(false))
+ .highlight_style(self.theme.tab(true))
+ .divider(strings::tab_divider(&self.key_config))
+ .select(self.tab),
+ r,
+ );
+ }
+}
diff --git a/src/clipboard.rs b/src/clipboard.rs
new file mode 100644
index 0000000..7270d89
--- /dev/null
+++ b/src/clipboard.rs
@@ -0,0 +1,75 @@
+use anyhow::Result;
+#[cfg(target_os = "linux")]
+use std::ffi::OsStr;
+use std::io::Write;
+use std::process::{Command, Stdio};
+
+fn execute_copy_command(command: Command, text: &str) -> Result<()> {
+ use anyhow::anyhow;
+
+ let mut command = command;
+
+ let mut process = command
+ .stdin(Stdio::piped())
+ .stdout(Stdio::null())
+ .spawn()
+ .map_err(|e| anyhow!("`{:?}`: {}", command, e))?;
+
+ process
+ .stdin
+ .as_mut()
+ .ok_or_else(|| anyhow!("`{:?}`", command))?
+ .write_all(text.as_bytes())
+ .map_err(|e| anyhow!("`{:?}`: {}", command, e))?;
+
+ process
+ .wait()
+ .map_err(|e| anyhow!("`{:?}`: {}", command, e))?;
+
+ Ok(())
+}
+
+#[cfg(target_os = "linux")]
+fn gen_command(
+ path: impl AsRef<OsStr>,
+ xclip_syntax: bool,
+) -> Command {
+ let mut c = Command::new(path);
+ if xclip_syntax {
+ c.arg("-selection");
+ c.arg("clipboard");
+ } else {
+ c.arg("--clipboard");
+ }
+ c
+}
+
+#[cfg(target_os = "linux")]
+pub fn copy_string(string: &str) -> Result<()> {
+ use std::path::PathBuf;
+ use which::which;
+ let (path, xclip_syntax) = which("xclip").ok().map_or_else(
+ || {
+ (
+ which("xsel")
+ .ok()
+ .unwrap_or_else(|| PathBuf::from("xsel")),
+ false,
+ )
+ },
+ |path| (path, true),
+ );
+
+ let cmd = gen_command(path, xclip_syntax);
+ execute_copy_command(cmd, string)
+}
+
+#[cfg(target_os = "macos")]
+pub fn copy_string(string: &str) -> Result<()> {
+ execute_copy_command(Command::new("pbcopy"), string)
+}
+
+#[cfg(windows)]
+pub fn copy_string(string: &str) -> Result<()> {
+ execute_copy_command(Command::new("clip"), string)
+}
diff --git a/src/cmdbar.rs b/src/cmdbar.rs
new file mode 100644
index 0000000..dc22772
--- /dev/null
+++ b/src/cmdbar.rs
@@ -0,0 +1,205 @@
+use crate::{
+ components::CommandInfo, keys::SharedKeyConfig, strings,
+ ui::style::SharedTheme,
+};
+use std::borrow::Cow;
+use tui::{
+ backend::Backend,
+ layout::{Alignment, Rect},
+ text::{Span, Spans},
+ widgets::Paragraph,
+ Frame,
+};
+use unicode_width::UnicodeWidthStr;
+
+enum DrawListEntry {
+ LineBreak,
+ Splitter,
+ Command(Command),
+}
+
+struct Command {
+ txt: String,
+ enabled: bool,
+ line: usize,
+}
+
+/// helper to be used while drawing
+pub struct CommandBar {
+ draw_list: Vec<DrawListEntry>,
+ cmd_infos: Vec<CommandInfo>,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ lines: u16,
+ width: u16,
+ expandable: bool,
+ expanded: bool,
+}
+
+const MORE_WIDTH: u16 = 9;
+
+impl CommandBar {
+ pub const fn new(
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ draw_list: Vec::new(),
+ cmd_infos: Vec::new(),
+ theme,
+ key_config,
+ lines: 0,
+ width: 0,
+ expandable: false,
+ expanded: false,
+ }
+ }
+
+ pub fn refresh_width(&mut self, width: u16) {
+ if width != self.width {
+ self.refresh_list(width);
+ self.width = width;
+ }
+ }
+
+ fn is_multiline(&self, width: u16) -> bool {
+ let mut line_width = 0_usize;
+ for c in &self.cmd_infos {
+ let entry_w =
+ UnicodeWidthStr::width(c.text.name.as_str());
+
+ if line_width + entry_w > width as usize {
+ return true;
+ }
+
+ line_width += entry_w + 1;
+ }
+
+ false
+ }
+
+ fn refresh_list(&mut self, width: u16) {
+ self.draw_list.clear();
+
+ let width = if self.is_multiline(width) {
+ width.saturating_sub(MORE_WIDTH)
+ } else {
+ width
+ };
+
+ let mut line_width = 0_usize;
+ let mut lines = 1_u16;
+
+ for c in &self.cmd_infos {
+ let entry_w =
+ UnicodeWidthStr::width(c.text.name.as_str());
+
+ if line_width + entry_w > width as usize {
+ self.draw_list.push(DrawListEntry::LineBreak);
+ line_width = 0;
+ lines += 1;
+ } else if line_width > 0 {
+ self.draw_list.push(DrawListEntry::Splitter);
+ }
+
+ line_width += entry_w + 1;
+
+ self.draw_list.push(DrawListEntry::Command(Command {
+ txt: c.text.name.to_string(),
+ enabled: c.enabled,
+ line: lines.saturating_sub(1) as usize,
+ }));
+ }
+
+ self.expandable = lines > 1;
+
+ self.lines = lines;
+ }
+
+ pub fn set_cmds(&mut self, cmds: Vec<CommandInfo>) {
+ self.cmd_infos = cmds
+ .into_iter()
+ .filter(CommandInfo::show_in_quickbar)
+ .collect::<Vec<_>>();
+ self.cmd_infos.sort_by_key(|e| e.order);
+ self.refresh_list(self.width);
+ }
+
+ pub const fn height(&self) -> u16 {
+ if self.expandable && self.expanded {
+ self.lines
+ } else {
+ 1_u16
+ }
+ }
+
+ pub fn toggle_more(&mut self) {
+ if self.expandable {
+ self.expanded = !self.expanded;
+ }
+ }
+
+ pub fn draw<B: Backend>(&self, f: &mut Frame<B>, r: Rect) {
+ if r.width < MORE_WIDTH {
+ return;
+ }
+ let splitter = Span::raw(Cow::from(strings::cmd_splitter(
+ &self.key_config,
+ )));
+
+ let texts = self
+ .draw_list
+ .split(|c| matches!(c, DrawListEntry::LineBreak))
+ .map(|c_arr| {
+ Spans::from(
+ c_arr
+ .iter()
+ .map(|c| match c {
+ DrawListEntry::Command(c) => {
+ Span::styled(
+ Cow::from(c.txt.as_str()),
+ self.theme.commandbar(
+ c.enabled, c.line,
+ ),
+ )
+ }
+ DrawListEntry::LineBreak => {
+ // Doesn't exist in split array
+ Span::raw("")
+ }
+ DrawListEntry::Splitter => {
+ splitter.clone()
+ }
+ })
+ .collect::<Vec<Span>>(),
+ )
+ })
+ .collect::<Vec<Spans>>();
+
+ f.render_widget(
+ Paragraph::new(texts).alignment(Alignment::Left),
+ r,
+ );
+
+ if self.expandable {
+ let r = Rect::new(
+ r.width.saturating_sub(MORE_WIDTH),
+ r.y + r.height.saturating_sub(1),
+ MORE_WIDTH.min(r.width),
+ 1.min(r.height),
+ );
+
+ f.render_widget(
+ Paragraph::new(Spans::from(vec![Span::raw(
+ Cow::from(if self.expanded {
+ "less [.]"
+ } else {
+ "more [.]"
+ }),
+ )]))
+ .alignment(Alignment::Right),
+ r,
+ );
+ }
+ }
+}
diff --git a/src/components/changes.rs b/src/components/changes.rs
new file mode 100644
index 0000000..9771ae9
--- /dev/null
+++ b/src/components/changes.rs
@@ -0,0 +1,302 @@
+use super::{
+ filetree::FileTreeComponent,
+ utils::filetree::{FileTreeItem, FileTreeItemKind},
+ CommandBlocking, DrawableComponent,
+};
+use crate::{
+ components::{CommandInfo, Component},
+ keys::SharedKeyConfig,
+ queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem},
+ strings, try_or_popup,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{sync, StatusItem, StatusItemType, CWD};
+use crossterm::event::Event;
+use std::path::Path;
+use tui::{backend::Backend, layout::Rect, Frame};
+
+///
+pub struct ChangesComponent {
+ files: FileTreeComponent,
+ is_working_dir: bool,
+ queue: Queue,
+ key_config: SharedKeyConfig,
+}
+
+impl ChangesComponent {
+ ///
+ pub fn new(
+ title: &str,
+ focus: bool,
+ is_working_dir: bool,
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ files: FileTreeComponent::new(
+ title,
+ focus,
+ Some(queue.clone()),
+ theme,
+ key_config.clone(),
+ ),
+ is_working_dir,
+ queue,
+ key_config,
+ }
+ }
+
+ ///
+ pub fn set_items(&mut self, list: &[StatusItem]) -> Result<()> {
+ self.files.update(list)?;
+ Ok(())
+ }
+
+ ///
+ pub fn selection(&self) -> Option<FileTreeItem> {
+ self.files.selection()
+ }
+
+ ///
+ pub fn focus_select(&mut self, focus: bool) {
+ self.files.focus(focus);
+ self.files.show_selection(focus);
+ }
+
+ /// returns true if list is empty
+ pub fn is_empty(&self) -> bool {
+ self.files.is_empty()
+ }
+
+ ///
+ pub fn is_file_seleted(&self) -> bool {
+ self.files.is_file_seleted()
+ }
+
+ fn index_add_remove(&mut self) -> Result<bool> {
+ if let Some(tree_item) = self.selection() {
+ if self.is_working_dir {
+ if let FileTreeItemKind::File(i) = tree_item.kind {
+ let path = Path::new(i.path.as_str());
+ match i.status {
+ StatusItemType::Deleted => {
+ sync::stage_addremoved(CWD, path)?
+ }
+ _ => sync::stage_add_file(CWD, path)?,
+ };
+
+ return Ok(true);
+ } else {
+ //TODO: check if we can handle the one file case with it aswell
+ sync::stage_add_all(
+ CWD,
+ tree_item.info.full_path.as_str(),
+ )?;
+
+ return Ok(true);
+ }
+ } else {
+ let path = tree_item.info.full_path.as_str();
+ sync::reset_stage(CWD, path)?;
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn index_add_all(&mut self) -> Result<()> {
+ sync::stage_add_all(CWD, "*")?;
+
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::Update(NeedsUpdate::ALL));
+
+ Ok(())
+ }
+
+ fn stage_remove_all(&mut self) -> Result<()> {
+ sync::reset_stage(CWD, "*")?;
+
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::Update(NeedsUpdate::ALL));
+
+ Ok(())
+ }
+
+ fn dispatch_reset_workdir(&mut self) -> bool {
+ if let Some(tree_item) = self.selection() {
+ let is_folder =
+ matches!(tree_item.kind, FileTreeItemKind::Path(_));
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ConfirmAction(Action::Reset(
+ ResetItem {
+ path: tree_item.info.full_path,
+ is_folder,
+ },
+ )),
+ );
+
+ return true;
+ }
+ false
+ }
+
+ fn add_to_ignore(&mut self) -> bool {
+ if let Some(tree_item) = self.selection() {
+ if let Err(e) =
+ sync::add_to_ignore(CWD, &tree_item.info.full_path)
+ {
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "ignore error:\n{}\nfile:\n{:?}",
+ e, tree_item.info.full_path
+ )),
+ );
+ } else {
+ self.queue.borrow_mut().push_back(
+ InternalEvent::Update(NeedsUpdate::ALL),
+ );
+
+ return true;
+ }
+ }
+
+ false
+ }
+}
+
+impl DrawableComponent for ChangesComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ r: Rect,
+ ) -> Result<()> {
+ self.files.draw(f, r)?;
+
+ Ok(())
+ }
+}
+
+impl Component for ChangesComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ self.files.commands(out, force_all);
+
+ let some_selection = self.selection().is_some();
+
+ if self.is_working_dir {
+ out.push(CommandInfo::new(
+ strings::commands::stage_all(&self.key_config),
+ some_selection,
+ self.focused(),
+ ));
+ out.push(CommandInfo::new(
+ strings::commands::stage_item(&self.key_config),
+ some_selection,
+ self.focused(),
+ ));
+ out.push(CommandInfo::new(
+ strings::commands::reset_item(&self.key_config),
+ some_selection,
+ self.focused(),
+ ));
+ out.push(CommandInfo::new(
+ strings::commands::ignore_item(&self.key_config),
+ some_selection,
+ self.focused(),
+ ));
+ } else {
+ out.push(CommandInfo::new(
+ strings::commands::unstage_item(&self.key_config),
+ some_selection,
+ self.focused(),
+ ));
+ out.push(CommandInfo::new(
+ strings::commands::unstage_all(&self.key_config),
+ some_selection,
+ self.focused(),
+ ));
+ out.push(
+ CommandInfo::new(
+ strings::commands::commit_open(&self.key_config),
+ !self.is_empty(),
+ self.focused() || force_all,
+ )
+ .order(-1),
+ );
+ }
+
+ CommandBlocking::PassingOn
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.files.event(ev)? {
+ return Ok(true);
+ }
+
+ if self.focused() {
+ if let Event::Key(e) = ev {
+ return if e == self.key_config.open_commit
+ && !self.is_working_dir
+ && !self.is_empty()
+ {
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::OpenCommit);
+ Ok(true)
+ } else if e == self.key_config.enter {
+ try_or_popup!(
+ self,
+ "staging error:",
+ self.index_add_remove()
+ );
+
+ self.queue.borrow_mut().push_back(
+ InternalEvent::Update(NeedsUpdate::ALL),
+ );
+ Ok(true)
+ } else if e == self.key_config.status_stage_all
+ && !self.is_empty()
+ {
+ if self.is_working_dir {
+ try_or_popup!(
+ self,
+ "staging error:",
+ self.index_add_all()
+ );
+ } else {
+ self.stage_remove_all()?;
+ }
+ Ok(true)
+ } else if e == self.key_config.status_reset_item
+ && self.is_working_dir
+ {
+ Ok(self.dispatch_reset_workdir())
+ } else if e == self.key_config.status_ignore_file
+ && self.is_working_dir
+ && !self.is_empty()
+ {
+ Ok(self.add_to_ignore())
+ } else {
+ Ok(false)
+ };
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn focused(&self) -> bool {
+ self.files.focused()
+ }
+ fn focus(&mut self, focus: bool) {
+ self.files.focus(focus)
+ }
+}
diff --git a/src/components/command.rs b/src/components/command.rs
new file mode 100644
index 0000000..8b6340f
--- /dev/null
+++ b/src/components/command.rs
@@ -0,0 +1,85 @@
+///
+#[derive(Clone, PartialEq, PartialOrd, Ord, Eq)]
+pub struct CommandText {
+ ///
+ pub name: String,
+ ///
+ pub desc: &'static str,
+ ///
+ pub group: &'static str,
+ ///
+ pub hide_help: bool,
+}
+
+impl CommandText {
+ ///
+ pub const fn new(
+ name: String,
+ desc: &'static str,
+ group: &'static str,
+ ) -> Self {
+ Self {
+ name,
+ desc,
+ group,
+ hide_help: false,
+ }
+ }
+ ///
+ pub const fn hide_help(self) -> Self {
+ let mut tmp = self;
+ tmp.hide_help = true;
+ tmp
+ }
+}
+
+///
+pub struct CommandInfo {
+ ///
+ pub text: CommandText,
+ /// available but not active in the context
+ pub enabled: bool,
+ /// will show up in the quick bar
+ pub quick_bar: bool,
+
+ /// available in current app state
+ pub available: bool,
+ /// used to order commands in quickbar
+ pub order: i8,
+}
+
+impl CommandInfo {
+ ///
+ pub const fn new(
+ text: CommandText,
+ enabled: bool,
+ available: bool,
+ ) -> Self {
+ Self {
+ text,
+ enabled,
+ quick_bar: true,
+ available,
+ order: 0,
+ }
+ }
+
+ ///
+ pub const fn order(self, order: i8) -> Self {
+ let mut res = self;
+ res.order = order;
+ res
+ }
+
+ ///
+ pub const fn hidden(self) -> Self {
+ let mut res = self;
+ res.quick_bar = false;
+ res
+ }
+
+ ///
+ pub const fn show_in_quickbar(&self) -> bool {
+ self.quick_bar && self.available
+ }
+}
diff --git a/src/components/commit.rs b/src/components/commit.rs
new file mode 100644
index 0000000..708aeb1
--- /dev/null
+++ b/src/components/commit.rs
@@ -0,0 +1,280 @@
+use super::{
+ textinput::TextInputComponent, visibility_blocking,
+ CommandBlocking, CommandInfo, Component, DrawableComponent,
+ ExternalEditorComponent,
+};
+use crate::{
+ get_app_config_path,
+ keys::SharedKeyConfig,
+ queue::{InternalEvent, NeedsUpdate, Queue},
+ strings,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{
+ sync::{self, CommitId, HookResult},
+ CWD,
+};
+use crossterm::event::Event;
+use std::{
+ fs::File,
+ io::{Read, Write},
+ path::PathBuf,
+};
+use tui::{backend::Backend, layout::Rect, Frame};
+
+pub struct CommitComponent {
+ input: TextInputComponent,
+ amend: Option<CommitId>,
+ queue: Queue,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for CommitComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ self.input.draw(f, rect)?;
+
+ Ok(())
+ }
+}
+
+impl Component for CommitComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ self.input.commands(out, force_all);
+
+ if self.is_visible() || force_all {
+ out.push(CommandInfo::new(
+ strings::commands::commit_enter(&self.key_config),
+ self.can_commit(),
+ true,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::commit_amend(&self.key_config),
+ self.can_amend(),
+ true,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::commit_open_editor(
+ &self.key_config,
+ ),
+ true,
+ true,
+ ));
+ }
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.is_visible() {
+ if self.input.event(ev)? {
+ return Ok(true);
+ }
+
+ if let Event::Key(e) = ev {
+ if e == self.key_config.enter && self.can_commit() {
+ self.commit()?;
+ } else if e == self.key_config.commit_amend
+ && self.can_amend()
+ {
+ self.amend()?;
+ } else if e == self.key_config.open_commit_editor {
+ self.queue.borrow_mut().push_back(
+ InternalEvent::OpenExternalEditor(None),
+ );
+ self.hide();
+ } else {
+ }
+ // stop key event propagation
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.input.is_visible()
+ }
+
+ fn hide(&mut self) {
+ self.input.hide()
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.amend = None;
+
+ self.input.clear();
+ self.input
+ .set_title(strings::commit_title(&self.key_config));
+ self.input.show()?;
+
+ Ok(())
+ }
+}
+
+impl CommitComponent {
+ ///
+ pub fn new(
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ queue,
+ amend: None,
+ input: TextInputComponent::new(
+ theme,
+ key_config.clone(),
+ "",
+ &strings::commit_msg(&key_config),
+ ),
+ key_config,
+ }
+ }
+
+ pub fn show_editor(&mut self) -> Result<()> {
+ const COMMIT_MSG_FILE_NAME: &str = "COMMITMSG_EDITOR";
+ //TODO: use a tmpfile here
+ let mut config_path: PathBuf = get_app_config_path()?;
+ config_path.push(COMMIT_MSG_FILE_NAME);
+
+ {
+ let mut file = File::create(&config_path)?;
+ file.write_fmt(format_args!(
+ "{}\n",
+ self.input.get_text()
+ ))?;
+ file.write_all(
+ strings::commit_editor_msg(&self.key_config)
+ .as_bytes(),
+ )?;
+ }
+
+ ExternalEditorComponent::open_file_in_editor(&config_path)?;
+
+ let mut message = String::new();
+
+ let mut file = File::open(&config_path)?;
+ file.read_to_string(&mut message)?;
+ drop(file);
+ std::fs::remove_file(&config_path)?;
+
+ let message: String = message
+ .lines()
+ .flat_map(|l| {
+ if l.starts_with('#') {
+ vec![]
+ } else {
+ vec![l, "\n"]
+ }
+ })
+ .collect();
+
+ let message = message.trim().to_string();
+
+ self.input.set_text(message);
+ self.input.show()?;
+
+ Ok(())
+ }
+
+ fn commit(&mut self) -> Result<()> {
+ self.commit_msg(self.input.get_text().clone())
+ }
+
+ fn commit_msg(&mut self, msg: String) -> Result<()> {
+ if let HookResult::NotOk(e) = sync::hooks_pre_commit(CWD)? {
+ log::error!("pre-commit hook error: {}", e);
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "pre-commit hook error:\n{}",
+ e
+ )),
+ );
+ return Ok(());
+ }
+ let mut msg = msg;
+ if let HookResult::NotOk(e) =
+ sync::hooks_commit_msg(CWD, &mut msg)?
+ {
+ log::error!("commit-msg hook error: {}", e);
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "commit-msg hook error:\n{}",
+ e
+ )),
+ );
+ return Ok(());
+ }
+
+ let res = self.amend.map_or_else(
+ || sync::commit(CWD, &msg),
+ |amend| sync::amend(CWD, amend, &msg),
+ );
+ if let Err(e) = res {
+ log::error!("commit error: {}", &e);
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "commit failed:\n{}",
+ &e
+ )),
+ );
+ return Ok(());
+ }
+
+ if let HookResult::NotOk(e) = sync::hooks_post_commit(CWD)? {
+ log::error!("post-commit hook error: {}", e);
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "post-commit hook error:\n{}",
+ e
+ )),
+ );
+ }
+
+ self.hide();
+
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::Update(NeedsUpdate::ALL));
+
+ Ok(())
+ }
+
+ fn can_commit(&self) -> bool {
+ !self.input.get_text().is_empty()
+ }
+
+ fn can_amend(&self) -> bool {
+ self.amend.is_none()
+ && sync::get_head(CWD).is_ok()
+ && self.input.get_text().is_empty()
+ }
+
+ fn amend(&mut self) -> Result<()> {
+ let id = sync::get_head(CWD)?;
+ self.amend = Some(id);
+
+ let details = sync::get_commit_details(CWD, id)?;
+
+ self.input
+ .set_title(strings::commit_title_amend(&self.key_config));
+
+ if let Some(msg) = details.message {
+ self.input.set_text(msg.combine());
+ }
+
+ Ok(())
+ }
+}
diff --git a/src/components/commit_details/details.rs b/src/components/commit_details/details.rs
new file mode 100644
index 0000000..918fccb
--- /dev/null
+++ b/src/components/commit_details/details.rs
@@ -0,0 +1,491 @@
+use crate::{
+ components::{
+ dialog_paragraph, utils::time_to_string, CommandBlocking,
+ CommandInfo, Component, DrawableComponent, ScrollType,
+ },
+ keys::SharedKeyConfig,
+ strings::{self, order},
+ ui::{self, style::SharedTheme},
+};
+use anyhow::Result;
+use asyncgit::{
+ sync::{self, CommitDetails, CommitId, CommitMessage},
+ CWD,
+};
+use crossterm::event::Event;
+use itertools::Itertools;
+use std::clone::Clone;
+use std::{borrow::Cow, cell::Cell};
+use sync::CommitTags;
+use tui::{
+ backend::Backend,
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Span, Spans, Text},
+ Frame,
+};
+enum Detail {
+ Author,
+ Date,
+ Commiter,
+ Sha,
+}
+
+pub struct DetailsComponent {
+ data: Option<CommitDetails>,
+ tags: Vec<String>,
+ theme: SharedTheme,
+ focused: bool,
+ current_size: Cell<(u16, u16)>,
+ scroll_top: Cell<usize>,
+ key_config: SharedKeyConfig,
+}
+
+type WrappedCommitMessage<'a> =
+ (Vec<Cow<'a, str>>, Vec<Cow<'a, str>>);
+
+impl DetailsComponent {
+ ///
+ pub const fn new(
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ focused: bool,
+ ) -> Self {
+ Self {
+ data: None,
+ tags: Vec::new(),
+ theme,
+ focused,
+ current_size: Cell::new((0, 0)),
+ scroll_top: Cell::new(0),
+ key_config,
+ }
+ }
+
+ pub fn set_commit(
+ &mut self,
+ id: Option<CommitId>,
+ tags: Option<CommitTags>,
+ ) -> Result<()> {
+ self.tags.clear();
+
+ self.data =
+ id.and_then(|id| sync::get_commit_details(CWD, id).ok());
+
+ self.scroll_top.set(0);
+
+ if let Some(tags) = tags {
+ self.tags.extend(tags)
+ }
+
+ Ok(())
+ }
+
+ fn wrap_commit_details(
+ message: &CommitMessage,
+ width: usize,
+ ) -> WrappedCommitMessage<'_> {
+ let wrapped_title = textwrap::wrap(&message.subject, width);
+
+ if let Some(ref body) = message.body {
+ let wrapped_message: Vec<Cow<'_, str>> =
+ textwrap::wrap(body, width)
+ .into_iter()
+ .skip(1)
+ .collect();
+
+ (wrapped_title, wrapped_message)
+ } else {
+ (wrapped_title, vec![])
+ }
+ }
+
+ fn get_wrapped_lines(
+ &self,
+ width: usize,
+ ) -> WrappedCommitMessage<'_> {
+ if let Some(ref data) = self.data {
+ if let Some(ref message) = data.message {
+ return Self::wrap_commit_details(message, width);
+ }
+ }
+
+ (vec![], vec![])
+ }
+
+ fn get_number_of_lines(&self, width: usize) -> usize {
+ let (wrapped_title, wrapped_message) =
+ self.get_wrapped_lines(width);
+
+ wrapped_title.len() + wrapped_message.len()
+ }
+
+ fn get_theme_for_line(&self, bold: bool) -> Style {
+ if bold {
+ self.theme.text(true, false).add_modifier(Modifier::BOLD)
+ } else {
+ self.theme.text(true, false)
+ }
+ }
+
+ fn get_wrapped_text_message(
+ &self,
+ width: usize,
+ height: usize,
+ ) -> Vec<Spans> {
+ let (wrapped_title, wrapped_message) =
+ self.get_wrapped_lines(width);
+
+ [&wrapped_title[..], &wrapped_message[..]]
+ .concat()
+ .iter()
+ .enumerate()
+ .skip(self.scroll_top.get())
+ .take(height)
+ .map(|(i, line)| {
+ Spans::from(vec![Span::styled(
+ line.clone(),
+ self.get_theme_for_line(i < wrapped_title.len()),
+ )])
+ })
+ .collect()
+ }
+
+ fn style_detail(&self, field: &Detail) -> Span {
+ match field {
+ Detail::Author => Span::styled(
+ Cow::from(strings::commit::details_author(
+ &self.key_config,
+ )),
+ self.theme.text(false, false),
+ ),
+ Detail::Date => Span::styled(
+ Cow::from(strings::commit::details_date(
+ &self.key_config,
+ )),
+ self.theme.text(false, false),
+ ),
+ Detail::Commiter => Span::styled(
+ Cow::from(strings::commit::details_committer(
+ &self.key_config,
+ )),
+ self.theme.text(false, false),
+ ),
+ Detail::Sha => Span::styled(
+ Cow::from(strings::commit::details_tags(
+ &self.key_config,
+ )),
+ self.theme.text(false, false),
+ ),
+ }
+ }
+
+ fn get_text_info(&self) -> Vec<Spans> {
+ if let Some(ref data) = self.data {
+ let mut res = vec![
+ Spans::from(vec![
+ self.style_detail(&Detail::Author),
+ Span::styled(
+ Cow::from(format!(
+ "{} <{}>",
+ data.author.name, data.author.email
+ )),
+ self.theme.text(true, false),
+ ),
+ ]),
+ Spans::from(vec![
+ self.style_detail(&Detail::Date),
+ Span::styled(
+ Cow::from(time_to_string(
+ data.author.time,
+ false,
+ )),
+ self.theme.text(true, false),
+ ),
+ ]),
+ ];
+
+ if let Some(ref committer) = data.committer {
+ res.extend(vec![
+ Spans::from(vec![
+ self.style_detail(&Detail::Commiter),
+ Span::styled(
+ Cow::from(format!(
+ "{} <{}>",
+ committer.name, committer.email
+ )),
+ self.theme.text(true, false),
+ ),
+ ]),
+ Spans::from(vec![
+ self.style_detail(&Detail::Date),
+ Span::styled(
+ Cow::from(time_to_string(
+ committer.time,
+ false,
+ )),
+ self.theme.text(true, false),
+ ),
+ ]),
+ ]);
+ }
+
+ res.push(Spans::from(vec![
+ Span::styled(
+ Cow::from(strings::commit::details_sha(
+ &self.key_config,
+ )),
+ self.theme.text(false, false),
+ ),
+ Span::styled(
+ Cow::from(data.hash.clone()),
+ self.theme.text(true, false),
+ ),
+ ]));
+
+ if !self.tags.is_empty() {
+ res.push(Spans::from(
+ self.style_detail(&Detail::Sha),
+ ));
+ res.push(Spans::from(
+ self.tags
+ .iter()
+ .map(|tag| {
+ Span::styled(
+ Cow::from(tag),
+ self.theme.text(true, false),
+ )
+ })
+ .intersperse(Span::styled(
+ Cow::from(","),
+ self.theme.text(true, false),
+ ))
+ .collect::<Vec<Span>>(),
+ ));
+ }
+
+ res
+ } else {
+ vec![]
+ }
+ }
+
+ fn move_scroll_top(&mut self, move_type: ScrollType) -> bool {
+ if self.data.is_some() {
+ let old = self.scroll_top.get();
+ let width = self.current_size.get().0 as usize;
+ let height = self.current_size.get().1 as usize;
+
+ let number_of_lines = self.get_number_of_lines(width);
+
+ let max = number_of_lines.saturating_sub(height) as usize;
+
+ let new_scroll_top = match move_type {
+ ScrollType::Down => old.saturating_add(1),
+ ScrollType::Up => old.saturating_sub(1),
+ ScrollType::Home => 0,
+ ScrollType::End => max,
+ _ => old,
+ };
+
+ if new_scroll_top > max {
+ return false;
+ }
+
+ self.scroll_top.set(new_scroll_top);
+
+ return true;
+ }
+ false
+ }
+}
+
+impl DrawableComponent for DetailsComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(
+ [Constraint::Length(8), Constraint::Min(10)].as_ref(),
+ )
+ .split(rect);
+
+ f.render_widget(
+ dialog_paragraph(
+ &strings::commit::details_info_title(
+ &self.key_config,
+ ),
+ Text::from(self.get_text_info()),
+ &self.theme,
+ false,
+ ),
+ chunks[0],
+ );
+
+ // We have to take the border into account which is one character on
+ // each side.
+ let border_width: u16 = 2;
+
+ let width = chunks[1].width.saturating_sub(border_width);
+ let height = chunks[1].height.saturating_sub(border_width);
+
+ self.current_size.set((width, height));
+
+ let wrapped_lines = self.get_wrapped_text_message(
+ width as usize,
+ height as usize,
+ );
+
+ f.render_widget(
+ dialog_paragraph(
+ &strings::commit::details_message_title(
+ &self.key_config,
+ ),
+ Text::from(wrapped_lines),
+ &self.theme,
+ self.focused,
+ ),
+ chunks[1],
+ );
+
+ if self.focused {
+ ui::draw_scrollbar(
+ f,
+ chunks[1],
+ &self.theme,
+ self.get_number_of_lines(width as usize),
+ self.scroll_top.get(),
+ )
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for DetailsComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ // visibility_blocking(self)
+
+ let width = self.current_size.get().0 as usize;
+ let number_of_lines = self.get_number_of_lines(width);
+
+ out.push(
+ CommandInfo::new(
+ strings::commands::navigate_commit_message(
+ &self.key_config,
+ ),
+ number_of_lines > 0,
+ self.focused || force_all,
+ )
+ .order(order::NAV),
+ );
+
+ CommandBlocking::PassingOn
+ }
+
+ fn event(&mut self, event: Event) -> Result<bool> {
+ if self.focused {
+ if let Event::Key(e) = event {
+ return Ok(if e == self.key_config.move_up {
+ self.move_scroll_top(ScrollType::Up)
+ } else if e == self.key_config.move_down {
+ self.move_scroll_top(ScrollType::Down)
+ } else if e == self.key_config.home
+ || e == self.key_config.shift_up
+ {
+ self.move_scroll_top(ScrollType::Home)
+ } else if e == self.key_config.end
+ || e == self.key_config.shift_down
+ {
+ self.move_scroll_top(ScrollType::End)
+ } else {
+ false
+ });
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn focused(&self) -> bool {
+ self.focused
+ }
+
+ fn focus(&mut self, focus: bool) {
+ if focus {
+ let width = self.current_size.get().0 as usize;
+ let height = self.current_size.get().1 as usize;
+
+ self.scroll_top.set(
+ self.get_number_of_lines(width)
+ .saturating_sub(height),
+ );
+ }
+
+ self.focused = focus;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn get_wrapped_lines(
+ message: &CommitMessage,
+ width: usize,
+ ) -> Vec<Cow<'_, str>> {
+ let (wrapped_title, wrapped_message) =
+ DetailsComponent::wrap_commit_details(&message, width);
+
+ [&wrapped_title[..], &wrapped_message[..]].concat()
+ }
+
+ #[test]
+ fn test_textwrap() {
+ let message = CommitMessage::from("Commit message");
+
+ assert_eq!(
+ get_wrapped_lines(&message, 7),
+ vec!["Commit", "message"]
+ );
+ assert_eq!(
+ get_wrapped_lines(&message, 14),
+ vec!["Commit message"]
+ );
+
+ let message_with_newline =
+ CommitMessage::from("Commit message\n");
+
+ assert_eq!(
+ get_wrapped_lines(&message_with_newline, 7),
+ vec!["Commit", "message"]
+ );
+ assert_eq!(
+ get_wrapped_lines(&message_with_newline, 14),
+ vec!["Commit message"]
+ );
+
+ let message_with_body = CommitMessage::from(
+ "Commit message\n\nFirst line\nSecond line",
+ );
+
+ assert_eq!(
+ get_wrapped_lines(&message_with_body, 7),
+ vec![
+ "Commit", "message", "First", "line", "Second",
+ "line"
+ ]
+ );
+ assert_eq!(
+ get_wrapped_lines(&message_with_body, 14),
+ vec!["Commit message", "First line", "Second line"]
+ );
+ }
+}
diff --git a/src/components/commit_details/mod.rs b/src/components/commit_details/mod.rs
new file mode 100644
index 0000000..35afda1
--- /dev/null
+++ b/src/components/commit_details/mod.rs
@@ -0,0 +1,208 @@
+mod details;
+
+use super::{
+ command_pump, event_pump, CommandBlocking, CommandInfo,
+ Component, DrawableComponent, FileTreeComponent,
+};
+use crate::{
+ accessors, keys::SharedKeyConfig, queue::Queue, strings,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{
+ sync::{CommitId, CommitTags},
+ AsyncCommitFiles, AsyncNotification,
+};
+use crossbeam_channel::Sender;
+use crossterm::event::Event;
+use details::DetailsComponent;
+use tui::{
+ backend::Backend,
+ layout::{Constraint, Direction, Layout, Rect},
+ Frame,
+};
+
+pub struct CommitDetailsComponent {
+ details: DetailsComponent,
+ file_tree: FileTreeComponent,
+ git_commit_files: AsyncCommitFiles,
+ visible: bool,
+ key_config: SharedKeyConfig,
+}
+
+impl CommitDetailsComponent {
+ accessors!(self, [details, file_tree]);
+
+ ///
+ pub fn new(
+ queue: &Queue,
+ sender: &Sender<AsyncNotification>,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ details: DetailsComponent::new(
+ theme.clone(),
+ key_config.clone(),
+ false,
+ ),
+ git_commit_files: AsyncCommitFiles::new(sender),
+ file_tree: FileTreeComponent::new(
+ "",
+ false,
+ Some(queue.clone()),
+ theme,
+ key_config.clone(),
+ ),
+ visible: false,
+ key_config,
+ }
+ }
+
+ fn get_files_title(&self) -> String {
+ let files_count = self.file_tree.file_count();
+
+ format!(
+ "{} {}",
+ strings::commit::details_files_title(&self.key_config),
+ files_count
+ )
+ }
+
+ ///
+ pub fn set_commit(
+ &mut self,
+ id: Option<CommitId>,
+ tags: Option<CommitTags>,
+ ) -> Result<()> {
+ self.details.set_commit(id, tags)?;
+
+ if let Some(id) = id {
+ if let Some((fetched_id, res)) =
+ self.git_commit_files.current()?
+ {
+ if fetched_id == id {
+ self.file_tree.update(res.as_slice())?;
+ self.file_tree.set_title(self.get_files_title());
+
+ return Ok(());
+ }
+ }
+
+ self.file_tree.clear()?;
+ self.git_commit_files.fetch(id)?;
+ }
+
+ self.file_tree.set_title(self.get_files_title());
+
+ Ok(())
+ }
+
+ ///
+ pub fn any_work_pending(&self) -> bool {
+ self.git_commit_files.is_pending()
+ }
+
+ ///
+ pub const fn files(&self) -> &FileTreeComponent {
+ &self.file_tree
+ }
+}
+
+impl DrawableComponent for CommitDetailsComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ let percentages = if self.file_tree.focused() {
+ (40, 60)
+ } else if self.details.focused() {
+ (60, 40)
+ } else {
+ (40, 60)
+ };
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(
+ [
+ Constraint::Percentage(percentages.0),
+ Constraint::Percentage(percentages.1),
+ ]
+ .as_ref(),
+ )
+ .split(rect);
+
+ self.details.draw(f, chunks[0])?;
+ self.file_tree.draw(f, chunks[1])?;
+
+ Ok(())
+ }
+}
+
+impl Component for CommitDetailsComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ if self.visible || force_all {
+ command_pump(
+ out,
+ force_all,
+ self.components().as_slice(),
+ );
+ }
+
+ CommandBlocking::PassingOn
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if event_pump(ev, self.components_mut().as_mut_slice())? {
+ return Ok(true);
+ }
+
+ if self.focused() {
+ if let Event::Key(e) = ev {
+ return if e == self.key_config.focus_below
+ && self.details.focused()
+ {
+ self.details.focus(false);
+ self.file_tree.focus(true);
+ Ok(true)
+ } else if e == self.key_config.focus_above
+ && self.file_tree.focused()
+ {
+ self.file_tree.focus(false);
+ self.details.focus(true);
+ Ok(true)
+ } else {
+ Ok(false)
+ };
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+ fn hide(&mut self) {
+ self.visible = false;
+ }
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+ Ok(())
+ }
+
+ fn focused(&self) -> bool {
+ self.details.focused() || self.file_tree.focused()
+ }
+ fn focus(&mut self, focus: bool) {
+ self.details.focus(false);
+ self.file_tree.focus(focus);
+ self.file_tree.show_selection(true);
+ }
+}
diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs
new file mode 100644
index 0000000..16b071d
--- /dev/null
+++ b/src/components/commitlist.rs
@@ -0,0 +1,437 @@
+use super::utils::logitems::{ItemBatch, LogEntry};
+use crate::{
+ components::{
+ CommandBlocking, CommandInfo, Component, DrawableComponent,
+ ScrollType,
+ },
+ keys::SharedKeyConfig,
+ strings,
+ ui::calc_scroll_top,
+ ui::style::{SharedTheme, Theme},
+};
+use anyhow::Result;
+use asyncgit::sync::Tags;
+use crossterm::event::Event;
+use std::{
+ borrow::Cow, cell::Cell, cmp, convert::TryFrom, time::Instant,
+};
+use tui::{
+ backend::Backend,
+ layout::{Alignment, Rect},
+ text::{Span, Spans},
+ widgets::{Block, Borders, Paragraph},
+ Frame,
+};
+use unicode_width::UnicodeWidthStr;
+
+const ELEMENTS_PER_LINE: usize = 10;
+
+///
+pub struct CommitList {
+ title: String,
+ selection: usize,
+ branch: Option<String>,
+ count_total: usize,
+ items: ItemBatch,
+ scroll_state: (Instant, f32),
+ tags: Option<Tags>,
+ current_size: Cell<(u16, u16)>,
+ scroll_top: Cell<usize>,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+}
+
+impl CommitList {
+ ///
+ pub fn new(
+ title: &str,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ items: ItemBatch::default(),
+ selection: 0,
+ branch: None,
+ count_total: 0,
+ scroll_state: (Instant::now(), 0_f32),
+ tags: None,
+ current_size: Cell::new((0, 0)),
+ scroll_top: Cell::new(0),
+ theme,
+ key_config,
+ title: String::from(title),
+ }
+ }
+
+ ///
+ pub fn items(&mut self) -> &mut ItemBatch {
+ &mut self.items
+ }
+
+ ///
+ pub fn set_branch(&mut self, name: Option<String>) {
+ self.branch = name;
+ }
+
+ ///
+ pub const fn selection(&self) -> usize {
+ self.selection
+ }
+
+ ///
+ pub fn current_size(&self) -> (u16, u16) {
+ self.current_size.get()
+ }
+
+ ///
+ pub fn set_count_total(&mut self, total: usize) {
+ self.count_total = total;
+ self.selection =
+ cmp::min(self.selection, self.selection_max());
+ }
+
+ ///
+ #[allow(clippy::missing_const_for_fn)]
+ pub fn selection_max(&self) -> usize {
+ self.count_total.saturating_sub(1)
+ }
+
+ ///
+ //TODO: make const as soon as Option::<T>::as_ref
+ // is stabilizeD to be const (not as of rust 1.47)
+ #[allow(clippy::missing_const_for_fn)]
+ pub fn tags(&self) -> Option<&Tags> {
+ self.tags.as_ref()
+ }
+
+ ///
+ pub fn clear(&mut self) {
+ self.items.clear();
+ }
+
+ ///
+ pub fn set_tags(&mut self, tags: Tags) {
+ self.tags = Some(tags);
+ }
+
+ ///
+ pub fn selected_entry(&self) -> Option<&LogEntry> {
+ self.items.iter().nth(
+ self.selection.saturating_sub(self.items.index_offset()),
+ )
+ }
+
+ pub fn copy_entry_hash(&self) -> Result<()> {
+ if let Some(e) = self.items.iter().nth(
+ self.selection.saturating_sub(self.items.index_offset()),
+ ) {
+ crate::clipboard::copy_string(&e.hash_short)?;
+ }
+ Ok(())
+ }
+
+ fn move_selection(&mut self, scroll: ScrollType) -> Result<bool> {
+ self.update_scroll_speed();
+
+ #[allow(clippy::cast_possible_truncation)]
+ let speed_int =
+ usize::try_from(self.scroll_state.1 as i64)?.max(1);
+
+ let page_offset =
+ usize::from(self.current_size.get().1).saturating_sub(1);
+
+ let new_selection = match scroll {
+ ScrollType::Up => {
+ self.selection.saturating_sub(speed_int)
+ }
+ ScrollType::Down => {
+ self.selection.saturating_add(speed_int)
+ }
+ ScrollType::PageUp => {
+ self.selection.saturating_sub(page_offset)
+ }
+ ScrollType::PageDown => {
+ self.selection.saturating_add(page_offset)
+ }
+ ScrollType::Home => 0,
+ ScrollType::End => self.selection_max(),
+ };
+
+ let new_selection =
+ cmp::min(new_selection, self.selection_max());
+
+ let needs_update = new_selection != self.selection;
+
+ self.selection = new_selection;
+
+ Ok(needs_update)
+ }
+
+ fn update_scroll_speed(&mut self) {
+ const REPEATED_SCROLL_THRESHOLD_MILLIS: u128 = 300;
+ const SCROLL_SPEED_START: f32 = 0.1_f32;
+ const SCROLL_SPEED_MAX: f32 = 10_f32;
+ const SCROLL_SPEED_MULTIPLIER: f32 = 1.05_f32;
+
+ let now = Instant::now();
+
+ let since_last_scroll =
+ now.duration_since(self.scroll_state.0);
+
+ self.scroll_state.0 = now;
+
+ let speed = if since_last_scroll.as_millis()
+ < REPEATED_SCROLL_THRESHOLD_MILLIS
+ {
+ self.scroll_state.1 * SCROLL_SPEED_MULTIPLIER
+ } else {
+ SCROLL_SPEED_START
+ };
+
+ self.scroll_state.1 = speed.min(SCROLL_SPEED_MAX);
+ }
+
+ fn get_entry_to_add<'a>(
+ e: &'a LogEntry,
+ selected: bool,
+ tags: Option<String>,
+ theme: &Theme,
+ width: usize,
+ ) -> Spans<'a> {
+ let mut txt: Vec<Span> = Vec::new();
+ txt.reserve(ELEMENTS_PER_LINE);
+
+ let splitter_txt = Cow::from(" ");
+ let splitter =
+ Span::styled(splitter_txt, theme.text(true, selected));
+
+ // commit hash
+ txt.push(Span::styled(
+ Cow::from(e.hash_short.as_str()),
+ theme.commit_hash(selected),
+ ));
+
+ txt.push(splitter.clone());
+
+ // commit timestamp
+ txt.push(Span::styled(
+ Cow::from(e.time.as_str()),
+ theme.commit_time(selected),
+ ));
+
+ txt.push(splitter.clone());
+
+ let author_width =
+ (width.saturating_sub(19) / 3).max(3).min(20);
+ let author = string_width_align(&e.author, author_width);
+
+ // commit author
+ txt.push(Span::styled::<String>(
+ author,
+ theme.commit_author(selected),
+ ));
+
+ txt.push(splitter.clone());
+
+ // commit tags
+ txt.push(Span::styled(
+ Cow::from(if let Some(tags) = tags {
+ format!(" {}", tags)
+ } else {
+ String::from("")
+ }),
+ theme.tags(selected),
+ ));
+
+ txt.push(splitter);
+
+ // commit msg
+ txt.push(Span::styled(
+ Cow::from(e.msg.as_str()),
+ theme.text(true, selected),
+ ));
+ Spans::from(txt)
+ }
+
+ fn get_text(&self, height: usize, width: usize) -> Vec<Spans> {
+ let selection = self.relative_selection();
+
+ let mut txt: Vec<Spans> = Vec::with_capacity(height);
+
+ for (idx, e) in self
+ .items
+ .iter()
+ .skip(self.scroll_top.get())
+ .take(height)
+ .enumerate()
+ {
+ let tags = self
+ .tags
+ .as_ref()
+ .and_then(|t| t.get(&e.id))
+ .map(|tags| tags.join(" "));
+ txt.push(Self::get_entry_to_add(
+ e,
+ idx + self.scroll_top.get() == selection,
+ tags,
+ &self.theme,
+ width,
+ ));
+ }
+
+ txt
+ }
+
+ #[allow(clippy::missing_const_for_fn)]
+ fn relative_selection(&self) -> usize {
+ self.selection.saturating_sub(self.items.index_offset())
+ }
+}
+
+impl DrawableComponent for CommitList {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ area: Rect,
+ ) -> Result<()> {
+ let current_size = (
+ area.width.saturating_sub(2),
+ area.height.saturating_sub(2),
+ );
+ self.current_size.set(current_size);
+
+ let height_in_lines = self.current_size.get().1 as usize;
+ let selection = self.relative_selection();
+
+ self.scroll_top.set(calc_scroll_top(
+ self.scroll_top.get(),
+ height_in_lines,
+ selection,
+ ));
+
+ let branch_post_fix =
+ self.branch.as_ref().map(|b| format!("- {{{}}}", b));
+
+ let title = format!(
+ "{} {}/{} {}",
+ self.title,
+ self.count_total.saturating_sub(self.selection),
+ self.count_total,
+ branch_post_fix.as_deref().unwrap_or(""),
+ );
+
+ f.render_widget(
+ Paragraph::new(
+ self.get_text(
+ height_in_lines,
+ current_size.0 as usize,
+ ),
+ )
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .title(Span::styled(
+ title.as_str(),
+ self.theme.title(true),
+ ))
+ .border_style(self.theme.block(true)),
+ )
+ .alignment(Alignment::Left),
+ area,
+ );
+
+ Ok(())
+ }
+}
+
+impl Component for CommitList {
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if let Event::Key(k) = ev {
+ let selection_changed = if k == self.key_config.move_up {
+ self.move_selection(ScrollType::Up)?
+ } else if k == self.key_config.move_down {
+ self.move_selection(ScrollType::Down)?
+ } else if k == self.key_config.shift_up
+ || k == self.key_config.home
+ {
+ self.move_selection(ScrollType::Home)?
+ } else if k == self.key_config.shift_down
+ || k == self.key_config.end
+ {
+ self.move_selection(ScrollType::End)?
+ } else if k == self.key_config.page_up {
+ self.move_selection(ScrollType::PageUp)?
+ } else if k == self.key_config.page_down {
+ self.move_selection(ScrollType::PageDown)?
+ } else {
+ false
+ };
+ return Ok(selection_changed);
+ }
+
+ Ok(false)
+ }
+
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ _force_all: bool,
+ ) -> CommandBlocking {
+ out.push(CommandInfo::new(
+ strings::commands::scroll(&self.key_config),
+ self.selected_entry().is_some(),
+ true,
+ ));
+ CommandBlocking::PassingOn
+ }
+}
+
+#[inline]
+fn string_width_align(s: &str, width: usize) -> String {
+ static POSTFIX: &str = "..";
+
+ let len = UnicodeWidthStr::width(s);
+ let width_wo_postfix = width.saturating_sub(POSTFIX.len());
+
+ if (len >= width_wo_postfix && len <= width)
+ || (len <= width_wo_postfix)
+ {
+ format!("{:w$}", s, w = width)
+ } else {
+ let mut s = s.to_string();
+ s.truncate(find_truncate_point(&s, width_wo_postfix));
+ format!("{}{}", s, POSTFIX)
+ }
+}
+
+#[inline]
+fn find_truncate_point(s: &str, chars: usize) -> usize {
+ s.chars().take(chars).map(char::len_utf8).sum()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_string_width_align() {
+ assert_eq!(string_width_align("123", 3), "123");
+ assert_eq!(string_width_align("123", 2), "..");
+ assert_eq!(string_width_align("123", 3), "123");
+ assert_eq!(string_width_align("12345", 6), "12345 ");
+ assert_eq!(string_width_align("1234556", 4), "12..");
+ }
+
+ #[test]
+ fn test_string_width_align_unicode() {
+ assert_eq!(string_width_align("äste", 3), "ä..");
+ assert_eq!(
+ string_width_align("wüsten äste", 10),
+ "wüsten ä.."
+ );
+ assert_eq!(
+ string_width_align("Jon Grythe Stødle", 19),
+ "Jon Grythe Stødle "
+ );
+ }
+}
diff --git a/src/components/create_branch.rs b/src/components/create_branch.rs
new file mode 100644
index 0000000..d10e4f6
--- /dev/null
+++ b/src/components/create_branch.rs
@@ -0,0 +1,144 @@
+use super::{
+ textinput::TextInputComponent, visibility_blocking,
+ CommandBlocking, CommandInfo, Component, DrawableComponent,
+};
+use crate::{
+ keys::SharedKeyConfig,
+ queue::{InternalEvent, NeedsUpdate, Queue},
+ strings,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{
+ sync::{self, CommitId},
+ CWD,
+};
+use crossterm::event::Event;
+use tui::{backend::Backend, layout::Rect, Frame};
+
+pub struct CreateBranchComponent {
+ input: TextInputComponent,
+ commit_id: Option<CommitId>,
+ queue: Queue,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for CreateBranchComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ self.input.draw(f, rect)?;
+
+ Ok(())
+ }
+}
+
+impl Component for CreateBranchComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ if self.is_visible() || force_all {
+ self.input.commands(out, force_all);
+
+ out.push(CommandInfo::new(
+ strings::commands::create_branch_confirm_msg(
+ &self.key_config,
+ ),
+ true,
+ true,
+ ));
+ }
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.is_visible() {
+ if self.input.event(ev)? {
+ return Ok(true);
+ }
+
+ if let Event::Key(e) = ev {
+ if e == self.key_config.enter {
+ self.create_branch();
+ }
+
+ return Ok(true);
+ }
+ }
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.input.is_visible()
+ }
+
+ fn hide(&mut self) {
+ self.input.hide()
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.input.show()?;
+
+ Ok(())
+ }
+}
+
+impl CreateBranchComponent {
+ ///
+ pub fn new(
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ queue,
+ input: TextInputComponent::new(
+ theme,
+ key_config.clone(),
+ &strings::create_branch_popup_title(&key_config),
+ &strings::create_branch_popup_msg(&key_config),
+ ),
+ commit_id: None,
+ key_config,
+ }
+ }
+
+ ///
+ pub fn open(&mut self) -> Result<()> {
+ self.commit_id = None;
+ self.show()?;
+
+ Ok(())
+ }
+
+ ///
+ pub fn create_branch(&mut self) {
+ let res =
+ sync::create_branch(CWD, self.input.get_text().as_str());
+
+ self.input.clear();
+ self.hide();
+
+ match res {
+ Ok(_) => {
+ self.queue.borrow_mut().push_back(
+ InternalEvent::Update(NeedsUpdate::ALL),
+ );
+ }
+ Err(e) => {
+ log::error!("create branch: {}", e,);
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "create branch error:\n{}",
+ e,
+ )),
+ );
+ }
+ }
+ }
+}
diff --git a/src/components/cred.rs b/src/components/cred.rs
new file mode 100644
index 0000000..8f0d526
--- /dev/null
+++ b/src/components/cred.rs
@@ -0,0 +1,164 @@
+use anyhow::Result;
+use crossterm::event::Event;
+use tui::{backend::Backend, layout::Rect, Frame};
+
+use asyncgit::sync::cred::BasicAuthCredential;
+
+use crate::components::{InputType, TextInputComponent};
+use crate::{
+ components::{
+ visibility_blocking, CommandBlocking, CommandInfo, Component,
+ DrawableComponent,
+ },
+ keys::SharedKeyConfig,
+ strings,
+ ui::style::SharedTheme,
+};
+
+///
+pub struct CredComponent {
+ visible: bool,
+ key_config: SharedKeyConfig,
+ input_username: TextInputComponent,
+ input_password: TextInputComponent,
+ cred: BasicAuthCredential,
+}
+
+impl CredComponent {
+ ///
+ pub fn new(
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ visible: false,
+ input_username: TextInputComponent::new(
+ theme.clone(),
+ key_config.clone(),
+ &strings::username_popup_title(&key_config),
+ &strings::username_popup_msg(&key_config),
+ )
+ .with_input_type(InputType::Singleline),
+ input_password: TextInputComponent::new(
+ theme,
+ key_config.clone(),
+ &strings::password_popup_title(&key_config),
+ &strings::password_popup_msg(&key_config),
+ )
+ .with_input_type(InputType::Password),
+ key_config,
+ cred: BasicAuthCredential::new(None, None),
+ }
+ }
+
+ pub fn set_cred(&mut self, cred: BasicAuthCredential) {
+ self.cred = cred;
+ }
+
+ pub const fn get_cred(&self) -> &BasicAuthCredential {
+ &self.cred
+ }
+}
+
+impl DrawableComponent for CredComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ if self.visible {
+ self.input_username.draw(f, rect)?;
+ self.input_password.draw(f, rect)?;
+ }
+ Ok(())
+ }
+}
+
+impl Component for CredComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ _force_all: bool,
+ ) -> CommandBlocking {
+ if self.is_visible() {
+ out.clear();
+ }
+
+ out.push(CommandInfo::new(
+ strings::commands::validate_msg(&self.key_config),
+ true,
+ self.visible,
+ ));
+ out.push(CommandInfo::new(
+ strings::commands::close_popup(&self.key_config),
+ true,
+ self.visible,
+ ));
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.visible {
+ if let Event::Key(e) = ev {
+ if e == self.key_config.exit_popup {
+ self.hide();
+ }
+ if self.input_username.event(ev)?
+ || self.input_password.event(ev)?
+ {
+ return Ok(true);
+ } else if e == self.key_config.enter {
+ if self.input_username.is_visible() {
+ self.cred = BasicAuthCredential::new(
+ Some(
+ self.input_username
+ .get_text()
+ .to_owned(),
+ ),
+ None,
+ );
+ self.input_username.hide();
+ self.input_password.show()?;
+ } else if self.input_password.is_visible() {
+ self.cred = BasicAuthCredential::new(
+ self.cred.username.clone(),
+ Some(
+ self.input_password
+ .get_text()
+ .to_owned(),
+ ),
+ );
+ self.input_password.hide();
+ self.input_password.clear();
+ return Ok(false);
+ } else {
+ self.hide();
+ }
+ }
+ }
+ return Ok(true);
+ }
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+
+ fn hide(&mut self) {
+ self.cred = BasicAuthCredential::new(None, None);
+ self.visible = false;
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+ if self.cred.username.is_none() {
+ self.input_username.show()
+ } else if self.cred.password.is_none() {
+ self.input_password.show()
+ } else {
+ Ok(())
+ }
+ }
+}
diff --git a/src/components/diff.rs b/src/components/diff.rs
new file mode 100644
index 0000000..b5be156
--- /dev/null
+++ b/src/components/diff.rs
@@ -0,0 +1,698 @@
+use super::{
+ CommandBlocking, Direction, DrawableComponent, ScrollType,
+};
+use crate::{
+ components::{CommandInfo, Component},
+ keys::SharedKeyConfig,
+ queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem},
+ strings, try_or_popup,
+ ui::{self, calc_scroll_top, style::SharedTheme},
+};
+use anyhow::Result;
+use asyncgit::{hash, sync, DiffLine, DiffLineType, FileDiff, CWD};
+use bytesize::ByteSize;
+use crossterm::event::Event;
+use std::{borrow::Cow, cell::Cell, cmp, path::Path};
+use tui::{
+ backend::Backend,
+ layout::Rect,
+ symbols,
+ text::{Span, Spans},
+ widgets::{Block, Borders, Paragraph},
+ Frame,
+};
+
+#[derive(Default)]
+struct Current {
+ path: String,
+ is_stage: bool,
+ hash: u64,
+}
+
+///
+#[derive(Clone, Copy)]
+enum Selection {
+ Single(usize),
+ Multiple(usize, usize),
+}
+
+impl Selection {
+ const fn get_start(&self) -> usize {
+ match self {
+ Self::Single(start) | Self::Multiple(start, _) => *start,
+ }
+ }
+
+ const fn get_end(&self) -> usize {
+ match self {
+ Self::Single(end) | Self::Multiple(_, end) => *end,
+ }
+ }
+
+ fn get_top(&self) -> usize {
+ match self {
+ Self::Single(start) => *start,
+ Self::Multiple(start, end) => cmp::min(*start, *end),
+ }
+ }
+
+ fn get_bottom(&self) -> usize {
+ match self {
+ Self::Single(start) => *start,
+ Self::Multiple(start, end) => cmp::max(*start, *end),
+ }
+ }
+
+ fn modify(&mut self, direction: Direction, max: usize) {
+ let start = self.get_start();
+ let old_end = self.get_end();
+
+ *self = match direction {
+ Direction::Up => {
+ Self::Multiple(start, old_end.saturating_sub(1))
+ }
+
+ Direction::Down => {
+ Self::Multiple(start, cmp::min(old_end + 1, max))
+ }
+ };
+ }
+
+ fn contains(&self, index: usize) -> bool {
+ match self {
+ Self::Single(start) => index == *start,
+ Self::Multiple(start, end) => {
+ if start <= end {
+ *start <= index && index <= *end
+ } else {
+ *end <= index && index <= *start
+ }
+ }
+ }
+ }
+}
+
+///
+pub struct DiffComponent {
+ diff: Option<FileDiff>,
+ pending: bool,
+ selection: Selection,
+ selected_hunk: Option<usize>,
+ current_size: Cell<(u16, u16)>,
+ focused: bool,
+ current: Current,
+ scroll_top: Cell<usize>,
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ is_immutable: bool,
+}
+
+impl DiffComponent {
+ ///
+ pub fn new(
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ is_immutable: bool,
+ ) -> Self {
+ Self {
+ focused: false,
+ queue,
+ current: Current::default(),
+ pending: false,
+ selected_hunk: None,
+ diff: None,
+ current_size: Cell::new((0, 0)),
+ selection: Selection::Single(0),
+ scroll_top: Cell::new(0),
+ theme,
+ key_config,
+ is_immutable,
+ }
+ }
+ ///
+ fn can_scroll(&self) -> bool {
+ self.diff
+ .as_ref()
+ .map(|diff| diff.lines > 1)
+ .unwrap_or_default()
+ }
+ ///
+ pub fn current(&self) -> (String, bool) {
+ (self.current.path.clone(), self.current.is_stage)
+ }
+ ///
+ pub fn clear(&mut self, pending: bool) -> Result<()> {
+ self.current = Current::default();
+ self.diff = None;
+ self.scroll_top.set(0);
+ self.selection = Selection::Single(0);
+ self.selected_hunk = None;
+ self.pending = pending;
+
+ Ok(())
+ }
+ ///
+ pub fn update(
+ &mut self,
+ path: String,
+ is_stage: bool,
+ diff: FileDiff,
+ ) -> Result<()> {
+ self.pending = false;
+
+ let hash = hash(&diff);
+
+ if self.current.hash != hash {
+ self.current = Current {
+ path,
+ is_stage,
+ hash,
+ };
+
+ self.selected_hunk = Self::find_selected_hunk(
+ &diff,
+ self.selection.get_start(),
+ );
+
+ self.diff = Some(diff);
+ self.scroll_top.set(0);
+ self.selection = Selection::Single(0);
+ }
+
+ Ok(())
+ }
+
+ fn move_selection(&mut self, move_type: ScrollType) {
+ if let Some(diff) = &self.diff {
+ let max = diff.lines.saturating_sub(1) as usize;
+
+ let new_start = match move_type {
+ ScrollType::Down => {
+ self.selection.get_bottom().saturating_add(1)
+ }
+ ScrollType::Up => {
+ self.selection.get_top().saturating_sub(1)
+ }
+ ScrollType::Home => 0,
+ ScrollType::End => max,
+ ScrollType::PageDown => {
+ self.selection.get_bottom().saturating_add(
+ self.current_size.get().1.saturating_sub(1)
+ as usize,
+ )
+ }
+ ScrollType::PageUp => {
+ self.selection.get_top().saturating_sub(
+ self.current_size.get().1.saturating_sub(1)
+ as usize,
+ )
+ }
+ };
+
+ let new_start = cmp::min(max, new_start);
+
+ self.selection = Selection::Single(new_start);
+
+ self.selected_hunk =
+ Self::find_selected_hunk(diff, new_start);
+ }
+ }
+
+ fn lines_count(&self) -> usize {
+ self.diff
+ .as_ref()
+ .map_or(0, |diff| diff.lines.saturating_sub(1))
+ }
+
+ fn modify_selection(&mut self, direction: Direction) {
+ if let Some(diff) = &self.diff {
+ let max = diff.lines.saturating_sub(1);
+
+ self.selection.modify(direction, max);
+ }
+ }
+
+ fn copy_selection(&self) {
+ if let Some(diff) = &self.diff {
+ let lines_to_copy: Vec<&str> = diff
+ .hunks
+ .iter()
+ .flat_map(|hunk| hunk.lines.iter())
+ .enumerate()
+ .filter_map(|(i, line)| {
+ if self.selection.contains(i) {
+ Some(
+ line.content
+ .trim_matches(|c| {
+ c == '\n' || c == '\r'
+ })
+ .as_ref(),
+ )
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ try_or_popup!(
+ self,
+ "copy to clipboard error:",
+ crate::clipboard::copy_string(
+ &lines_to_copy.join("\n")
+ )
+ );
+ }
+ }
+
+ fn find_selected_hunk(
+ diff: &FileDiff,
+ line_selected: usize,
+ ) -> Option<usize> {
+ let mut line_cursor = 0_usize;
+ for (i, hunk) in diff.hunks.iter().enumerate() {
+ let hunk_len = hunk.lines.len();
+ let hunk_min = line_cursor;
+ let hunk_max = line_cursor + hunk_len;
+
+ let hunk_selected =
+ hunk_min <= line_selected && hunk_max > line_selected;
+
+ if hunk_selected {
+ return Some(i);
+ }
+
+ line_cursor += hunk_len;
+ }
+
+ None
+ }
+
+ fn get_text(&self, width: u16, height: u16) -> Vec<Spans> {
+ let mut res: Vec<Spans> = Vec::new();
+ if let Some(diff) = &self.diff {
+ if diff.hunks.is_empty() {
+ let is_positive = diff.size_delta >= 0;
+ let delta_byte_size =
+ ByteSize::b(diff.size_delta.abs() as u64);
+ let sign = if is_positive { "+" } else { "-" };
+ res.extend(vec![Spans::from(vec![
+ Span::raw(Cow::from("size: ")),
+ Span::styled(
+ Cow::from(format!(
+ "{}",
+ ByteSize::b(diff.sizes.0)
+ )),
+ self.theme.text(false, false),
+ ),
+ Span::raw(Cow::from(" -> ")),
+ Span::styled(
+ Cow::from(format!(
+ "{}",
+ ByteSize::b(diff.sizes.1)
+ )),
+ self.theme.text(false, false),
+ ),
+ Span::raw(Cow::from(" (")),
+ Span::styled(
+ Cow::from(format!(
+ "{}{:}",
+ sign, delta_byte_size
+ )),
+ self.theme.diff_line(
+ if is_positive {
+ DiffLineType::Add
+ } else {
+ DiffLineType::Delete
+ },
+ false,
+ ),
+ ),
+ Span::raw(Cow::from(")")),
+ ])]);
+ } else {
+ let min = self.scroll_top.get();
+ let max = min + height as usize;
+
+ let mut line_cursor = 0_usize;
+ let mut lines_added = 0_usize;
+
+ for (i, hunk) in diff.hunks.iter().enumerate() {
+ let hunk_selected = self.focused()
+ && self
+ .selected_hunk
+ .map_or(false, |s| s == i);
+
+ if lines_added >= height as usize {
+ break;
+ }
+
+ let hunk_len = hunk.lines.len();
+ let hunk_min = line_cursor;
+ let hunk_max = line_cursor + hunk_len;
+
+ if Self::hunk_visible(
+ hunk_min, hunk_max, min, max,
+ ) {
+ for (i, line) in hunk.lines.iter().enumerate()
+ {
+ if line_cursor >= min
+ && line_cursor <= max
+ {
+ res.push(Self::get_line_to_add(
+ width,
+ line,
+ self.focused()
+ && self
+ .selection
+ .contains(line_cursor),
+ hunk_selected,
+ i == hunk_len as usize - 1,
+ &self.theme,
+ ));
+ lines_added += 1;
+ }
+
+ line_cursor += 1;
+ }
+ } else {
+ line_cursor += hunk_len;
+ }
+ }
+ }
+ }
+ res
+ }
+
+ fn get_line_to_add<'a>(
+ width: u16,
+ line: &'a DiffLine,
+ selected: bool,
+ selected_hunk: bool,
+ end_of_hunk: bool,
+ theme: &SharedTheme,
+ ) -> Spans<'a> {
+ let style = theme.diff_hunk_marker(selected_hunk);
+
+ let left_side_of_line = if end_of_hunk {
+ Span::styled(Cow::from(symbols::line::BOTTOM_LEFT), style)
+ } else {
+ match line.line_type {
+ DiffLineType::Header => Span::styled(
+ Cow::from(symbols::line::TOP_LEFT),
+ style,
+ ),
+ _ => Span::styled(
+ Cow::from(symbols::line::VERTICAL),
+ style,
+ ),
+ }
+ };
+
+ let trimmed =
+ line.content.trim_matches(|c| c == '\n' || c == '\r');
+
+ let filled = if selected {
+ // selected line
+ format!("{:w$}\n", trimmed, w = width as usize)
+ } else {
+ // weird eof missing eol line
+ format!("{}\n", trimmed)
+ };
+ //TODO: allow customize tabsize
+ let content = Cow::from(filled.replace("\t", " "));
+
+ Spans::from(vec![
+ left_side_of_line,
+ Span::styled(
+ content,
+ theme.diff_line(line.line_type, selected),
+ ),
+ ])
+ }
+
+ const fn hunk_visible(
+ hunk_min: usize,
+ hunk_max: usize,
+ min: usize,
+ max: usize,
+ ) -> bool {
+ // full overlap
+ if hunk_min <= min && hunk_max >= max {
+ return true;
+ }
+
+ // partly overlap
+ if (hunk_min >= min && hunk_min <= max)
+ || (hunk_max >= min && hunk_max <= max)
+ {
+ return true;
+ }
+
+ false
+ }
+
+ fn unstage_hunk(&mut self) -> Result<()> {
+ if let Some(diff) = &self.diff {
+ if let Some(hunk) = self.selected_hunk {
+ let hash = diff.hunks[hunk].header_hash;
+ sync::unstage_hunk(
+ CWD,
+ self.current.path.clone(),
+ hash,
+ )?;
+ self.queue_update();
+ }
+ }
+
+ Ok(())
+ }
+
+ fn stage_hunk(&mut self) -> Result<()> {
+ if let Some(diff) = &self.diff {
+ if let Some(hunk) = self.selected_hunk {
+ let path = self.current.path.clone();
+ if diff.untracked {
+ sync::stage_add_file(CWD, Path::new(&path))?;
+ } else {
+ let hash = diff.hunks[hunk].header_hash;
+ sync::stage_hunk(CWD, path, hash)?;
+ }
+
+ self.queue_update();
+ }
+ }
+
+ Ok(())
+ }
+
+ fn queue_update(&mut self) {
+ self.queue
+ .as_ref()
+ .borrow_mut()
+ .push_back(InternalEvent::Update(NeedsUpdate::ALL));
+ }
+
+ fn reset_hunk(&self) {
+ if let Some(diff) = &self.diff {
+ if let Some(hunk) = self.selected_hunk {
+ let hash = diff.hunks[hunk].header_hash;
+
+ self.queue.as_ref().borrow_mut().push_back(
+ InternalEvent::ConfirmAction(Action::ResetHunk(
+ self.current.path.clone(),
+ hash,
+ )),
+ );
+ }
+ }
+ }
+
+ fn reset_untracked(&self) {
+ self.queue.as_ref().borrow_mut().push_back(
+ InternalEvent::ConfirmAction(Action::Reset(ResetItem {
+ path: self.current.path.clone(),
+ is_folder: false,
+ })),
+ );
+ }
+
+ const fn is_stage(&self) -> bool {
+ self.current.is_stage
+ }
+}
+
+impl DrawableComponent for DiffComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ r: Rect,
+ ) -> Result<()> {
+ self.current_size.set((
+ r.width.saturating_sub(2),
+ r.height.saturating_sub(2),
+ ));
+
+ self.scroll_top.set(calc_scroll_top(
+ self.scroll_top.get(),
+ self.current_size.get().1 as usize,
+ self.selection.get_end(),
+ ));
+
+ let title = format!(
+ "{}{}",
+ strings::title_diff(&self.key_config),
+ self.current.path
+ );
+
+ let txt = if self.pending {
+ vec![Spans::from(vec![Span::styled(
+ Cow::from(strings::loading_text(&self.key_config)),
+ self.theme.text(false, false),
+ )])]
+ } else {
+ self.get_text(r.width, self.current_size.get().1)
+ };
+
+ f.render_widget(
+ Paragraph::new(txt).block(
+ Block::default()
+ .title(Span::styled(
+ title.as_str(),
+ self.theme.title(self.focused),
+ ))
+ .borders(Borders::ALL)
+ .border_style(self.theme.block(self.focused)),
+ ),
+ r,
+ );
+ if self.focused {
+ ui::draw_scrollbar(
+ f,
+ r,
+ &self.theme,
+ self.lines_count(),
+ self.scroll_top.get(),
+ );
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for DiffComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ _force_all: bool,
+ ) -> CommandBlocking {
+ out.push(CommandInfo::new(
+ strings::commands::scroll(&self.key_config),
+ self.can_scroll(),
+ self.focused,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::copy(&self.key_config),
+ true,
+ self.focused,
+ ));
+
+ out.push(
+ CommandInfo::new(
+ strings::commands::diff_home_end(&self.key_config),
+ self.can_scroll(),
+ self.focused,
+ )
+ .hidden(),
+ );
+
+ if !self.is_immutable {
+ out.push(CommandInfo::new(
+ strings::commands::diff_hunk_remove(&self.key_config),
+ self.selected_hunk.is_some(),
+ self.focused && self.is_stage(),
+ ));
+ out.push(CommandInfo::new(
+ strings::commands::diff_hunk_add(&self.key_config),
+ self.selected_hunk.is_some(),
+ self.focused && !self.is_stage(),
+ ));
+ out.push(CommandInfo::new(
+ strings::commands::diff_hunk_revert(&self.key_config),
+ self.selected_hunk.is_some(),
+ self.focused && !self.is_stage(),
+ ));
+ }
+
+ CommandBlocking::PassingOn
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.focused {
+ if let Event::Key(e) = ev {
+ return if e == self.key_config.move_down {
+ self.move_selection(ScrollType::Down);
+ Ok(true)
+ } else if e == self.key_config.shift_down {
+ self.modify_selection(Direction::Down);
+ Ok(true)
+ } else if e == self.key_config.shift_up {
+ self.modify_selection(Direction::Up);
+ Ok(true)
+ } else if e == self.key_config.end {
+ self.move_selection(ScrollType::End);
+ Ok(true)
+ } else if e == self.key_config.home {
+ self.move_selection(ScrollType::Home);
+ Ok(true)
+ } else if e == self.key_config.move_up {
+ self.move_selection(ScrollType::Up);
+ Ok(true)
+ } else if e == self.key_config.page_up {
+ self.move_selection(ScrollType::PageUp);
+ Ok(true)
+ } else if e == self.key_config.page_down {
+ self.move_selection(ScrollType::PageDown);
+ Ok(true)
+ } else if e == self.key_config.enter
+ && !self.is_immutable
+ {
+ if self.current.is_stage {
+ self.unstage_hunk()?;
+ } else {
+ self.stage_hunk()?;
+ }
+ Ok(true)
+ } else if e == self.key_config.status_reset_item
+ && !self.is_immutable
+ && !self.is_stage()
+ {
+ if let Some(diff) = &self.diff {
+ if diff.untracked {
+ self.reset_untracked();
+ } else {
+ self.reset_hunk();
+ }
+ }
+ Ok(true)
+ } else if e == self.key_config.copy {
+ self.copy_selection();
+ Ok(true)
+ } else {
+ Ok(false)
+ };
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn focused(&self) -> bool {
+ self.focused
+ }
+ fn focus(&mut self, focus: bool) {
+ self.focused = focus
+ }
+}
diff --git a/src/components/externaleditor.rs b/src/components/externaleditor.rs
new file mode 100644
index 0000000..4b4e7ef
--- /dev/null
+++ b/src/components/externaleditor.rs
@@ -0,0 +1,189 @@
+use crate::{
+ components::{
+ visibility_blocking, CommandBlocking, CommandInfo, Component,
+ DrawableComponent,
+ },
+ keys::SharedKeyConfig,
+ strings,
+ ui::{self, style::SharedTheme},
+};
+use anyhow::{anyhow, bail, Result};
+use asyncgit::{
+ sync::utils::get_config_string, sync::utils::repo_work_dir, CWD,
+};
+use crossterm::{
+ event::Event,
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen},
+ ExecutableCommand,
+};
+use scopeguard::defer;
+use std::ffi::OsStr;
+use std::{env, io, path::Path, process::Command};
+use tui::{
+ backend::Backend,
+ layout::Rect,
+ text::{Span, Spans},
+ widgets::{Block, BorderType, Borders, Clear, Paragraph},
+ Frame,
+};
+
+///
+pub struct ExternalEditorComponent {
+ visible: bool,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+}
+
+impl ExternalEditorComponent {
+ ///
+ pub fn new(
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ visible: false,
+ theme,
+ key_config,
+ }
+ }
+
+ /// opens file at given `path` in an available editor
+ pub fn open_file_in_editor(path: &Path) -> Result<()> {
+ let work_dir = repo_work_dir(CWD)?;
+
+ let path = if path.is_relative() {
+ Path::new(&work_dir).join(path)
+ } else {
+ path.into()
+ };
+
+ if !path.exists() {
+ bail!("file not found: {:?}", path);
+ }
+
+ io::stdout().execute(LeaveAlternateScreen)?;
+ defer! {
+ io::stdout().execute(EnterAlternateScreen).expect("reset terminal");
+ }
+
+ let environment_options = ["GIT_EDITOR", "VISUAL", "EDITOR"];
+
+ let editor = env::var(environment_options[0])
+ .ok()
+ .or_else(|| get_config_string(CWD, "core.editor").ok()?)
+ .or_else(|| env::var(environment_options[1]).ok())
+ .or_else(|| env::var(environment_options[2]).ok())
+ .unwrap_or_else(|| String::from("vi"));
+
+ // TODO: proper handling arguments containing whitespaces
+ // This does not do the right thing if the input is `editor --something "with spaces"`
+
+ // deal with "editor name with spaces" p1 p2 p3
+ // and with "editor_no_spaces" p1 p2 p3
+ // does not address spaces in pn
+ let mut echars = editor.chars().peekable();
+
+ let first_char = *echars.peek().ok_or_else(|| {
+ anyhow!(
+ "editor env variable found empty: {}",
+ environment_options.join(" or ")
+ )
+ })?;
+ let command: String = if first_char == '\"' {
+ echars
+ .by_ref()
+ .skip(1)
+ .take_while(|c| *c != '\"')
+ .collect()
+ } else {
+ echars.by_ref().take_while(|c| *c != ' ').collect()
+ };
+
+ let remainder_str = echars.collect::<String>();
+ let remainder = remainder_str.split_whitespace();
+
+ let mut args: Vec<&OsStr> =
+ remainder.map(|s| OsStr::new(s)).collect();
+
+ args.push(path.as_os_str());
+
+ Command::new(command.clone())
+ .current_dir(work_dir)
+ .args(args)
+ .status()
+ .map_err(|e| anyhow!("\"{}\": {}", command, e))?;
+
+ Ok(())
+ }
+}
+
+impl DrawableComponent for ExternalEditorComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ _rect: Rect,
+ ) -> Result<()> {
+ if self.visible {
+ let txt = Spans::from(
+ strings::msg_opening_editor(&self.key_config)
+ .split('\n')
+ .map(|string| {
+ Span::raw::<String>(string.to_string())
+ })
+ .collect::<Vec<Span>>(),
+ );
+
+ let area = ui::centered_rect_absolute(25, 3, f.size());
+ f.render_widget(Clear, area);
+ f.render_widget(
+ Paragraph::new(txt)
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Thick)
+ .border_style(self.theme.block(true)),
+ )
+ .style(self.theme.block(true)),
+ area,
+ );
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for ExternalEditorComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ _force_all: bool,
+ ) -> CommandBlocking {
+ if self.visible {
+ out.clear();
+ }
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, _ev: Event) -> Result<bool> {
+ if self.visible {
+ return Ok(true);
+ }
+
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+
+ fn hide(&mut self) {
+ self.visible = false
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+
+ Ok(())
+ }
+}
diff --git a/src/components/filetree.rs b/src/components/filetree.rs
new file mode 100644
index 0000000..6cdfe45
--- /dev/null
+++ b/src/components/filetree.rs
@@ -0,0 +1,530 @@
+use super::{
+ utils::{
+ filetree::{FileTreeItem, FileTreeItemKind},
+ statustree::{MoveSelection, StatusTree},
+ },
+ CommandBlocking, DrawableComponent,
+};
+use crate::{
+ components::{CommandInfo, Component},
+ keys::SharedKeyConfig,
+ queue::{InternalEvent, NeedsUpdate, Queue},
+ strings::{self, order},
+ ui,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{hash, StatusItem, StatusItemType};
+use crossterm::event::Event;
+use std::{borrow::Cow, cell::Cell, convert::From, path::Path};
+use tui::{backend::Backend, layout::Rect, text::Span, Frame};
+
+///
+pub struct FileTreeComponent {
+ title: String,
+ tree: StatusTree,
+ pending: bool,
+ current_hash: u64,
+ focused: bool,
+ show_selection: bool,
+ queue: Option<Queue>,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ scroll_top: Cell<usize>,
+}
+
+impl FileTreeComponent {
+ ///
+ pub fn new(
+ title: &str,
+ focus: bool,
+ queue: Option<Queue>,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ title: title.to_string(),
+ tree: StatusTree::default(),
+ current_hash: 0,
+ focused: focus,
+ show_selection: focus,
+ queue,
+ theme,
+ key_config,
+ scroll_top: Cell::new(0),
+ pending: true,
+ }
+ }
+
+ ///
+ pub fn update(&mut self, list: &[StatusItem]) -> Result<()> {
+ self.pending = false;
+ let new_hash = hash(list);
+ if self.current_hash != new_hash {
+ self.tree.update(list)?;
+ self.current_hash = new_hash;
+ }
+
+ Ok(())
+ }
+
+ ///
+ pub fn selection(&self) -> Option<FileTreeItem> {
+ self.tree.selected_item()
+ }
+
+ ///
+ pub fn selection_file(&self) -> Option<StatusItem> {
+ self.tree.selected_item().and_then(|f| {
+ if let FileTreeItemKind::File(f) = f.kind {
+ Some(f)
+ } else {
+ None
+ }
+ })
+ }
+
+ ///
+ pub fn show_selection(&mut self, show: bool) {
+ self.show_selection = show;
+ }
+
+ /// returns true if list is empty
+ pub fn is_empty(&self) -> bool {
+ self.tree.is_empty()
+ }
+
+ ///
+ pub const fn file_count(&self) -> usize {
+ self.tree.tree.file_count()
+ }
+
+ ///
+ pub fn set_title(&mut self, title: String) {
+ self.title = title;
+ }
+
+ ///
+ pub fn clear(&mut self) -> Result<()> {
+ self.current_hash = 0;
+ self.pending = true;
+ self.tree.update(&[])
+ }
+
+ ///
+ pub fn is_file_seleted(&self) -> bool {
+ self.tree.selected_item().map_or(false, |item| {
+ match item.kind {
+ FileTreeItemKind::File(_) => true,
+ FileTreeItemKind::Path(..) => false,
+ }
+ })
+ }
+
+ fn move_selection(&mut self, dir: MoveSelection) -> bool {
+ let changed = self.tree.move_selection(dir);
+
+ if changed {
+ if let Some(ref queue) = self.queue {
+ queue.borrow_mut().push_back(InternalEvent::Update(
+ NeedsUpdate::DIFF,
+ ));
+ }
+ }
+
+ changed
+ }
+
+ const fn item_status_char(item_type: StatusItemType) -> char {
+ match item_type {
+ StatusItemType::Modified => 'M',
+ StatusItemType::New => '+',
+ StatusItemType::Deleted => '-',
+ StatusItemType::Renamed => 'R',
+ StatusItemType::Typechange => ' ',
+ }
+ }
+
+ fn item_to_text<'b>(
+ string: &str,
+ indent: usize,
+ visible: bool,
+ file_item_kind: &FileTreeItemKind,
+ width: u16,
+ selected: bool,
+ theme: &'b SharedTheme,
+ ) -> Option<Span<'b>> {
+ let indent_str = if indent == 0 {
+ String::from("")
+ } else {
+ format!("{:w$}", " ", w = (indent as usize) * 2)
+ };
+
+ if !visible {
+ return None;
+ }
+
+ match file_item_kind {
+ FileTreeItemKind::File(status_item) => {
+ let status_char =
+ Self::item_status_char(status_item.status);
+ let file = Path::new(&status_item.path)
+ .file_name()
+ .and_then(std::ffi::OsStr::to_str)
+ .expect("invalid path.");
+
+ let txt = if selected {
+ format!(
+ "{} {}{:w$}",
+ status_char,
+ indent_str,
+ file,
+ w = width as usize
+ )
+ } else {
+ format!("{} {}{}", status_char, indent_str, file)
+ };
+
+ Some(Span::styled(
+ Cow::from(txt),
+ theme.item(status_item.status, selected),
+ ))
+ }
+
+ FileTreeItemKind::Path(path_collapsed) => {
+ let collapse_char =
+ if path_collapsed.0 { '▸' } else { '▾' };
+
+ let txt = if selected {
+ format!(
+ " {}{}{:w$}",
+ indent_str,
+ collapse_char,
+ string,
+ w = width as usize
+ )
+ } else {
+ format!(
+ " {}{}{}",
+ indent_str, collapse_char, string,
+ )
+ };
+
+ Some(Span::styled(
+ Cow::from(txt),
+ theme.text(true, selected),
+ ))
+ }
+ }
+ }
+
+ /// Returns a Vec<TextDrawInfo> which is used to draw the `FileTreeComponent` correctly,
+ /// allowing folders to be folded up if they are alone in their directory
+ fn build_vec_text_draw_info_for_drawing(
+ &self,
+ ) -> (Vec<TextDrawInfo>, usize, usize) {
+ let mut should_skip_over: usize = 0;
+ let mut selection_offset: usize = 0;
+ let mut selection_offset_visible: usize = 0;
+ let mut vec_draw_text_info: Vec<TextDrawInfo> = vec![];
+ let tree_items = self.tree.tree.items();
+
+ for (index, item) in tree_items.iter().enumerate() {
+ if should_skip_over > 0 {
+ should_skip_over -= 1;
+ continue;
+ }
+
+ let index_above_select =
+ index < self.tree.selection.unwrap_or(0);
+
+ if !item.info.visible && index_above_select {
+ selection_offset_visible += 1;
+ }
+
+ vec_draw_text_info.push(TextDrawInfo {
+ name: item.info.path.clone(),
+ indent: item.info.indent,
+ visible: item.info.visible,
+ item_kind: &item.kind,
+ });
+
+ let mut idx_temp = index;
+
+ while idx_temp < tree_items.len().saturating_sub(2)
+ && tree_items[idx_temp].info.indent
+ < tree_items[idx_temp + 1].info.indent
+ {
+ // fold up the folder/file
+ idx_temp += 1;
+ should_skip_over += 1;
+
+ // don't fold files up
+ if let FileTreeItemKind::File(_) =
+ &tree_items[idx_temp].kind
+ {
+ should_skip_over -= 1;
+ break;
+ }
+ // don't fold up if more than one folder in folder
+ else if self
+ .tree
+ .tree
+ .multiple_items_at_path(idx_temp)
+ {
+ should_skip_over -= 1;
+ break;
+ } else {
+ // There is only one item at this level (i.e only one folder in the folder),
+ // so do fold up
+
+ let vec_draw_text_info_len =
+ vec_draw_text_info.len();
+ vec_draw_text_info[vec_draw_text_info_len - 1]
+ .name += &(String::from("/")
+ + &tree_items[idx_temp].info.path);
+ if index_above_select {
+ selection_offset += 1;
+ }
+ }
+ }
+ }
+ (
+ vec_draw_text_info,
+ selection_offset,
+ selection_offset_visible,
+ )
+ }
+}
+
+/// Used for drawing the `FileTreeComponent`
+struct TextDrawInfo<'a> {
+ name: String,
+ indent: u8,
+ visible: bool,
+ item_kind: &'a FileTreeItemKind,
+}
+
+impl DrawableComponent for FileTreeComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ r: Rect,
+ ) -> Result<()> {
+ if self.pending {
+ let items = vec![Span::styled(
+ Cow::from(strings::loading_text(&self.key_config)),
+ self.theme.text(false, false),
+ )];
+
+ ui::draw_list(
+ f,
+ r,
+ self.title.as_str(),
+ items.into_iter(),
+ None,
+ self.focused,
+ &self.theme,
+ );
+ } else {
+ let (
+ vec_draw_text_info,
+ selection_offset,
+ selection_offset_visible,
+ ) = self.build_vec_text_draw_info_for_drawing();
+
+ let select = self
+ .tree
+ .selection
+ .map(|idx| idx.saturating_sub(selection_offset))
+ .unwrap_or_default();
+ let tree_height = r.height.saturating_sub(2) as usize;
+
+ self.scroll_top.set(ui::calc_scroll_top(
+ self.scroll_top.get(),
+ tree_height,
+ select.saturating_sub(selection_offset_visible),
+ ));
+
+ let items = vec_draw_text_info
+ .iter()
+ .enumerate()
+ .filter_map(|(index, draw_text_info)| {
+ Self::item_to_text(
+ &draw_text_info.name,
+ draw_text_info.indent as usize,
+ draw_text_info.visible,
+ draw_text_info.item_kind,
+ r.width,
+ self.show_selection && select == index,
+ &self.theme,
+ )
+ })
+ .skip(self.scroll_top.get());
+ ui::draw_list(
+ f,
+ r,
+ self.title.as_str(),
+ items,
+ Some(select),
+ self.focused,
+ &self.theme,
+ );
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for FileTreeComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ out.push(
+ CommandInfo::new(
+ strings::commands::navigate_tree(&self.key_config),
+ !self.is_empty(),
+ self.focused || force_all,
+ )
+ .order(order::NAV),
+ );
+
+ CommandBlocking::PassingOn
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.focused {
+ if let Event::Key(e) = ev {
+ return if e == self.key_config.move_down {
+ Ok(self.move_selection(MoveSelection::Down))
+ } else if e == self.key_config.move_up {
+ Ok(self.move_selection(MoveSelection::Up))
+ } else if e == self.key_config.home
+ || e == self.key_config.shift_up
+ {
+ Ok(self.move_selection(MoveSelection::Home))
+ } else if e == self.key_config.end
+ || e == self.key_config.shift_down
+ {
+ Ok(self.move_selection(MoveSelection::End))
+ } else if e == self.key_config.move_left {
+ Ok(self.move_selection(MoveSelection::Left))
+ } else if e == self.key_config.move_right {
+ Ok(self.move_selection(MoveSelection::Right))
+ } else {
+ Ok(false)
+ };
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn focused(&self) -> bool {
+ self.focused
+ }
+ fn focus(&mut self, focus: bool) {
+ self.focused = focus;
+ self.show_selection(focus);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use asyncgit::StatusItemType;
+
+ fn string_vec_to_status(items: &[&str]) -> Vec<StatusItem> {
+ items
+ .iter()
+ .map(|a| StatusItem {
+ path: String::from(*a),
+ status: StatusItemType::Modified,
+ })
+ .collect::<Vec<_>>()
+ }
+
+ #[test]
+ fn test_correct_scroll_position() {
+ let items = string_vec_to_status(&[
+ "a/b/b1", //
+ "a/b/b2", //
+ "a/c/c1", //
+ ]);
+
+ //0 a/
+ //1 b/
+ //2 b1
+ //3 b2
+ //4 c/
+ //5 c1
+
+ // Set up test terminal
+ let test_backend = tui::backend::TestBackend::new(100, 100);
+ let mut terminal = tui::Terminal::new(test_backend)
+ .expect("Unable to set up terminal");
+ let mut frame = terminal.get_frame();
+
+ // set up file tree
+ let mut ftc = FileTreeComponent::new(
+ "title",
+ true,
+ None,
+ SharedTheme::default(),
+ SharedKeyConfig::default(),
+ );
+ ftc.update(&items)
+ .expect("Updating FileTreeComponent failed");
+
+ ftc.move_selection(MoveSelection::Down); // Move to b/
+ ftc.move_selection(MoveSelection::Left); // Fold b/
+ ftc.move_selection(MoveSelection::Down); // Move to c/
+
+ ftc.draw(&mut frame, Rect::new(0, 0, 10, 5))
+ .expect("Draw failed");
+
+ assert_eq!(ftc.scroll_top.get(), 0); // should still be at top
+ }
+
+ #[test]
+ fn test_correct_foldup_and_not_visible_scroll_position() {
+ let items = string_vec_to_status(&[
+ "a/b/b1", //
+ "c/d1", //
+ "c/d2", //
+ ]);
+
+ //0 a/b/
+ //2 b1
+ //3 c/
+ //4 d1
+ //5 d2
+
+ // Set up test terminal
+ let test_backend = tui::backend::TestBackend::new(100, 100);
+ let mut terminal = tui::Terminal::new(test_backend)
+ .expect("Unable to set up terminal");
+ let mut frame = terminal.get_frame();
+
+ // set up file tree
+ let mut ftc = FileTreeComponent::new(
+ "title",
+ true,
+ None,
+ SharedTheme::default(),
+ SharedKeyConfig::default(),
+ );
+ ftc.update(&items)
+ .expect("Updating FileTreeComponent failed");
+
+ ftc.move_selection(MoveSelection::Left); // Fold a/b/
+ ftc.move_selection(MoveSelection::Down); // Move to c/
+
+ ftc.draw(&mut frame, Rect::new(0, 0, 10, 5))
+ .expect("Draw failed");
+
+ assert_eq!(ftc.scroll_top.get(), 0); // should still be at top
+ }
+}
diff --git a/src/components/help.rs b/src/components/help.rs
new file mode 100644
index 0000000..bba6a8c
--- /dev/null
+++ b/src/components/help.rs
@@ -0,0 +1,247 @@
+use super::{
+ visibility_blocking, CommandBlocking, CommandInfo, Component,
+ DrawableComponent,
+};
+use crate::{keys::SharedKeyConfig, strings, ui, version::Version};
+use anyhow::Result;
+use asyncgit::hash;
+use crossterm::event::Event;
+use itertools::Itertools;
+use std::{borrow::Cow, cmp, convert::TryFrom};
+use tui::{
+ backend::Backend,
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Span, Spans},
+ widgets::{Block, BorderType, Borders, Clear, Paragraph},
+ Frame,
+};
+use ui::style::SharedTheme;
+
+///
+pub struct HelpComponent {
+ cmds: Vec<CommandInfo>,
+ visible: bool,
+ selection: u16,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for HelpComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ _rect: Rect,
+ ) -> Result<()> {
+ if self.visible {
+ const SIZE: (u16, u16) = (65, 24);
+ let scroll_threshold = SIZE.1 / 3;
+ let scroll =
+ self.selection.saturating_sub(scroll_threshold);
+
+ let area =
+ ui::centered_rect_absolute(SIZE.0, SIZE.1, f.size());
+
+ f.render_widget(Clear, area);
+ f.render_widget(
+ Block::default()
+ .title(strings::help_title(&self.key_config))
+ .borders(Borders::ALL)
+ .border_type(BorderType::Thick),
+ area,
+ );
+
+ let chunks = Layout::default()
+ .vertical_margin(1)
+ .horizontal_margin(1)
+ .direction(Direction::Vertical)
+ .constraints(
+ [Constraint::Min(1), Constraint::Length(1)]
+ .as_ref(),
+ )
+ .split(area);
+
+ f.render_widget(
+ Paragraph::new(self.get_text())
+ .scroll((scroll, 0))
+ .alignment(Alignment::Left),
+ chunks[0],
+ );
+
+ f.render_widget(
+ Paragraph::new(Spans::from(vec![Span::styled(
+ Cow::from(format!("gitui {}", Version::new(),)),
+ Style::default(),
+ )]))
+ .alignment(Alignment::Right),
+ chunks[1],
+ );
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for HelpComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ // only if help is open we have no other commands available
+ if self.visible && !force_all {
+ out.clear();
+ }
+
+ if self.visible {
+ out.push(CommandInfo::new(
+ strings::commands::scroll(&self.key_config),
+ true,
+ true,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::close_popup(&self.key_config),
+ true,
+ true,
+ ));
+ }
+
+ if !self.visible || force_all {
+ out.push(
+ CommandInfo::new(
+ strings::commands::help_open(&self.key_config),
+ true,
+ true,
+ )
+ .order(99),
+ );
+ }
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.visible {
+ if let Event::Key(e) = ev {
+ if e == self.key_config.exit_popup {
+ self.hide()
+ } else if e == self.key_config.move_down {
+ self.move_selection(true)
+ } else if e == self.key_config.move_up {
+ self.move_selection(false)
+ } else {
+ }
+ }
+
+ Ok(true)
+ } else if let Event::Key(k) = ev {
+ if k == self.key_config.open_help {
+ self.show()?;
+ Ok(true)
+ } else {
+ Ok(false)
+ }
+ } else {
+ Ok(false)
+ }
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+
+ fn hide(&mut self) {
+ self.visible = false
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+
+ Ok(())
+ }
+}
+
+impl HelpComponent {
+ pub const fn new(
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ cmds: vec![],
+ visible: false,
+ selection: 0,
+ theme,
+ key_config,
+ }
+ }
+ ///
+ pub fn set_cmds(&mut self, cmds: Vec<CommandInfo>) {
+ self.cmds = cmds
+ .into_iter()
+ .filter(|e| !e.text.hide_help)
+ .collect::<Vec<_>>();
+ self.cmds.sort_by_key(|e| e.text.clone());
+ self.cmds.dedup_by_key(|e| e.text.clone());
+ self.cmds.sort_by_key(|e| hash(&e.text.group));
+ }
+
+ fn move_selection(&mut self, inc: bool) {
+ let mut new_selection = self.selection;
+
+ new_selection = if inc {
+ new_selection.saturating_add(1)
+ } else {
+ new_selection.saturating_sub(1)
+ };
+ new_selection = cmp::max(new_selection, 0);
+
+ if let Ok(max) =
+ u16::try_from(self.cmds.len().saturating_sub(1))
+ {
+ self.selection = cmp::min(new_selection, max);
+ }
+ }
+
+ fn get_text(&self) -> Vec<Spans> {
+ let mut txt: Vec<Spans> = Vec::new();
+
+ let mut processed = 0_u16;
+
+ for (key, group) in
+ &self.cmds.iter().group_by(|e| e.text.group)
+ {
+ txt.push(Spans::from(Span::styled(
+ Cow::from(key.to_string()),
+ Style::default().add_modifier(Modifier::REVERSED),
+ )));
+
+ for command_info in group {
+ let is_selected = self.selection == processed;
+
+ processed += 1;
+
+ txt.push(Spans::from(Span::styled(
+ Cow::from(if is_selected {
+ format!(">{}", command_info.text.name)
+ } else {
+ format!(" {}", command_info.text.name)
+ }),
+ self.theme.text(true, is_selected),
+ )));
+
+ if is_selected {
+ txt.push(Spans::from(Span::styled(
+ Cow::from(format!(
+ " {}\n",
+ command_info.text.desc
+ )),
+ self.theme.text(true, is_selected),
+ )));
+ }
+ }
+ }
+
+ txt
+ }
+}
diff --git a/src/components/inspect_commit.rs b/src/components/inspect_commit.rs
new file mode 100644
index 0000000..2dff7bc
--- /dev/null
+++ b/src/components/inspect_commit.rs
@@ -0,0 +1,258 @@
+use super::{
+ command_pump, event_pump, visibility_blocking, CommandBlocking,
+ CommandInfo, CommitDetailsComponent, Component, DiffComponent,
+ DrawableComponent,
+};
+use crate::{
+ accessors, keys::SharedKeyConfig, queue::Queue, strings,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{
+ sync::{CommitId, CommitTags},
+ AsyncDiff, AsyncNotification, DiffParams, DiffType,
+};
+use crossbeam_channel::Sender;
+use crossterm::event::Event;
+use tui::{
+ backend::Backend,
+ layout::{Constraint, Direction, Layout, Rect},
+ widgets::Clear,
+ Frame,
+};
+
+pub struct InspectCommitComponent {
+ commit_id: Option<CommitId>,
+ tags: Option<CommitTags>,
+ diff: DiffComponent,
+ details: CommitDetailsComponent,
+ git_diff: AsyncDiff,
+ visible: bool,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for InspectCommitComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ if self.is_visible() {
+ let percentages = if self.diff.focused() {
+ (30, 70)
+ } else {
+ (50, 50)
+ };
+
+ let chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints(
+ [
+ Constraint::Percentage(percentages.0),
+ Constraint::Percentage(percentages.1),
+ ]
+ .as_ref(),
+ )
+ .split(rect);
+
+ f.render_widget(Clear, rect);
+
+ self.details.draw(f, chunks[0])?;
+ self.diff.draw(f, chunks[1])?;
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for InspectCommitComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ if self.is_visible() || force_all {
+ command_pump(
+ out,
+ force_all,
+ self.components().as_slice(),
+ );
+
+ out.push(
+ CommandInfo::new(
+ strings::commands::close_popup(&self.key_config),
+ true,
+ true,
+ )
+ .order(1),
+ );
+
+ out.push(CommandInfo::new(
+ strings::commands::diff_focus_right(&self.key_config),
+ self.can_focus_diff(),
+ !self.diff.focused() || force_all,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::diff_focus_left(&self.key_config),
+ true,
+ self.diff.focused() || force_all,
+ ));
+ }
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.is_visible() {
+ if event_pump(ev, self.components_mut().as_mut_slice())? {
+ return Ok(true);
+ }
+
+ if let Event::Key(e) = ev {
+ if e == self.key_config.exit_popup {
+ self.hide();
+ } else if e == self.key_config.focus_right
+ && self.can_focus_diff()
+ {
+ self.details.focus(false);
+ self.diff.focus(true);
+ } else if e == self.key_config.focus_left
+ && self.diff.focused()
+ {
+ self.details.focus(true);
+ self.diff.focus(false);
+ }
+
+ // stop key event propagation
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+ fn hide(&mut self) {
+ self.visible = false;
+ }
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+ self.details.show()?;
+ self.details.focus(true);
+ self.diff.focus(false);
+ self.update()?;
+ Ok(())
+ }
+}
+
+impl InspectCommitComponent {
+ accessors!(self, [diff, details]);
+
+ ///
+ pub fn new(
+ queue: &Queue,
+ sender: &Sender<AsyncNotification>,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ details: CommitDetailsComponent::new(
+ queue,
+ sender,
+ theme.clone(),
+ key_config.clone(),
+ ),
+ diff: DiffComponent::new(
+ queue.clone(),
+ theme,
+ key_config.clone(),
+ true,
+ ),
+ commit_id: None,
+ tags: None,
+ git_diff: AsyncDiff::new(sender),
+ visible: false,
+ key_config,
+ }
+ }
+
+ ///
+ pub fn open(
+ &mut self,
+ id: CommitId,
+ tags: Option<CommitTags>,
+ ) -> Result<()> {
+ self.commit_id = Some(id);
+ self.tags = tags;
+ self.show()?;
+
+ Ok(())
+ }
+
+ ///
+ pub fn any_work_pending(&self) -> bool {
+ self.git_diff.is_pending() || self.details.any_work_pending()
+ }
+
+ ///
+ pub fn update_git(
+ &mut self,
+ ev: AsyncNotification,
+ ) -> Result<()> {
+ if self.is_visible() {
+ if let AsyncNotification::CommitFiles = ev {
+ self.update()?
+ } else if let AsyncNotification::Diff = ev {
+ self.update_diff()?
+ }
+ }
+
+ Ok(())
+ }
+
+ /// called when any tree component changed selection
+ pub fn update_diff(&mut self) -> Result<()> {
+ if self.is_visible() {
+ if let Some(id) = self.commit_id {
+ if let Some(f) = self.details.files().selection_file()
+ {
+ let diff_params = DiffParams {
+ path: f.path.clone(),
+ diff_type: DiffType::Commit(id),
+ };
+
+ if let Some((params, last)) =
+ self.git_diff.last()?
+ {
+ if params == diff_params {
+ self.diff.update(f.path, false, last)?;
+ return Ok(());
+ }
+ }
+
+ self.git_diff.request(diff_params)?;
+ self.diff.clear(true)?;
+ return Ok(());
+ }
+ }
+
+ self.diff.clear(false)?;
+ }
+
+ Ok(())
+ }
+
+ fn update(&mut self) -> Result<()> {
+ self.details.set_commit(self.commit_id, self.tags.clone())?;
+ self.update_diff()?;
+
+ Ok(())
+ }
+
+ fn can_focus_diff(&self) -> bool {
+ self.details.files().selection_file().is_some()
+ }
+}
diff --git a/src/components/mod.rs b/src/components/mod.rs
new file mode 100644
index 0000000..38efec7
--- /dev/null
+++ b/src/components/mod.rs
@@ -0,0 +1,226 @@
+mod changes;
+mod command;
+mod commit;
+mod commit_details;
+mod commitlist;
+mod create_branch;
+mod cred;
+mod diff;
+mod externaleditor;
+mod filetree;
+mod help;
+mod inspect_commit;
+mod msg;
+mod push;
+mod rename_branch;
+mod reset;
+mod select_branch;
+mod stashmsg;
+mod tag_commit;
+mod textinput;
+mod utils;
+
+pub use changes::ChangesComponent;
+pub use command::{CommandInfo, CommandText};
+pub use commit::CommitComponent;
+pub use commit_details::CommitDetailsComponent;
+pub use commitlist::CommitList;
+pub use create_branch::CreateBranchComponent;
+pub use diff::DiffComponent;
+pub use externaleditor::ExternalEditorComponent;
+pub use filetree::FileTreeComponent;
+pub use help::HelpComponent;
+pub use inspect_commit::InspectCommitComponent;
+pub use msg::MsgComponent;
+pub use push::PushComponent;
+pub use rename_branch::RenameBranchComponent;
+pub use reset::ResetComponent;
+pub use select_branch::SelectBranchComponent;
+pub use stashmsg::StashMsgComponent;
+pub use tag_commit::TagCommitComponent;
+pub use textinput::{InputType, TextInputComponent};
+pub use utils::filetree::FileTreeItemKind;
+
+use crate::ui::style::Theme;
+use anyhow::Result;
+use crossterm::event::Event;
+use tui::{
+ backend::Backend,
+ layout::{Alignment, Rect},
+ text::{Span, Text},
+ widgets::{Block, BorderType, Borders, Paragraph, Wrap},
+ Frame,
+};
+
+/// creates accessors for a list of components
+///
+/// allows generating code to make sure
+/// we always enumerate all components in both getter functions
+#[macro_export]
+macro_rules! accessors {
+ ($self:ident, [$($element:ident),+]) => {
+ fn components(& $self) -> Vec<&dyn Component> {
+ vec![
+ $(&$self.$element,)+
+ ]
+ }
+
+ fn components_mut(&mut $self) -> Vec<&mut dyn Component> {
+ vec![
+ $(&mut $self.$element,)+
+ ]
+ }
+ };
+}
+
+/// returns `true` if event was consumed
+pub fn event_pump(
+ ev: Event,
+ components: &mut [&mut dyn Component],
+) -> Result<bool> {
+ for c in components {
+ if c.event(ev)? {
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+}
+
+/// helper fn to simplify delegating command
+/// gathering down into child components
+/// see `event_pump`,`accessors`
+pub fn command_pump(
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ components: &[&dyn Component],
+) {
+ for c in components {
+ if c.commands(out, force_all) != CommandBlocking::PassingOn
+ && !force_all
+ {
+ break;
+ }
+ }
+}
+
+#[derive(Copy, Clone)]
+pub enum ScrollType {
+ Up,
+ Down,
+ Home,
+ End,
+ PageUp,
+ PageDown,
+}
+
+#[derive(Copy, Clone)]
+pub enum Direction {
+ Up,
+ Down,
+}
+
+///
+#[derive(PartialEq)]
+pub enum CommandBlocking {
+ Blocking,
+ PassingOn,
+}
+
+///
+pub fn visibility_blocking<T: Component>(
+ comp: &T,
+) -> CommandBlocking {
+ if comp.is_visible() {
+ CommandBlocking::Blocking
+ } else {
+ CommandBlocking::PassingOn
+ }
+}
+
+///
+pub trait DrawableComponent {
+ ///
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()>;
+}
+
+/// base component trait
+pub trait Component {
+ ///
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking;
+
+ /// returns true if event propagation needs to end (event was consumed)
+ fn event(&mut self, ev: Event) -> Result<bool>;
+
+ ///
+ fn focused(&self) -> bool {
+ false
+ }
+ /// focus/unfocus this component depending on param
+ fn focus(&mut self, _focus: bool) {}
+ ///
+ fn is_visible(&self) -> bool {
+ true
+ }
+ ///
+ fn hide(&mut self) {}
+ ///
+ fn show(&mut self) -> Result<()> {
+ Ok(())
+ }
+
+ ///
+ fn toggle_visible(&mut self) -> Result<()> {
+ if self.is_visible() {
+ self.hide();
+ Ok(())
+ } else {
+ self.show()
+ }
+ }
+}
+
+fn dialog_paragraph<'a>(
+ title: &'a str,
+ content: Text<'a>,
+ theme: &Theme,
+ focused: bool,
+) -> Paragraph<'a> {
+ Paragraph::new(content)
+ .block(
+ Block::default()
+ .title(Span::styled(title, theme.title(focused)))
+ .borders(Borders::ALL)
+ .border_style(theme.block(focused)),
+ )
+ .alignment(Alignment::Left)
+}
+
+fn popup_paragraph<'a, T>(
+ title: &'a str,
+ content: T,
+ theme: &Theme,
+ focused: bool,
+) -> Paragraph<'a>
+where
+ T: Into<Text<'a>>,
+{
+ Paragraph::new(content.into())
+ .block(
+ Block::default()
+ .title(Span::styled(title, theme.title(focused)))
+ .borders(Borders::ALL)
+ .border_type(BorderType::Thick)
+ .border_style(theme.block(focused)),
+ )
+ .alignment(Alignment::Left)
+ .wrap(Wrap { trim: true })
+}
diff --git a/src/components/msg.rs b/src/components/msg.rs
new file mode 100644
index 0000000..3a50327
--- /dev/null
+++ b/src/components/msg.rs
@@ -0,0 +1,142 @@
+use super::{
+ visibility_blocking, CommandBlocking, CommandInfo, Component,
+ DrawableComponent,
+};
+use crate::{keys::SharedKeyConfig, strings, ui};
+use crossterm::event::Event;
+use std::convert::TryFrom;
+use tui::{
+ backend::Backend,
+ layout::{Alignment, Rect},
+ text::Span,
+ widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
+ Frame,
+};
+use ui::style::SharedTheme;
+pub struct MsgComponent {
+ title: String,
+ msg: String,
+ visible: bool,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+}
+
+use anyhow::Result;
+
+impl DrawableComponent for MsgComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ _rect: Rect,
+ ) -> Result<()> {
+ if !self.visible {
+ return Ok(());
+ }
+
+ // determine the maximum width of text block
+ let lens = self
+ .msg
+ .split('\n')
+ .map(str::len)
+ .collect::<Vec<usize>>();
+ let mut max = lens.iter().max().expect("max") + 2;
+ if max > std::u16::MAX as usize {
+ max = std::u16::MAX as usize;
+ }
+ let mut width =
+ u16::try_from(max).expect("cant fail due to check above");
+ // dont overflow screen, and dont get too narrow
+ if width > f.size().width {
+ width = f.size().width
+ } else if width < 60 {
+ width = 60
+ }
+
+ let area = ui::centered_rect_absolute(width, 25, f.size());
+ f.render_widget(Clear, area);
+ f.render_widget(
+ Paragraph::new(self.msg.clone())
+ .block(
+ Block::default()
+ .title(Span::styled(
+ self.title.as_str(),
+ self.theme.text_danger(),
+ ))
+ .borders(Borders::ALL)
+ .border_type(BorderType::Thick),
+ )
+ .alignment(Alignment::Left)
+ .wrap(Wrap { trim: true }),
+ area,
+ );
+
+ Ok(())
+ }
+}
+
+impl Component for MsgComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ _force_all: bool,
+ ) -> CommandBlocking {
+ out.push(CommandInfo::new(
+ strings::commands::close_msg(&self.key_config),
+ true,
+ self.visible,
+ ));
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.visible {
+ if let Event::Key(e) = ev {
+ if e == self.key_config.enter {
+ self.hide();
+ }
+ }
+ Ok(true)
+ } else {
+ Ok(false)
+ }
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+
+ fn hide(&mut self) {
+ self.visible = false
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+
+ Ok(())
+ }
+}
+
+impl MsgComponent {
+ pub const fn new(
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ title: String::new(),
+ msg: String::new(),
+ visible: false,
+ theme,
+ key_config,
+ }
+ }
+
+ ///
+ pub fn show_error(&mut self, msg: &str) -> Result<()> {
+ self.title = strings::msg_title_error(&self.key_config);
+ self.msg = msg.to_string();
+ self.show()?;
+
+ Ok(())
+ }
+}
diff --git a/src/components/push.rs b/src/components/push.rs
new file mode 100644
index 0000000..21f183f
--- /dev/null
+++ b/src/components/push.rs
@@ -0,0 +1,262 @@
+use crate::{
+ components::{
+ cred::CredComponent, visibility_blocking, CommandBlocking,
+ CommandInfo, Component, DrawableComponent,
+ },
+ keys::SharedKeyConfig,
+ queue::{InternalEvent, Queue},
+ strings,
+ ui::{self, style::SharedTheme},
+};
+use anyhow::Result;
+use asyncgit::{
+ sync::cred::{
+ extract_username_password, need_username_password,
+ BasicAuthCredential,
+ },
+ sync::DEFAULT_REMOTE_NAME,
+ AsyncNotification, AsyncPush, PushProgress, PushProgressState,
+ PushRequest,
+};
+use crossbeam_channel::Sender;
+use crossterm::event::Event;
+use tui::{
+ backend::Backend,
+ layout::Rect,
+ text::Span,
+ widgets::{Block, BorderType, Borders, Clear, Gauge},
+ Frame,
+};
+
+///
+pub struct PushComponent {
+ visible: bool,
+ git_push: AsyncPush,
+ progress: Option<PushProgress>,
+ pending: bool,
+ branch: String,
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ input_cred: CredComponent,
+}
+
+impl PushComponent {
+ ///
+ pub fn new(
+ queue: &Queue,
+ sender: &Sender<AsyncNotification>,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ queue: queue.clone(),
+ pending: false,
+ visible: false,
+ branch: String::new(),
+ git_push: AsyncPush::new(sender),
+ progress: None,
+ input_cred: CredComponent::new(
+ theme.clone(),
+ key_config.clone(),
+ ),
+ theme,
+ key_config,
+ }
+ }
+
+ ///
+ pub fn push(&mut self, branch: String) -> Result<()> {
+ self.branch = branch;
+ self.show()?;
+ if need_username_password(DEFAULT_REMOTE_NAME)? {
+ let cred = extract_username_password(DEFAULT_REMOTE_NAME)
+ .unwrap_or_else(|_| {
+ BasicAuthCredential::new(None, None)
+ });
+ if cred.is_complete() {
+ self.push_to_remote(Some(cred))
+ } else {
+ self.input_cred.set_cred(cred);
+ self.input_cred.show()
+ }
+ } else {
+ self.push_to_remote(None)
+ }
+ }
+
+ fn push_to_remote(
+ &mut self,
+ cred: Option<BasicAuthCredential>,
+ ) -> Result<()> {
+ self.pending = true;
+ self.progress = None;
+ self.git_push.request(PushRequest {
+ //TODO: find tracking branch name
+ remote: String::from(DEFAULT_REMOTE_NAME),
+ branch: self.branch.clone(),
+ basic_credential: cred,
+ })?;
+ Ok(())
+ }
+
+ ///
+ pub fn update_git(
+ &mut self,
+ ev: AsyncNotification,
+ ) -> Result<()> {
+ if self.is_visible() {
+ if let AsyncNotification::Push = ev {
+ self.update()?;
+ }
+ }
+
+ Ok(())
+ }
+
+ ///
+ fn update(&mut self) -> Result<()> {
+ self.pending = self.git_push.is_pending()?;
+ self.progress = self.git_push.progress()?;
+
+ if !self.pending {
+ if let Some(err) = self.git_push.last_result()? {
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "push failed:\n{}",
+ err
+ )),
+ );
+ }
+ self.hide();
+ }
+
+ Ok(())
+ }
+
+ fn get_progress(&self) -> (String, u8) {
+ self.progress.as_ref().map_or(
+ (strings::PUSH_POPUP_PROGRESS_NONE.into(), 0),
+ |progress| {
+ (
+ Self::progress_state_name(&progress.state),
+ progress.progress,
+ )
+ },
+ )
+ }
+
+ fn progress_state_name(state: &PushProgressState) -> String {
+ match state {
+ PushProgressState::PackingAddingObject => {
+ strings::PUSH_POPUP_STATES_ADDING
+ }
+ PushProgressState::PackingDeltafiction => {
+ strings::PUSH_POPUP_STATES_DELTAS
+ }
+ PushProgressState::Pushing => {
+ strings::PUSH_POPUP_STATES_PUSHING
+ }
+ }
+ .into()
+ }
+}
+
+impl DrawableComponent for PushComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ if self.visible {
+ let (state, progress) = self.get_progress();
+
+ let area = ui::centered_rect_absolute(30, 3, f.size());
+
+ f.render_widget(Clear, area);
+ f.render_widget(
+ Gauge::default()
+ .label(state.as_str())
+ .block(
+ Block::default()
+ .title(Span::styled(
+ strings::PUSH_POPUP_MSG,
+ self.theme.title(true),
+ ))
+ .borders(Borders::ALL)
+ .border_type(BorderType::Thick)
+ .border_style(self.theme.block(true)),
+ )
+ .gauge_style(self.theme.push_gauge())
+ .percent(u16::from(progress)),
+ area,
+ );
+ self.input_cred.draw(f, rect)?;
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for PushComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ if self.is_visible() {
+ out.clear();
+ }
+
+ if self.input_cred.is_visible() {
+ self.input_cred.commands(out, force_all)
+ } else {
+ out.push(CommandInfo::new(
+ strings::commands::close_msg(&self.key_config),
+ !self.pending,
+ self.visible,
+ ));
+ visibility_blocking(self)
+ }
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.visible {
+ if let Event::Key(e) = ev {
+ if e == self.key_config.exit_popup {
+ self.hide();
+ }
+ if self.input_cred.event(ev)? {
+ return Ok(true);
+ } else if e == self.key_config.enter {
+ if self.input_cred.is_visible()
+ && self.input_cred.get_cred().is_complete()
+ {
+ self.push_to_remote(Some(
+ self.input_cred.get_cred().clone(),
+ ))?;
+ self.input_cred.hide();
+ } else {
+ self.hide();
+ }
+ }
+ }
+ return Ok(true);
+ }
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+
+ fn hide(&mut self) {
+ self.visible = false
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+
+ Ok(())
+ }
+}
diff --git a/src/components/rename_branch.rs b/src/components/rename_branch.rs
new file mode 100644
index 0000000..81a9cda
--- /dev/null
+++ b/src/components/rename_branch.rs
@@ -0,0 +1,166 @@
+use super::{
+ textinput::TextInputComponent, visibility_blocking,
+ CommandBlocking, CommandInfo, Component, DrawableComponent,
+};
+use crate::{
+ keys::SharedKeyConfig,
+ queue::{InternalEvent, NeedsUpdate, Queue},
+ strings,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{
+ sync::{self},
+ CWD,
+};
+use crossterm::event::Event;
+use tui::{backend::Backend, layout::Rect, Frame};
+
+pub struct RenameBranchComponent {
+ input: TextInputComponent,
+ branch_ref: Option<String>,
+ queue: Queue,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for RenameBranchComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ self.input.draw(f, rect)?;
+
+ Ok(())
+ }
+}
+
+impl Component for RenameBranchComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ if self.is_visible() || force_all {
+ self.input.commands(out, force_all);
+
+ out.push(CommandInfo::new(
+ strings::commands::rename_branch_confirm_msg(
+ &self.key_config,
+ ),
+ true,
+ true,
+ ));
+ }
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.is_visible() {
+ if self.input.event(ev)? {
+ return Ok(true);
+ }
+
+ if let Event::Key(e) = ev {
+ if e == self.key_config.enter {
+ self.rename_branch();
+ }
+
+ return Ok(true);
+ }
+ }
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.input.is_visible()
+ }
+
+ fn hide(&mut self) {
+ self.input.hide()
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.input.show()?;
+
+ Ok(())
+ }
+}
+
+impl RenameBranchComponent {
+ ///
+ pub fn new(
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ queue,
+ input: TextInputComponent::new(
+ theme,
+ key_config.clone(),
+ &strings::rename_branch_popup_title(&key_config),
+ &strings::rename_branch_popup_msg(&key_config),
+ ),
+ branch_ref: None,
+ key_config,
+ }
+ }
+
+ ///
+ pub fn open(
+ &mut self,
+ branch_ref: String,
+ cur_name: String,
+ ) -> Result<()> {
+ self.branch_ref = None;
+ self.branch_ref = Some(branch_ref);
+ self.input.set_text(cur_name);
+ self.show()?;
+
+ Ok(())
+ }
+
+ ///
+ pub fn rename_branch(&mut self) {
+ if let Some(br) = &self.branch_ref {
+ let res = sync::rename_branch(
+ CWD,
+ br,
+ self.input.get_text().as_str(),
+ );
+
+ match res {
+ Ok(_) => {
+ self.queue.borrow_mut().push_back(
+ InternalEvent::Update(NeedsUpdate::ALL),
+ );
+ self.hide();
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::SelectBranch);
+ }
+ Err(e) => {
+ log::error!("create branch: {}", e,);
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "rename branch error:\n{}",
+ e,
+ )),
+ );
+ }
+ }
+ } else {
+ log::error!("create branch: No branch selected");
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::ShowErrorMsg(
+ "rename branch error: No branch selected to rename"
+ .to_string(),
+ ));
+ }
+
+ self.input.clear();
+ }
+}
diff --git a/src/components/reset.rs b/src/components/reset.rs
new file mode 100644
index 0000000..be8e76d
--- /dev/null
+++ b/src/components/reset.rs
@@ -0,0 +1,168 @@
+use crate::{
+ components::{
+ popup_paragraph, visibility_blocking, CommandBlocking,
+ CommandInfo, Component, DrawableComponent,
+ },
+ keys::SharedKeyConfig,
+ queue::{Action, InternalEvent, Queue},
+ strings, ui,
+};
+use anyhow::Result;
+use crossterm::event::Event;
+use std::borrow::Cow;
+use tui::{
+ backend::Backend, layout::Rect, text::Text, widgets::Clear, Frame,
+};
+use ui::style::SharedTheme;
+
+///
+pub struct ResetComponent {
+ target: Option<Action>,
+ visible: bool,
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for ResetComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ _rect: Rect,
+ ) -> Result<()> {
+ if self.visible {
+ let (title, msg) = self.get_text();
+
+ let txt = Text::styled(
+ Cow::from(msg),
+ self.theme.text_danger(),
+ );
+
+ let area = ui::centered_rect(30, 20, f.size());
+ f.render_widget(Clear, area);
+ f.render_widget(
+ popup_paragraph(&title, txt, &self.theme, true),
+ area,
+ );
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for ResetComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ _force_all: bool,
+ ) -> CommandBlocking {
+ out.push(CommandInfo::new(
+ strings::commands::reset_confirm(&self.key_config),
+ true,
+ self.visible,
+ ));
+ out.push(CommandInfo::new(
+ strings::commands::close_popup(&self.key_config),
+ true,
+ self.visible,
+ ));
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.visible {
+ if let Event::Key(e) = ev {
+ if e == self.key_config.exit_popup {
+ self.hide();
+ } else if e == self.key_config.enter {
+ self.confirm();
+ }
+
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+
+ fn hide(&mut self) {
+ self.visible = false
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+
+ Ok(())
+ }
+}
+
+impl ResetComponent {
+ ///
+ pub fn new(
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ target: None,
+ visible: false,
+ queue,
+ theme,
+ key_config,
+ }
+ }
+ ///
+ pub fn open(&mut self, a: Action) -> Result<()> {
+ self.target = Some(a);
+ self.show()?;
+
+ Ok(())
+ }
+ ///
+ pub fn confirm(&mut self) {
+ if let Some(a) = self.target.take() {
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::ConfirmedAction(a));
+ }
+
+ self.hide();
+ }
+
+ fn get_text(&self) -> (String, String) {
+ if let Some(ref a) = self.target {
+ return match a {
+ Action::Reset(_) => (
+ strings::confirm_title_reset(&self.key_config),
+ strings::confirm_msg_reset(&self.key_config),
+ ),
+ Action::StashDrop(_) => (
+ strings::confirm_title_stashdrop(
+ &self.key_config,
+ ),
+ strings::confirm_msg_stashdrop(&self.key_config),
+ ),
+ Action::ResetHunk(_, _) => (
+ strings::confirm_title_reset(&self.key_config),
+ strings::confirm_msg_resethunk(&self.key_config),
+ ),
+ Action::DeleteBranch(branch_ref) => (
+ strings::confirm_title_delete_branch(
+ &self.key_config,
+ ),
+ strings::confirm_msg_delete_branch(
+ &self.key_config,
+ branch_ref,
+ ),
+ ),
+ };
+ }
+
+ (String::new(), String::new())
+ }
+}
diff --git a/src/components/select_branch.rs b/src/components/select_branch.rs
new file mode 100644
index 0000000..f65796f
--- /dev/null
+++ b/src/components/select_branch.rs
@@ -0,0 +1,394 @@
+use super::{
+ visibility_blocking, CommandBlocking, CommandInfo, Component,
+ DrawableComponent,
+};
+use crate::{
+ components::ScrollType,
+ keys::SharedKeyConfig,
+ queue::{Action, InternalEvent, NeedsUpdate, Queue},
+ strings,
+ ui::{self, calc_scroll_top},
+};
+use asyncgit::{
+ sync::{
+ checkout_branch, get_branches_to_display, BranchForDisplay,
+ },
+ CWD,
+};
+use crossterm::event::Event;
+use std::{cell::Cell, convert::TryInto};
+use tui::{
+ backend::Backend,
+ layout::{Alignment, Rect},
+ text::{Span, Spans, Text},
+ widgets::{Block, BorderType, Borders, Clear, Paragraph},
+ Frame,
+};
+
+use crate::ui::Size;
+use anyhow::Result;
+use ui::style::SharedTheme;
+
+///
+pub struct SelectBranchComponent {
+ branch_names: Vec<BranchForDisplay>,
+ visible: bool,
+ selection: u16,
+ scroll_top: Cell<usize>,
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for SelectBranchComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ if self.visible {
+ const PERCENT_SIZE: Size = Size::new(60, 25);
+ const MIN_SIZE: Size = Size::new(50, 20);
+
+ let area = ui::centered_rect(
+ PERCENT_SIZE.width,
+ PERCENT_SIZE.height,
+ f.size(),
+ );
+ let area =
+ ui::rect_inside(MIN_SIZE, f.size().into(), area);
+ let area = area.intersection(rect);
+
+ let height_in_lines =
+ (area.height as usize).saturating_sub(2);
+
+ self.scroll_top.set(calc_scroll_top(
+ self.scroll_top.get(),
+ height_in_lines,
+ self.selection as usize,
+ ));
+
+ f.render_widget(Clear, area);
+ f.render_widget(
+ Paragraph::new(self.get_text(
+ &self.theme,
+ area.width,
+ height_in_lines,
+ ))
+ .block(
+ Block::default()
+ .title(strings::SELECT_BRANCH_POPUP_MSG)
+ .border_type(BorderType::Thick)
+ .borders(Borders::ALL),
+ )
+ .alignment(Alignment::Left),
+ area,
+ );
+
+ ui::draw_scrollbar(
+ f,
+ area,
+ &self.theme,
+ self.branch_names.len(),
+ self.scroll_top.get(),
+ );
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for SelectBranchComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ if self.visible || force_all {
+ out.clear();
+
+ out.push(CommandInfo::new(
+ strings::commands::scroll(&self.key_config),
+ true,
+ true,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::close_popup(&self.key_config),
+ true,
+ true,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::open_branch_create_popup(
+ &self.key_config,
+ ),
+ true,
+ true,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::delete_branch_popup(
+ &self.key_config,
+ ),
+ !self.selection_is_cur_branch(),
+ true,
+ ));
+
+ out.push(CommandInfo::new(
+ strings::commands::rename_branch_popup(
+ &self.key_config,
+ ),
+ true,
+ true,
+ ));
+ }
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.visible {
+ if let Event::Key(e) = ev {
+ if e == self.key_config.exit_popup {
+ self.hide()
+ } else if e == self.key_config.move_down {
+ return self.move_selection(ScrollType::Up);
+ } else if e == self.key_config.move_up {
+ return self.move_selection(ScrollType::Down);
+ } else if e == self.key_config.enter {
+ if let Err(e) = self.switch_to_selected_branch() {
+ log::error!("switch branch error: {}", e);
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "switch branch error:\n{}",
+ e
+ )),
+ );
+ }
+ self.hide()
+ } else if e == self.key_config.create_branch {
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::CreateBranch);
+ self.hide();
+ } else if e == self.key_config.rename_branch {
+ let cur_branch =
+ &self.branch_names[self.selection as usize];
+ self.queue.borrow_mut().push_back(
+ InternalEvent::RenameBranch(
+ cur_branch.reference.clone(),
+ cur_branch.name.clone(),
+ ),
+ );
+ self.hide();
+ } else if e == self.key_config.delete_branch
+ && !self.selection_is_cur_branch()
+ {
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ConfirmAction(
+ Action::DeleteBranch(
+ self.branch_names
+ [self.selection as usize]
+ .reference
+ .clone(),
+ ),
+ ),
+ );
+ }
+ }
+
+ Ok(true)
+ } else {
+ Ok(false)
+ }
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+
+ fn hide(&mut self) {
+ self.visible = false
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+
+ Ok(())
+ }
+}
+
+impl SelectBranchComponent {
+ pub fn new(
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ branch_names: Vec::new(),
+ visible: false,
+ selection: 0,
+ scroll_top: Cell::new(0),
+ queue,
+ theme,
+ key_config,
+ }
+ }
+ /// Get all the names of the branches in the repo
+ pub fn get_branch_names() -> Result<Vec<BranchForDisplay>> {
+ Ok(get_branches_to_display(CWD)?)
+ }
+
+ ///
+ pub fn open(&mut self) -> Result<()> {
+ self.update_branches()?;
+ self.show()?;
+
+ Ok(())
+ }
+
+ ////
+ pub fn update_branches(&mut self) -> Result<()> {
+ self.branch_names = Self::get_branch_names()?;
+ Ok(())
+ }
+
+ ///
+ pub fn selection_is_cur_branch(&self) -> bool {
+ self.branch_names
+ .iter()
+ .enumerate()
+ .filter(|(index, b)| {
+ b.is_head && *index == self.selection as usize
+ })
+ .count()
+ > 0
+ }
+
+ ///
+ fn move_selection(&mut self, scroll: ScrollType) -> Result<bool> {
+ let num_branches: u16 = self.branch_names.len().try_into()?;
+ let num_branches = num_branches.saturating_sub(1);
+
+ let mut new_selection = match scroll {
+ ScrollType::Up => self.selection.saturating_add(1),
+ ScrollType::Down => self.selection.saturating_sub(1),
+ _ => self.selection,
+ };
+
+ if new_selection > num_branches {
+ new_selection = num_branches;
+ }
+
+ self.selection = new_selection;
+
+ Ok(true)
+ }
+
+ /// Get branches to display
+ fn get_text(
+ &self,
+ theme: &SharedTheme,
+ width_available: u16,
+ height: usize,
+ ) -> Text {
+ const COMMIT_HASH_LENGTH: usize = 8;
+ const IS_HEAD_STAR_LENGTH: usize = 3; // "* "
+ const THREE_DOTS_LENGTH: usize = 3; // "..."
+
+ // branch name = 30% of area size
+ let branch_name_length: usize =
+ width_available as usize * 30 / 100;
+ // commit message takes up the remaining width
+ let commit_message_length: usize = (width_available as usize)
+ .saturating_sub(COMMIT_HASH_LENGTH)
+ .saturating_sub(branch_name_length)
+ .saturating_sub(IS_HEAD_STAR_LENGTH)
+ .saturating_sub(THREE_DOTS_LENGTH);
+ let mut txt = Vec::new();
+
+ for (i, displaybranch) in self
+ .branch_names
+ .iter()
+ .skip(self.scroll_top.get())
+ .take(height)
+ .enumerate()
+ {
+ let mut commit_message =
+ displaybranch.top_commit_message.clone();
+ if commit_message.len() > commit_message_length {
+ commit_message.truncate(
+ commit_message_length
+ .saturating_sub(THREE_DOTS_LENGTH),
+ );
+ commit_message += "...";
+ }
+
+ let mut branch_name = displaybranch.name.clone();
+ if branch_name.len() > branch_name_length {
+ branch_name.truncate(
+ branch_name_length
+ .saturating_sub(THREE_DOTS_LENGTH),
+ );
+ branch_name += "...";
+ }
+
+ let selected =
+ self.selection as usize - self.scroll_top.get() == i;
+
+ let is_head_str =
+ if displaybranch.is_head { "*" } else { " " };
+ let has_upstream_str = if displaybranch.has_upstream {
+ "\u{2191}"
+ } else {
+ " "
+ };
+
+ let span_prefix = Span::styled(
+ format!("{}{} ", is_head_str, has_upstream_str),
+ theme.commit_author(selected),
+ );
+ let span_hash = Span::styled(
+ format!(
+ "{} ",
+ displaybranch.top_commit.get_short_string()
+ ),
+ theme.commit_hash(selected),
+ );
+ let span_msg = Span::styled(
+ commit_message.to_string(),
+ theme.text(true, selected),
+ );
+ let span_name = Span::styled(
+ format!(
+ "{:w$} ",
+ branch_name,
+ w = branch_name_length
+ ),
+ theme.branch(selected, displaybranch.is_head),
+ );
+
+ txt.push(Spans::from(vec![
+ span_prefix,
+ span_name,
+ span_hash,
+ span_msg,
+ ]));
+ }
+
+ Text::from(txt)
+ }
+
+ ///
+ fn switch_to_selected_branch(&self) -> Result<()> {
+ checkout_branch(
+ asyncgit::CWD,
+ &self.branch_names[self.selection as usize].reference,
+ )?;
+ self.queue
+ .borrow_mut()
+ .push_back(InternalEvent::Update(NeedsUpdate::ALL));
+
+ Ok(())
+ }
+}
diff --git a/src/components/stashmsg.rs b/src/components/stashmsg.rs
new file mode 100644
index 0000000..8b3e01f
--- /dev/null
+++ b/src/components/stashmsg.rs
@@ -0,0 +1,148 @@
+use super::{
+ textinput::TextInputComponent, visibility_blocking,
+ CommandBlocking, CommandInfo, Component, DrawableComponent,
+};
+use crate::{
+ keys::SharedKeyConfig,
+ queue::{InternalEvent, NeedsUpdate, Queue},
+ strings,
+ tabs::StashingOptions,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{sync, CWD};
+use crossterm::event::Event;
+use tui::{backend::Backend, layout::Rect, Frame};
+
+pub struct StashMsgComponent {
+ options: StashingOptions,
+ input: TextInputComponent,
+ queue: Queue,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for StashMsgComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ self.input.draw(f, rect)?;
+
+ Ok(())
+ }
+}
+
+impl Component for StashMsgComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ if self.is_visible() || force_all {
+ self.input.commands(out, force_all);
+
+ out.push(CommandInfo::new(
+ strings::commands::stashing_confirm_msg(
+ &self.key_config,
+ ),
+ true,
+ true,
+ ));
+ }
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.is_visible() {
+ if self.input.event(ev)? {
+ return Ok(true);
+ }
+
+ if let Event::Key(e) = ev {
+ if e == self.key_config.enter {
+ match sync::stash_save(
+ CWD,
+ if self.input.get_text().is_empty() {
+ None
+ } else {
+ Some(self.input.get_text().as_str())
+ },
+ self.options.stash_untracked,
+ self.options.keep_index,
+ ) {
+ Ok(_) => {
+ self.input.clear();
+ self.hide();
+
+ self.queue.borrow_mut().push_back(
+ InternalEvent::Update(
+ NeedsUpdate::ALL,
+ ),
+ );
+ }
+ Err(e) => {
+ self.hide();
+ log::error!(
+ "e: {} (options: {:?})",
+ e,
+ self.options
+ );
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "stash error:\n{}\noptions:\n{:?}",
+ e, self.options
+ )),
+ );
+ }
+ }
+ }
+
+ // stop key event propagation
+ return Ok(true);
+ }
+ }
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.input.is_visible()
+ }
+
+ fn hide(&mut self) {
+ self.input.hide()
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.input.show()?;
+
+ Ok(())
+ }
+}
+
+impl StashMsgComponent {
+ ///
+ pub fn new(
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ options: StashingOptions::default(),
+ queue,
+ input: TextInputComponent::new(
+ theme,
+ key_config.clone(),
+ &strings::stash_popup_title(&key_config),
+ &strings::stash_popup_msg(&key_config),
+ ),
+ key_config,
+ }
+ }
+
+ ///
+ pub fn options(&mut self, options: StashingOptions) {
+ self.options = options;
+ }
+}
diff --git a/src/components/tag_commit.rs b/src/components/tag_commit.rs
new file mode 100644
index 0000000..063871b
--- /dev/null
+++ b/src/components/tag_commit.rs
@@ -0,0 +1,144 @@
+use super::{
+ textinput::TextInputComponent, visibility_blocking,
+ CommandBlocking, CommandInfo, Component, DrawableComponent,
+};
+use crate::{
+ keys::SharedKeyConfig,
+ queue::{InternalEvent, NeedsUpdate, Queue},
+ strings,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{
+ sync::{self, CommitId},
+ CWD,
+};
+use crossterm::event::Event;
+use tui::{backend::Backend, layout::Rect, Frame};
+
+pub struct TagCommitComponent {
+ input: TextInputComponent,
+ commit_id: Option<CommitId>,
+ queue: Queue,
+ key_config: SharedKeyConfig,
+}
+
+impl DrawableComponent for TagCommitComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ rect: Rect,
+ ) -> Result<()> {
+ self.input.draw(f, rect)?;
+
+ Ok(())
+ }
+}
+
+impl Component for TagCommitComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ force_all: bool,
+ ) -> CommandBlocking {
+ if self.is_visible() || force_all {
+ self.input.commands(out, force_all);
+
+ out.push(CommandInfo::new(
+ strings::commands::tag_commit_confirm_msg(
+ &self.key_config,
+ ),
+ true,
+ true,
+ ));
+ }
+
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.is_visible() {
+ if self.input.event(ev)? {
+ return Ok(true);
+ }
+
+ if let Event::Key(e) = ev {
+ if e == self.key_config.enter {
+ self.tag()
+ }
+
+ return Ok(true);
+ }
+ }
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.input.is_visible()
+ }
+
+ fn hide(&mut self) {
+ self.input.hide()
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.input.show()?;
+
+ Ok(())
+ }
+}
+
+impl TagCommitComponent {
+ ///
+ pub fn new(
+ queue: Queue,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ queue,
+ input: TextInputComponent::new(
+ theme,
+ key_config.clone(),
+ &strings::tag_commit_popup_title(&key_config),
+ &strings::tag_commit_popup_msg(&key_config),
+ ),
+ commit_id: None,
+ key_config,
+ }
+ }
+
+ ///
+ pub fn open(&mut self, id: CommitId) -> Result<()> {
+ self.commit_id = Some(id);
+ self.show()?;
+
+ Ok(())
+ }
+
+ ///
+ pub fn tag(&mut self) {
+ if let Some(commit_id) = self.commit_id {
+ match sync::tag(CWD, &commit_id, self.input.get_text()) {
+ Ok(_) => {
+ self.input.clear();
+ self.hide();
+
+ self.queue.borrow_mut().push_back(
+ InternalEvent::Update(NeedsUpdate::ALL),
+ );
+ }
+ Err(e) => {
+ self.hide();
+ log::error!("e: {}", e,);
+ self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "tag error:\n{}",
+ e,
+ )),
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/textinput.rs b/src/components/textinput.rs
new file mode 100644
index 0000000..a0c471d
--- /dev/null
+++ b/src/components/textinput.rs
@@ -0,0 +1,503 @@
+use crate::ui::Size;
+use crate::{
+ components::{
+ popup_paragraph, visibility_blocking, CommandBlocking,
+ CommandInfo, Component, DrawableComponent,
+ },
+ keys::SharedKeyConfig,
+ strings,
+ ui::{self, style::SharedTheme},
+};
+use anyhow::Result;
+use crossterm::event::{Event, KeyCode, KeyModifiers};
+use itertools::Itertools;
+use std::{collections::HashMap, ops::Range};
+use tui::{
+ backend::Backend,
+ layout::Rect,
+ style::Modifier,
+ text::{Spans, Text},
+ widgets::Clear,
+ Frame,
+};
+
+#[derive(PartialEq)]
+pub enum InputType {
+ Singleline,
+ Multiline,
+ Password,
+}
+
+/// primarily a subcomponet for user input of text (used in `CommitComponent`)
+pub struct TextInputComponent {
+ title: String,
+ default_msg: String,
+ msg: String,
+ visible: bool,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ cursor_position: usize,
+ input_type: InputType,
+}
+
+impl TextInputComponent {
+ ///
+ pub fn new(
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ title: &str,
+ default_msg: &str,
+ ) -> Self {
+ Self {
+ msg: String::new(),
+ visible: false,
+ theme,
+ key_config,
+ title: title.to_string(),
+ default_msg: default_msg.to_string(),
+ cursor_position: 0,
+ input_type: InputType::Multiline,
+ }
+ }
+
+ pub const fn with_input_type(
+ mut self,
+ input_type: InputType,
+ ) -> Self {
+ self.input_type = input_type;
+ self
+ }
+
+ /// Clear the `msg`.
+ pub fn clear(&mut self) {
+ self.msg.clear();
+ self.cursor_position = 0;
+ }
+
+ /// Get the `msg`.
+ pub const fn get_text(&self) -> &String {
+ &self.msg
+ }
+
+ /// Move the cursor right one char.
+ fn incr_cursor(&mut self) {
+ if let Some(pos) = self.next_char_position() {
+ self.cursor_position = pos;
+ }
+ }
+
+ /// Move the cursor left one char.
+ fn decr_cursor(&mut self) {
+ let mut index = self.cursor_position.saturating_sub(1);
+ while index > 0 && !self.msg.is_char_boundary(index) {
+ index -= 1;
+ }
+ self.cursor_position = index;
+ }
+
+ /// Get the position of the next char, or, if the cursor points
+ /// to the last char, the `msg.len()`.
+ /// Returns None when the cursor is already at `msg.len()`.
+ fn next_char_position(&self) -> Option<usize> {
+ if self.cursor_position >= self.msg.len() {
+ return None;
+ }
+ let mut index = self.cursor_position.saturating_add(1);
+ while index < self.msg.len()
+ && !self.msg.is_char_boundary(index)
+ {
+ index += 1;
+ }
+ Some(index)
+ }
+
+ fn backspace(&mut self) {
+ if self.cursor_position > 0 {
+ self.decr_cursor();
+ self.msg.remove(self.cursor_position);
+ }
+ }
+
+ /// Set the `msg`.
+ pub fn set_text(&mut self, msg: String) {
+ self.msg = msg;
+ self.cursor_position = 0;
+ }
+
+ /// Set the `title`.
+ pub fn set_title(&mut self, t: String) {
+ self.title = t;
+ }
+
+ fn get_draw_text(&self) -> Text {
+ let style = self.theme.text(true, false);
+
+ let mut txt = Text::default();
+ // The portion of the text before the cursor is added
+ // if the cursor is not at the first character.
+ if self.cursor_position > 0 {
+ let text_before_cursor =
+ self.get_msg(0..self.cursor_position);
+ let ends_in_nl = text_before_cursor.ends_with('\n');
+ txt = text_append(
+ txt,
+ Text::styled(text_before_cursor, style),
+ );
+ if ends_in_nl {
+ txt.lines.push(Spans::default());
+ // txt = text_append(txt, Text::styled("\n\r", style));
+ }
+ }
+
+ let cursor_str = self
+ .next_char_position()
+ // if the cursor is at the end of the msg
+ // a whitespace is used to underline
+ .map_or(" ".to_owned(), |pos| {
+ self.get_msg(self.cursor_position..pos)
+ });
+
+ let cursor_highlighting = {
+ let mut h = HashMap::with_capacity(2);
+ h.insert("\n", "\u{21b5}\n\r");
+ h.insert(" ", "\u{00B7}");
+ h
+ };
+
+ if let Some(substitute) =
+ cursor_highlighting.get(cursor_str.as_str())
+ {
+ txt = text_append(
+ txt,
+ Text::styled(
+ substitute.to_owned(),
+ self.theme
+ .text(false, false)
+ .add_modifier(Modifier::UNDERLINED),
+ ),
+ );
+ } else {
+ txt = text_append(
+ txt,
+ Text::styled(
+ cursor_str,
+ style.add_modifier(Modifier::UNDERLINED),
+ ),
+ );
+ }
+
+ // The final portion of the text is added if there are
+ // still remaining characters.
+ if let Some(pos) = self.next_char_position() {
+ if pos < self.msg.len() {
+ txt = text_append(
+ txt,
+ Text::styled(
+ self.get_msg(pos..self.msg.len()),
+ style,
+ ),
+ );
+ }
+ }
+
+ txt
+ }
+
+ fn get_msg(&self, range: Range<usize>) -> String {
+ match self.input_type {
+ InputType::Password => range.map(|_| "*").join(""),
+ _ => self.msg[range].to_owned(),
+ }
+ }
+}
+
+// merges last line of `txt` with first of `append` so we do not generate unneeded newlines
+fn text_append<'a>(txt: Text<'a>, append: Text<'a>) -> Text<'a> {
+ let mut txt = txt;
+ if let Some(last_line) = txt.lines.last_mut() {
+ if let Some(first_line) = append.lines.first() {
+ last_line.0.extend(first_line.0.clone());
+ }
+
+ if append.lines.len() > 1 {
+ for line in 1..append.lines.len() {
+ let spans = append.lines[line].clone();
+ txt.lines.push(spans);
+ }
+ }
+ } else {
+ txt = append
+ }
+ txt
+}
+
+impl DrawableComponent for TextInputComponent {
+ fn draw<B: Backend>(
+ &self,
+ f: &mut Frame<B>,
+ _rect: Rect,
+ ) -> Result<()> {
+ if self.visible {
+ let txt = if self.msg.is_empty() {
+ Text::styled(
+ self.default_msg.as_str(),
+ self.theme.text(false, false),
+ )
+ } else {
+ self.get_draw_text()
+ };
+
+ let area = match self.input_type {
+ InputType::Multiline => {
+ let area = ui::centered_rect(60, 20, f.size());
+ ui::rect_inside(
+ Size::new(10, 3),
+ f.size().into(),
+ area,
+ )
+ }
+ _ => ui::centered_rect_absolute(32, 3, f.size()),
+ };
+
+ f.render_widget(Clear, area);
+ f.render_widget(
+ popup_paragraph(
+ self.title.as_str(),
+ txt,
+ &self.theme,
+ true,
+ ),
+ area,
+ );
+ }
+
+ Ok(())
+ }
+}
+
+impl Component for TextInputComponent {
+ fn commands(
+ &self,
+ out: &mut Vec<CommandInfo>,
+ _force_all: bool,
+ ) -> CommandBlocking {
+ out.push(
+ CommandInfo::new(
+ strings::commands::close_popup(&self.key_config),
+ true,
+ self.visible,
+ )
+ .order(1),
+ );
+ visibility_blocking(self)
+ }
+
+ fn event(&mut self, ev: Event) -> Result<bool> {
+ if self.visible {
+ if let Event::Key(e) = ev {
+ if e == self.key_config.exit_popup {
+ self.hide();
+ return Ok(true);
+ }
+
+ let is_ctrl =
+ e.modifiers.contains(KeyModifiers::CONTROL);
+
+ match e.code {
+ KeyCode::Char(c) if !is_ctrl => {
+ self.msg.insert(self.cursor_position, c);
+ self.incr_cursor();
+ return Ok(true);
+ }
+ KeyCode::Delete => {
+ if self.cursor_position < self.msg.len() {
+ self.msg.remove(self.cursor_position);
+ }
+ return Ok(true);
+ }
+ KeyCode::Backspace => {
+ self.backspace();
+ return Ok(true);
+ }
+ KeyCode::Left => {
+ self.decr_cursor();
+ return Ok(true);
+ }
+ KeyCode::Right => {
+ self.incr_cursor();
+ return Ok(true);
+ }
+ KeyCode::Home => {
+ self.cursor_position = 0;
+ return Ok(true);
+ }
+ KeyCode::End => {
+ self.cursor_position = self.msg.len();
+ return Ok(true);
+ }
+ _ => (),
+ };
+ }
+ }
+ Ok(false)
+ }
+
+ fn is_visible(&self) -> bool {
+ self.visible
+ }
+
+ fn hide(&mut self) {
+ self.visible = false
+ }
+
+ fn show(&mut self) -> Result<()> {
+ self.visible = true;
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tui::{style::Style, text::Span};
+
+ #[test]
+ fn test_smoke() {
+ let mut comp = TextInputComponent::new(
+ SharedTheme::default(),
+ SharedKeyConfig::default(),
+ "",
+ "",
+ );
+
+ comp.set_text(String::from("a\nb"));
+
+ assert_eq!(comp.cursor_position, 0);
+
+ comp.incr_cursor();
+ assert_eq!(comp.cursor_position, 1);
+
+ comp.decr_cursor();
+ assert_eq!(comp.cursor_position, 0);
+ }
+
+ #[test]
+ fn text_cursor_initial_position() {
+ let mut comp = TextInputComponent::new(
+ SharedTheme::default(),
+ SharedKeyConfig::default(),
+ "",
+ "",
+ );
+ let theme = SharedTheme::default();
+ let underlined = theme
+ .text(true, false)
+ .add_modifier(Modifier::UNDERLINED);
+
+ comp.set_text(String::from("a"));
+
+ let txt = comp.get_draw_text();
+
+ assert_eq!(txt.lines[0].0.len(), 1);
+ assert_eq!(get_text(&txt.lines[0].0[0]), Some("a"));
+ assert_eq!(get_style(&txt.lines[0].0[0]), Some(&underlined));
+ }
+
+ #[test]
+ fn test_cursor_second_position() {
+ let mut comp = TextInputComponent::new(
+ SharedTheme::default(),
+ SharedKeyConfig::default(),
+ "",
+ "",
+ );
+ let theme = SharedTheme::default();
+ let underlined_whitespace = theme
+ .text(false, false)
+ .add_modifier(Modifier::UNDERLINED);
+
+ let not_underlined = Style::default();
+
+ comp.set_text(String::from("a"));
+ comp.incr_cursor();
+
+ let txt = comp.get_draw_text();
+
+ assert_eq!(txt.lines[0].0.len(), 2);
+ assert_eq!(get_text(&txt.lines[0].0[0]), Some("a"));
+ assert_eq!(
+ get_style(&txt.lines[0].0[0]),
+ Some(&not_underlined)
+ );
+ assert_eq!(get_text(&txt.lines[0].0[1]), Some("\u{00B7}"));
+ assert_eq!(
+ get_style(&txt.lines[0].0[1]),
+ Some(&underlined_whitespace)
+ );
+ }
+
+ #[test]
+ fn test_visualize_newline() {
+ let mut comp = TextInputComponent::new(
+ SharedTheme::default(),
+ SharedKeyConfig::default(),
+ "",
+ "",
+ );
+
+ let theme = SharedTheme::default();
+ let underlined = theme
+ .text(false, false)
+ .add_modifier(Modifier::UNDERLINED);
+
+ comp.set_text(String::from("a\nb"));
+ comp.incr_cursor();
+
+ let txt = comp.get_draw_text();
+
+ assert_eq!(txt.lines.len(), 2);
+ assert_eq!(txt.lines[0].0.len(), 2);
+ assert_eq!(txt.lines[1].0.len(), 2);
+ assert_eq!(get_text(&txt.lines[0].0[0]), Some("a"));
+ assert_eq!(get_text(&txt.lines[0].0[1]), Some("\u{21b5}"));
+ assert_eq!(get_style(&txt.lines[0].0[1]), Some(&underlined));
+ assert_eq!(get_text(&txt.lines[1].0[0]), Some(""));
+ assert_eq!(get_text(&txt.lines[1].0[1]), Some("b"));
+ }
+
+ #[test]
+ fn test_invisable_newline() {
+ let mut comp = TextInputComponent::new(
+ SharedTheme::default(),
+ SharedKeyConfig::default(),
+ "",
+ "",
+ );
+
+ let theme = SharedTheme::default();
+ let underlined = theme
+ .text(true, false)
+ .add_modifier(Modifier::UNDERLINED);
+
+ comp.set_text(String::from("a\nb"));
+
+ let txt = comp.get_draw_text();
+
+ assert_eq!(txt.lines.len(), 2);
+ assert_eq!(txt.lines[0].0.len(), 2);
+ assert_eq!(txt.lines[1].0.len(), 1);
+ assert_eq!(get_text(&txt.lines[0].0[0]), Some("a"));
+ assert_eq!(get_text(&txt.lines[0].0[1]), Some(""));
+ assert_eq!(get_style(&txt.lines[0].0[0]), Some(&underlined));
+ assert_eq!(get_text(&txt.lines[1].0[0]), Some("b"));
+ }
+
+ fn get_text<'a>(t: &'a Span) -> Option<&'a str> {
+ Some(&t.content)
+ }
+
+ fn get_style<'a>(t: &'a Span) -> Option<&'a Style> {
+ Some(&t.style)
+ }
+}
diff --git a/src/components/utils/filetree.rs b/src/components/utils/filetree.rs
new file mode 100644
index 0000000..b77c7ad
--- /dev/null
+++ b/src/components/utils/filetree.rs
@@ -0,0 +1,428 @@
+use anyhow::{bail, Result};
+use asyncgit::StatusItem;
+use std::{
+ collections::BTreeSet,
+ convert::TryFrom,
+ ffi::OsStr,
+ ops::{Index, IndexMut},
+ path::Path,
+};
+
+/// holds the information shared among all `FileTreeItem` in a `FileTree`
+#[derive(Debug, Clone)]
+pub struct TreeItemInfo {
+ /// indent level
+ pub indent: u8,
+ /// currently visible depending on the folder collapse states
+ pub visible: bool,
+ /// just the last path element
+ pub path: String,
+ /// the full path
+ pub full_path: String,
+}
+
+impl TreeItemInfo {
+ const fn new(
+ indent: u8,
+ path: String,
+ full_path: String,
+ ) -> Self {
+ Self {
+ indent,
+ visible: true,
+ path,
+ full_path,
+ }
+ }
+}
+
+/// attribute used to indicate the collapse/expand state of a path item
+#[derive(PartialEq, Debug, Copy, Clone)]
+pub struct PathCollapsed(pub bool);
+
+/// `FileTreeItem` can be of two kinds
+#[derive(PartialEq, Debug, Clone)]
+pub enum FileTreeItemKind {
+ Path(PathCollapsed),
+ File(StatusItem),
+}
+
+/// `FileTreeItem` can be of two kinds: see `FileTreeItem` but shares an info
+#[derive(Debug, Clone)]
+pub struct FileTreeItem {
+ pub info: TreeItemInfo,
+ pub kind: FileTreeItemKind,
+}
+
+impl FileTreeItem {
+ fn new_file(item: &StatusItem) -> Result<Self> {
+ let item_path = Path::new(&item.path);
+ let indent = u8::try_from(
+ item_path.ancestors().count().saturating_sub(2),
+ )?;
+
+ let name = item_path
+ .file_name()
+ .map(OsStr::to_string_lossy)
+ .map(|x| x.to_string());
+
+ match name {
+ Some(path) => Ok(Self {
+ info: TreeItemInfo::new(
+ indent,
+ path,
+ item.path.clone(),
+ ),
+ kind: FileTreeItemKind::File(item.clone()),
+ }),
+ None => bail!("invalid file name {:?}", item),
+ }
+ }
+
+ fn new_path(
+ path: &Path,
+ path_string: String,
+ collapsed: bool,
+ ) -> Result<Self> {
+ let indent =
+ u8::try_from(path.ancestors().count().saturating_sub(2))?;
+
+ match path
+ .components()
+ .last()
+ .map(std::path::Component::as_os_str)
+ .map(OsStr::to_string_lossy)
+ .map(String::from)
+ {
+ Some(path) => Ok(Self {
+ info: TreeItemInfo::new(indent, path, path_string),
+ kind: FileTreeItemKind::Path(PathCollapsed(
+ collapsed,
+ )),
+ }),
+ None => bail!("failed to create item from path"),
+ }
+ }
+}
+
+impl Eq for FileTreeItem {}
+
+impl PartialEq for FileTreeItem {
+ fn eq(&self, other: &Self) -> bool {
+ self.info.full_path.eq(&other.info.full_path)
+ }
+}
+
+impl PartialOrd for FileTreeItem {
+ fn partial_cmp(
+ &self,
+ other: &Self,
+ ) -> Option<std::cmp::Ordering> {
+ self.info.full_path.partial_cmp(&other.info.full_path)
+ }
+}
+
+impl Ord for FileTreeItem {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ self.info.path.cmp(&other.info.path)
+ }
+}
+
+///
+#[derive(Default)]
+pub struct FileTreeItems {
+ items: Vec<FileTreeItem>,
+ file_count: usize,
+}
+
+impl FileTreeItems {
+ ///
+ pub(crate) fn new(
+ list: &[StatusItem],
+ collapsed: &BTreeSet<&String>,
+ ) -> Result<Self> {
+ let mut items = Vec::with_capacity(list.len());
+ let mut paths_added = BTreeSet::new();
+
+ for e in list {
+ {
+ let item_path = Path::new(&e.path);
+
+ Self::push_dirs(
+ item_path,
+ &mut items,
+ &mut paths_added,
+ collapsed,
+ )?;
+ }
+
+ items.push(FileTreeItem::new_file(e)?);
+ }
+
+ Ok(Self {
+ items,
+ file_count: list.len(),
+ })
+ }
+
+ ///
+ pub(crate) const fn items(&self) -> &Vec<FileTreeItem> {
+ &self.items
+ }
+
+ ///
+ pub(crate) fn len(&self) -> usize {
+ self.items.len()
+ }
+
+ ///
+ pub const fn file_count(&self) -> usize {
+ self.file_count
+ }
+
+ ///
+ pub(crate) fn find_parent_index(&self, index: usize) -> usize {
+ let item_indent = &self.items[index].info.indent;
+ let mut parent_index = index;
+ while item_indent <= &self.items[parent_index].info.indent {
+ if parent_index == 0 {
+ return 0;
+ }
+ parent_index -= 1;
+ }
+
+ parent_index
+ }
+
+ fn push_dirs<'a>(
+ item_path: &'a Path,
+ nodes: &mut Vec<FileTreeItem>,
+ paths_added: &mut BTreeSet<&'a Path>,
+ collapsed: &BTreeSet<&String>,
+ ) -> Result<()> {
+ let mut ancestors =
+ { item_path.ancestors().skip(1).collect::<Vec<_>>() };
+ ancestors.reverse();
+
+ for c in &ancestors {
+ if c.parent().is_some() && !paths_added.contains(c) {
+ paths_added.insert(c);
+ let path_string =
+ String::from(c.to_str().expect("invalid path"));
+ let is_collapsed = collapsed.contains(&path_string);
+ nodes.push(FileTreeItem::new_path(
+ c,
+ path_string,
+ is_collapsed,
+ )?);
+ }
+ }
+
+ Ok(())
+ }
+
+ pub fn multiple_items_at_path(&self, index: usize) -> bool {
+ let tree_items = self.items();
+ let mut idx_temp_inner;
+ if index + 2 < tree_items.len() {
+ idx_temp_inner = index + 1;
+ while idx_temp_inner < tree_items.len().saturating_sub(1)
+ && tree_items[index].info.indent
+ < tree_items[idx_temp_inner].info.indent
+ {
+ idx_temp_inner += 1;
+ }
+ } else {
+ return false;
+ }
+
+ tree_items[idx_temp_inner].info.indent
+ == tree_items[index].info.indent
+ }
+}
+
+impl IndexMut<usize> for FileTreeItems {
+ fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
+ &mut self.items[idx]
+ }
+}
+
+impl Index<usize> for FileTreeItems {
+ type Output = FileTreeItem;
+
+ fn index(&self, idx: usize) -> &Self::Output {
+ &self.items[idx]
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use asyncgit::StatusItemType;
+
+ fn string_vec_to_status(items: &[&str]) -> Vec<StatusItem> {
+ items
+ .iter()
+ .map(|a| StatusItem {
+ path: String::from(*a),
+ status: StatusItemType::Modified,
+ })
+ .collect::<Vec<_>>()
+ }
+
+ #[test]
+ fn test_simple() {
+ let items = string_vec_to_status(&[
+ "file.txt", //
+ ]);
+
+ let res =
+ FileTreeItems::new(&items, &BTreeSet::new()).unwrap();
+
+ assert_eq!(
+ res.items,
+ vec![FileTreeItem {
+ info: TreeItemInfo {
+ path: items[0].path.clone(),
+ full_path: items[0].path.clone(),
+ indent: 0,
+ visible: true,
+ },
+ kind: FileTreeItemKind::File(items[0].clone())
+ }]
+ );
+
+ let items = string_vec_to_status(&[
+ "file.txt", //
+ "file2.txt", //
+ ]);
+
+ let res =
+ FileTreeItems::new(&items, &BTreeSet::new()).unwrap();
+
+ assert_eq!(res.items.len(), 2);
+ assert_eq!(res.items[1].info.path, items[1].path);
+ }
+
+ #[test]
+ fn test_folder() {
+ let items = string_vec_to_status(&[
+ "a/file.txt", //
+ ]);
+
+ let res = FileTreeItems::new(&items, &BTreeSet::new())
+ .unwrap()
+ .items
+ .iter()
+ .map(|i| i.info.full_path.clone())
+ .collect::<Vec<_>>();
+
+ assert_eq!(
+ res,
+ vec![String::from("a"), items[0].path.clone(),]
+ );
+ }
+
+ #[test]
+ fn test_indent() {
+ let items = string_vec_to_status(&[
+ "a/b/file.txt", //
+ ]);
+
+ let list =
+ FileTreeItems::new(&items, &BTreeSet::new()).unwrap();
+ let mut res = list
+ .items
+ .iter()
+ .map(|i| (i.info.indent, i.info.path.as_str()));
+
+ assert_eq!(res.next(), Some((0, "a")));
+ assert_eq!(res.next(), Some((1, "b")));
+ assert_eq!(res.next(), Some((2, "file.txt")));
+ }
+
+ #[test]
+ fn test_indent_folder_file_name() {
+ let items = string_vec_to_status(&[
+ "a/b", //
+ "a.txt", //
+ ]);
+
+ let list =
+ FileTreeItems::new(&items, &BTreeSet::new()).unwrap();
+ let mut res = list
+ .items
+ .iter()
+ .map(|i| (i.info.indent, i.info.path.as_str()));
+
+ assert_eq!(res.next(), Some((0, "a")));
+ assert_eq!(res.next(), Some((1, "b")));
+ assert_eq!(res.next(), Some((0, "a.txt")));
+ }
+
+ #[test]
+ fn test_folder_dup() {
+ let items = string_vec_to_status(&[
+ "a/file.txt", //
+ "a/file2.txt", //
+ ]);
+
+ let res = FileTreeItems::new(&items, &BTreeSet::new())
+ .unwrap()
+ .items
+ .iter()
+ .map(|i| i.info.full_path.clone())
+ .collect::<Vec<_>>();
+
+ assert_eq!(
+ res,
+ vec![
+ String::from("a"),
+ items[0].path.clone(),
+ items[1].path.clone()
+ ]
+ );
+ }
+
+ #[test]
+ fn test_multiple_items_at_path() {
+ //0 a/
+ //1 b/
+ //2 c/
+ //3 d
+ //4 e/
+ //5 f
+
+ let res = FileTreeItems::new(
+ &string_vec_to_status(&[
+ "a/b/c/d", //
+ "a/b/e/f", //
+ ]),
+ &BTreeSet::new(),
+ )
+ .unwrap();
+
+ assert_eq!(res.multiple_items_at_path(0), false);
+ assert_eq!(res.multiple_items_at_path(1), false);
+ assert_eq!(res.multiple_items_at_path(2), true);
+ }
+
+ #[test]
+ fn test_find_parent() {
+ //0 a/
+ //1 b/
+ //2 c
+ //3 d
+
+ let res = FileTreeItems::new(
+ &string_vec_to_status(&[
+ "a/b/c", //
+ "a/b/d", //
+ ]),
+ &BTreeSet::new(),
+ )
+ .unwrap();
+
+ assert_eq!(res.find_parent_index(3), 1);
+ }
+}
diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs
new file mode 100644
index 0000000..9fa26be
--- /dev/null
+++ b/src/components/utils/logitems.rs
@@ -0,0 +1,77 @@
+use super::time_to_string;
+use asyncgit::sync::{CommitId, CommitInfo};
+use std::slice::Iter;
+
+static SLICE_OFFSET_RELOAD_THRESHOLD: usize = 100;
+
+pub struct LogEntry {
+ pub time: String,
+ pub author: String,
+ pub msg: String,
+ pub hash_short: String,
+ pub id: CommitId,
+}
+
+impl From<CommitInfo> for LogEntry {
+ fn from(c: CommitInfo) -> Self {
+ Self {
+ author: c.author,
+ msg: c.message,
+ time: time_to_string(c.time, true),
+ hash_short: c.id.get_short_string(),
+ id: c.id,
+ }
+ }
+}
+
+///
+#[derive(Default)]
+pub struct ItemBatch {
+ index_offset: usize,
+ items: Vec<LogEntry>,
+}
+
+impl ItemBatch {
+ fn last_idx(&self) -> usize {
+ self.index_offset + self.items.len()
+ }
+
+ ///
+ pub const fn index_offset(&self) -> usize {
+ self.index_offset
+ }
+
+ /// shortcut to get an `Iter` of our internal items
+ pub fn iter(&self) -> Iter<'_, LogEntry> {
+ self.items.iter()
+ }
+
+ /// clear curent list of items
+ pub fn clear(&mut self) {
+ self.items.clear();
+ }
+
+ /// insert new batch of items
+ pub fn set_items(
+ &mut self,
+ start_index: usize,
+ commits: Vec<CommitInfo>,
+ ) {
+ self.items.clear();
+ self.items.extend(commits.into_iter().map(LogEntry::from));
+ self.index_offset = start_index;
+ }
+
+ /// returns `true` if we should fetch updated list of items
+ pub fn needs_data(&self, idx: usize, idx_max: usize) -> bool {
+ let want_min =
+ idx.saturating_sub(SLICE_OFFSET_RELOAD_THRESHOLD);
+ let want_max = idx
+ .saturating_add(SLICE_OFFSET_RELOAD_THRESHOLD)
+ .min(idx_max);
+
+ let needs_data_top = want_min < self.index_offset;
+ let needs_data_bottom = want_max >= self.last_idx();
+ needs_data_bottom || needs_data_top
+ }
+}
diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs
new file mode 100644
index 0000000..a3fe565
--- /dev/null
+++ b/src/components/utils/mod.rs
@@ -0,0 +1,35 @@
+use chrono::{DateTime, Local, NaiveDateTime, Utc};
+
+pub mod filetree;
+pub mod logitems;
+pub mod statustree;
+
+/// macro to simplify running code that might return Err.
+/// It will show a popup in that case
+#[macro_export]
+macro_rules! try_or_popup {
+ ($self:ident, $msg:literal, $e:expr) => {
+ if let Err(err) = $e {
+ $self.queue.borrow_mut().push_back(
+ InternalEvent::ShowErrorMsg(format!(
+ "{}\n{}",
+ $msg, err
+ )),
+ );
+ }
+ };
+}
+
+/// helper func to convert unix time since epoch to formated time string in local timezone
+pub fn time_to_string(secs: i64, short: bool) -> String {
+ let time = DateTime::<Local>::from(DateTime::<Utc>::from_utc(
+ NaiveDateTime::from_timestamp(secs, 0),
+ Utc,
+ ));
+ time.format(if short {
+ "%Y-%m-%d"
+ } else {
+ "%Y-%m-%d %H:%M:%S"
+ })
+ .to_string()
+}
diff --git a/src/components/utils/statustree.rs b/src/components/utils/statustree.rs
new file mode 100644
index 0000000..61f2f9b
--- /dev/null
+++ b/src/components/utils/statustree.rs
@@ -0,0 +1,903 @@
+use super::filetree::{
+ FileTreeItem, FileTreeItemKind, FileTreeItems, PathCollapsed,
+};
+use anyhow::Result;
+use asyncgit::StatusItem;
+use std::{cmp, collections::BTreeSet};
+
+///
+#[derive(Default)]
+pub struct StatusTree {
+ pub tree: FileTreeItems,
+ pub selection: Option<usize>,
+
+ // some folders may be folded up, this allows jumping
+ // over folders which are folded into their parent
+ pub available_selections: Vec<usize>,
+}
+
+///
+#[derive(Copy, Clone, Debug)]
+pub enum MoveSelection {
+ Up,
+ Down,
+ Left,
+ Right,
+ Home,
+ End,
+}
+
+#[derive(Copy, Clone, Debug)]
+struct SelectionChange {
+ new_index: usize,
+ changes: bool,
+}
+impl SelectionChange {
+ const fn new(new_index: usize, changes: bool) -> Self {
+ Self { new_index, changes }
+ }
+}
+
+impl StatusTree {
+ /// update tree with a new list, try to retain selection and collapse states
+ pub fn update(&mut self, list: &[StatusItem]) -> Result<()> {
+ let last_collapsed = self.all_collapsed();
+
+ let last_selection =
+ self.selected_item().map(|e| e.info.full_path);
+ let last_selection_index = self.selection.unwrap_or(0);
+
+ self.tree = FileTreeItems::new(list, &last_collapsed)?;
+ self.selection = last_selection.as_ref().map_or_else(
+ || self.tree.items().first().map(|_| 0),
+ |last_selection| {
+ self.find_last_selection(
+ last_selection,
+ last_selection_index,
+ )
+ .or_else(|| self.tree.items().first().map(|_| 0))
+ },
+ );
+
+ self.update_visibility(None, 0, true);
+ self.available_selections = self.setup_available_selections();
+
+ //NOTE: now that visibility is set we can make sure selection is visible
+ if let Some(idx) = self.selection {
+ self.selection = Some(self.find_visible_idx(idx));
+ }
+
+ Ok(())
+ }
+
+ /// Return which indices can be selected, taking into account that
+ /// some folders may be folded up into their parent
+ ///
+ /// It should be impossible to select a folder which has been folded into its parent
+ fn setup_available_selections(&self) -> Vec<usize> {
+ // use the same algorithm as in filetree build_vec_text_for_drawing function
+ let mut should_skip_over: usize = 0;
+ let mut vec_available_selections: Vec<usize> = vec![];
+ let tree_items = self.tree.items();
+ for index in 0..tree_items.len() {
+ if should_skip_over > 0 {
+ should_skip_over -= 1;
+ continue;
+ }
+ let mut idx_temp = index;
+ vec_available_selections.push(index);
+
+ while idx_temp < tree_items.len().saturating_sub(2)
+ && tree_items[idx_temp].info.indent
+ < tree_items[idx_temp + 1].info.indent
+ {
+ // fold up the folder/file
+ idx_temp += 1;
+ should_skip_over += 1;
+
+ // don't fold files up
+ if let FileTreeItemKind::File(_) =
+ &tree_items[idx_temp].kind
+ {
+ should_skip_over -= 1;
+ break;
+ }
+
+ // don't fold up if more than one folder in folder
+ if self.tree.multiple_items_at_path(idx_temp) {
+ should_skip_over -= 1;
+ break;
+ }
+ }
+ }
+ vec_available_selections
+ }
+
+ fn find_visible_idx(&self, mut idx: usize) -> usize {
+ while idx > 0 {
+ if self.is_visible_index(idx) {
+ break;
+ }
+
+ idx -= 1;
+ }
+
+ idx
+ }
+
+ ///
+ pub fn move_selection(&mut self, dir: MoveSelection) -> bool {
+ self.selection.map_or(false, |selection| {
+ let selection_change = match dir {
+ MoveSelection::Up => {
+ self.selection_updown(selection, true)
+ }
+ MoveSelection::Down => {
+ self.selection_updown(selection, false)
+ }
+ MoveSelection::Left => self.selection_left(selection),
+ MoveSelection::Right => {
+ self.selection_right(selection)
+ }
+ MoveSelection::Home => SelectionChange::new(0, false),
+ MoveSelection::End => self.selection_end(),
+ };
+
+ let changed_index =
+ selection_change.new_index != selection;
+
+ self.selection = Some(selection_change.new_index);
+
+ changed_index || selection_change.changes
+ })
+ }
+
+ ///
+ pub fn selected_item(&self) -> Option<FileTreeItem> {
+ self.selection.map(|i| self.tree[i].clone())
+ }
+
+ ///
+ pub fn is_empty(&self) -> bool {
+ self.tree.items().is_empty()
+ }
+
+ fn all_collapsed(&self) -> BTreeSet<&String> {
+ let mut res = BTreeSet::new();
+
+ for i in self.tree.items() {
+ if let FileTreeItemKind::Path(PathCollapsed(collapsed)) =
+ i.kind
+ {
+ if collapsed {
+ res.insert(&i.info.full_path);
+ }
+ }
+ }
+
+ res
+ }
+
+ fn find_last_selection(
+ &self,
+ last_selection: &str,
+ last_index: usize,
+ ) -> Option<usize> {
+ if self.is_empty() {
+ return None;
+ }
+
+ if let Ok(i) = self.tree.items().binary_search_by(|e| {
+ e.info.full_path.as_str().cmp(last_selection)
+ }) {
+ return Some(i);
+ }
+
+ Some(cmp::min(last_index, self.tree.len() - 1))
+ }
+
+ fn selection_updown(
+ &self,
+ current_index: usize,
+ up: bool,
+ ) -> SelectionChange {
+ let mut current_index_in_available_selections;
+ let mut cur_index_find = current_index;
+ if self.available_selections.is_empty() {
+ // Go to top
+ current_index_in_available_selections = 0;
+ } else {
+ loop {
+ if let Some(pos) = self
+ .available_selections
+ .iter()
+ .position(|i| *i == cur_index_find)
+ {
+ current_index_in_available_selections = pos;
+ break;
+ } else {
+ // Find the closest to the index, usually this shouldn't happen
+ if current_index == 0 {
+ // This should never happen
+ current_index_in_available_selections = 0;
+ break;
+ }
+ cur_index_find -= 1;
+ }
+ }
+ }
+
+ let mut new_index;
+
+ loop {
+ // Use available_selections to go to the correct selection as
+ // some of the folders may be folded up
+ new_index = if up {
+ current_index_in_available_selections =
+ current_index_in_available_selections
+ .saturating_sub(1);
+ self.available_selections
+ [current_index_in_available_selections]
+ } else if current_index_in_available_selections
+ .saturating_add(1)
+ <= self.available_selections.len().saturating_sub(1)
+ {
+ current_index_in_available_selections =
+ current_index_in_available_selections
+ .saturating_add(1);
+ self.available_selections
+ [current_index_in_available_selections]
+ } else {
+ // can't move down anymore
+ new_index = current_index;
+ break;
+ };
+
+ if self.is_visible_index(new_index) {
+ break;
+ }
+ }
+ SelectionChange::new(new_index, false)
+ }
+
+ fn selection_end(&self) -> SelectionChange {
+ let items_max = self.tree.len().saturating_sub(1);
+
+ let mut new_index = items_max;
+
+ loop {
+ if self.is_visible_index(new_index) {
+ break;
+ }
+
+ if new_index == 0 {
+ break;
+ }
+
+ new_index = new_index.saturating_sub(1);
+ new_index = cmp::min(new_index, items_max);
+ }
+
+ SelectionChange::new(new_index, false)
+ }
+
+ fn is_visible_index(&self, idx: usize) -> bool {
+ self.tree[idx].info.visible
+ }
+
+ fn selection_right(
+ &mut self,
+ current_selection: usize,
+ ) -> SelectionChange {
+ let item_kind = self.tree[current_selection].kind.clone();
+ let item_path =
+ self.tree[current_selection].info.full_path.clone();
+
+ match item_kind {
+ FileTreeItemKind::Path(PathCollapsed(collapsed))
+ if collapsed =>
+ {
+ self.expand(&item_path, current_selection);
+ return SelectionChange::new(current_selection, true);
+ }
+ FileTreeItemKind::Path(PathCollapsed(collapsed))
+ if !collapsed =>
+ {
+ return self
+ .selection_updown(current_selection, false);
+ }
+ _ => (),
+ }
+
+ SelectionChange::new(current_selection, false)
+ }
+
+ fn selection_left(
+ &mut self,
+ current_selection: usize,
+ ) -> SelectionChange {
+ let item_kind = self.tree[current_selection].kind.clone();
+ let item_path =
+ self.tree[current_selection].info.full_path.clone();
+
+ if matches!(item_kind, FileTreeItemKind::File(_))
+ || matches!(item_kind,FileTreeItemKind::Path(PathCollapsed(collapsed))
+ if collapsed)
+ {
+ let mut cur_parent =
+ self.tree.find_parent_index(current_selection);
+ while !self.available_selections.contains(&cur_parent)
+ && cur_parent != 0
+ {
+ cur_parent = self.tree.find_parent_index(cur_parent);
+ }
+ SelectionChange::new(cur_parent, false)
+ } else if matches!(item_kind, FileTreeItemKind::Path(PathCollapsed(collapsed))
+ if !collapsed)
+ {
+ self.collapse(&item_path, current_selection);
+ SelectionChange::new(current_selection, true)
+ } else {
+ SelectionChange::new(current_selection, false)
+ }
+ }
+
+ fn collapse(&mut self, path: &str, index: usize) {
+ if let FileTreeItemKind::Path(PathCollapsed(
+ ref mut collapsed,
+ )) = self.tree[index].kind
+ {
+ *collapsed = true;
+ }
+
+ let path = format!("{}/", path);
+
+ for i in index + 1..self.tree.len() {
+ let item = &mut self.tree[i];
+ let item_path = &item.info.full_path;
+ if item_path.starts_with(&path) {
+ item.info.visible = false
+ } else {
+ return;
+ }
+ }
+ }
+
+ fn expand(&mut self, path: &str, current_index: usize) {
+ if let FileTreeItemKind::Path(PathCollapsed(
+ ref mut collapsed,
+ )) = self.tree[current_index].kind
+ {
+ *collapsed = false;
+ }
+
+ let path = format!("{}/", path);
+
+ self.update_visibility(
+ Some(path.as_str()),
+ current_index + 1,
+ false,
+ );
+ }
+
+ fn update_visibility(
+ &mut self,
+ prefix: Option<&str>,
+ start_idx: usize,
+ set_defaults: bool,
+ ) {
+ // if we are in any subpath that is collapsed we keep skipping over it
+ let mut inner_collapsed: Option<String> = None;
+
+ for i in start_idx..self.tree.len() {
+ if let Some(ref collapsed_path) = inner_collapsed {
+ let p: &String = &self.tree[i].info.full_path;
+ if p.starts_with(collapsed_path) {
+ if set_defaults {
+ self.tree[i].info.visible = false;
+ }
+ // we are still in a collapsed inner path
+ continue;
+ } else {
+ inner_collapsed = None;
+ }
+ }
+
+ let item_kind = self.tree[i].kind.clone();
+ let item_path = &self.tree[i].info.full_path;
+
+ if matches!(item_kind, FileTreeItemKind::Path(PathCollapsed(collapsed)) if collapsed)
+ {
+ // we encountered an inner path that is still collapsed
+ inner_collapsed = Some(format!("{}/", &item_path));
+ }
+
+ if prefix
+ .map_or(true, |prefix| item_path.starts_with(prefix))
+ {
+ self.tree[i].info.visible = true
+ } else {
+ // if we do not set defaults we can early out
+ if set_defaults {
+ self.tree[i].info.visible = false;
+ } else {
+ return;
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use asyncgit::StatusItemType;
+
+ fn string_vec_to_status(items: &[&str]) -> Vec<StatusItem> {
+ items
+ .iter()
+ .map(|a| StatusItem {
+ path: String::from(*a),
+ status: StatusItemType::Modified,
+ })
+ .collect::<Vec<_>>()
+ }
+
+ fn get_visibles(tree: &StatusTree) -> Vec<bool> {
+ tree.tree
+ .items()
+ .iter()
+ .map(|e| e.info.visible)
+ .collect::<Vec<_>>()
+ }
+
+ #[test]
+ fn test_selection() {
+ let items = string_vec_to_status(&[
+ "a/b", //
+ ]);
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+
+ assert!(res.move_selection(MoveSelection::Down));
+
+ assert_eq!(res.selection, Some(1));
+
+ assert!(res.move_selection(MoveSelection::Left));
+
+ assert_eq!(res.selection, Some(0));
+ }
+
+ #[test]
+ fn test_keep_selected_item() {
+ let mut res = StatusTree::default();
+ res.update(&string_vec_to_status(&["b"])).unwrap();
+
+ assert_eq!(res.selection, Some(0));
+
+ res.update(&string_vec_to_status(&["a", "b"])).unwrap();
+
+ assert_eq!(res.selection, Some(1));
+ }
+
+ #[test]
+ fn test_keep_selected_index() {
+ let mut res = StatusTree::default();
+ res.update(&string_vec_to_status(&["a", "b"])).unwrap();
+ res.selection = Some(1);
+
+ res.update(&string_vec_to_status(&["d", "c", "a"])).unwrap();
+ assert_eq!(res.selection, Some(1));
+ }
+
+ #[test]
+ fn test_keep_selected_index_if_not_collapsed() {
+ let mut res = StatusTree::default();
+ res.update(&string_vec_to_status(&["a/b", "c"])).unwrap();
+
+ res.collapse("a/b", 0);
+
+ res.selection = Some(2);
+
+ res.update(&string_vec_to_status(&["a/b"])).unwrap();
+ assert_eq!(
+ get_visibles(&res),
+ vec![
+ true, //
+ false, //
+ ]
+ );
+ assert_eq!(
+ res.is_visible_index(res.selection.unwrap()),
+ true
+ );
+ assert_eq!(res.selection, Some(0));
+ }
+
+ #[test]
+ fn test_keep_collapsed_states() {
+ let mut res = StatusTree::default();
+ res.update(&string_vec_to_status(&[
+ "a/b", //
+ "c",
+ ]))
+ .unwrap();
+
+ res.collapse("a", 0);
+
+ assert_eq!(
+ res.all_collapsed().iter().collect::<Vec<_>>(),
+ vec![&&String::from("a")]
+ );
+
+ assert_eq!(
+ get_visibles(&res),
+ vec![
+ true, //
+ false, //
+ true, //
+ ]
+ );
+
+ res.update(&string_vec_to_status(&[
+ "a/b", //
+ "c", //
+ "d",
+ ]))
+ .unwrap();
+
+ assert_eq!(
+ res.all_collapsed().iter().collect::<Vec<_>>(),
+ vec![&&String::from("a")]
+ );
+
+ assert_eq!(
+ get_visibles(&res),
+ vec![
+ true, //
+ false, //
+ true, //
+ true
+ ]
+ );
+ }
+
+ #[test]
+ fn test_expand() {
+ let items = string_vec_to_status(&[
+ "a/b/c", //
+ "a/d", //
+ ]);
+
+ //0 a/
+ //1 b/
+ //2 c
+ //3 d
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+
+ res.collapse(&String::from("a/b"), 1);
+
+ let visibles = get_visibles(&res);
+
+ assert_eq!(
+ visibles,
+ vec![
+ true, //
+ true, //
+ false, //
+ true,
+ ]
+ );
+
+ res.expand(&String::from("a/b"), 1);
+
+ let visibles = get_visibles(&res);
+
+ assert_eq!(
+ visibles,
+ vec![
+ true, //
+ true, //
+ true, //
+ true,
+ ]
+ );
+ }
+
+ #[test]
+ fn test_expand_bug() {
+ let items = string_vec_to_status(&[
+ "a/b/c", //
+ "a/b2/d", //
+ ]);
+
+ //0 a/
+ //1 b/
+ //2 c
+ //3 b2/
+ //4 d
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+
+ res.collapse(&String::from("b"), 1);
+ res.collapse(&String::from("a"), 0);
+
+ assert_eq!(
+ get_visibles(&res),
+ vec![
+ true, //
+ false, //
+ false, //
+ false, //
+ false,
+ ]
+ );
+
+ res.expand(&String::from("a"), 0);
+
+ assert_eq!(
+ get_visibles(&res),
+ vec![
+ true, //
+ true, //
+ false, //
+ true, //
+ true,
+ ]
+ );
+ }
+
+ #[test]
+ fn test_collapse_too_much() {
+ let items = string_vec_to_status(&[
+ "a/b", //
+ "a2/c", //
+ ]);
+
+ //0 a/
+ //1 b
+ //2 a2/
+ //3 c
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+
+ res.collapse(&String::from("a"), 0);
+
+ let visibles = get_visibles(&res);
+
+ assert_eq!(
+ visibles,
+ vec![
+ true, //
+ false, //
+ true, //
+ true,
+ ]
+ );
+ }
+
+ #[test]
+ fn test_expand_with_collapsed_sub_parts() {
+ let items = string_vec_to_status(&[
+ "a/b/c", //
+ "a/d", //
+ ]);
+
+ //0 a/
+ //1 b/
+ //2 c
+ //3 d
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+
+ res.collapse(&String::from("a/b"), 1);
+
+ let visibles = get_visibles(&res);
+
+ assert_eq!(
+ visibles,
+ vec![
+ true, //
+ true, //
+ false, //
+ true,
+ ]
+ );
+
+ res.collapse(&String::from("a"), 0);
+
+ let visibles = get_visibles(&res);
+
+ assert_eq!(
+ visibles,
+ vec![
+ true, //
+ false, //
+ false, //
+ false,
+ ]
+ );
+
+ res.expand(&String::from("a"), 0);
+
+ let visibles = get_visibles(&res);
+
+ assert_eq!(
+ visibles,
+ vec![
+ true, //
+ true, //
+ false, //
+ true,
+ ]
+ );
+ }
+
+ #[test]
+ fn test_selection_skips_collapsed() {
+ let items = string_vec_to_status(&[
+ "a/b/c", //
+ "a/d", //
+ ]);
+
+ //0 a/
+ //1 b/
+ //2 c
+ //3 d
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+ res.collapse(&String::from("a/b"), 1);
+ res.selection = Some(1);
+
+ assert!(res.move_selection(MoveSelection::Down));
+
+ assert_eq!(res.selection, Some(3));
+ }
+
+ #[test]
+ fn test_folders_fold_up_if_alone_in_directory() {
+ let items = string_vec_to_status(&[
+ "a/b/c/d", //
+ "a/e/f/g", //
+ "a/h/i/j", //
+ ]);
+
+ //0 a/
+ //1 b/
+ //2 c/
+ //3 d
+ //4 e/
+ //5 f/
+ //6 g
+ //7 h/
+ //8 i/
+ //9 j
+
+ //0 a/
+ //1 b/c/
+ //3 d
+ //4 e/f/
+ //6 g
+ //7 h/i/
+ //9 j
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+ res.selection = Some(0);
+
+ assert!(res.move_selection(MoveSelection::Down));
+ assert_eq!(res.selection, Some(1));
+
+ assert!(res.move_selection(MoveSelection::Down));
+ assert_eq!(res.selection, Some(3));
+
+ assert!(res.move_selection(MoveSelection::Down));
+ assert_eq!(res.selection, Some(4));
+
+ assert!(res.move_selection(MoveSelection::Down));
+ assert_eq!(res.selection, Some(6));
+
+ assert!(res.move_selection(MoveSelection::Down));
+ assert_eq!(res.selection, Some(7));
+
+ assert!(res.move_selection(MoveSelection::Down));
+ assert_eq!(res.selection, Some(9));
+ }
+
+ #[test]
+ fn test_folders_fold_up_if_alone_in_directory_2() {
+ let items = string_vec_to_status(&["a/b/c/d/e/f/g/h"]);
+
+ //0 a/
+ //1 b/
+ //2 c/
+ //3 d/
+ //4 e/
+ //5 f/
+ //6 g/
+ //7 h
+
+ //0 a/b/c/d/e/f/g/
+ //7 h
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+ res.selection = Some(0);
+
+ assert!(res.move_selection(MoveSelection::Down));
+ assert_eq!(res.selection, Some(7));
+ }
+
+ #[test]
+ fn test_folders_fold_up_down_with_selection_left_right() {
+ let items = string_vec_to_status(&[
+ "a/b/c/d", //
+ "a/e/f/g", //
+ "a/h/i/j", //
+ ]);
+
+ //0 a/
+ //1 b/
+ //2 c/
+ //3 d
+ //4 e/
+ //5 f/
+ //6 g
+ //7 h/
+ //8 i/
+ //9 j
+
+ //0 a/
+ //1 b/c/
+ //3 d
+ //4 e/f/
+ //6 g
+ //7 h/i/
+ //9 j
+
+ let mut res = StatusTree::default();
+ res.update(&items).unwrap();
+ res.selection = Some(0);
+
+ assert!(res.move_selection(MoveSelection::Left));
+ assert_eq!(res.selection, Some(0));
+
+ // These should do nothing
+ res.move_selection(MoveSelection::Left);
+ res.move_selection(MoveSelection::Left);
+ assert_eq!(res.selection, Some(0));
+ //
+ assert!(res.move_selection(MoveSelection::Right)); // unfold 0
+ assert_eq!(res.selection, Some(0));
+
+ assert!(res.move_selection(MoveSelection::Right)); // move to 1
+ assert_eq!(res.selection, Some(1));
+
+ assert!(res.move_selection(MoveSelection::Left)); // fold 1
+ assert!(res.move_selection(MoveSelection::Down)); // move to 4
+ assert_eq!(res.selection, Some(4));
+
+ assert!(res.move_selection(MoveSelection::Left)); // fold 4
+ assert!(res.move_selection(MoveSelection::Down)); // move to 7
+ assert_eq!(res.selection, Some(7));
+
+ assert!(res.move_selection(MoveSelection::Right)); // move to 9
+ assert_eq!(res.selection, Some(9));
+
+ assert!(res.move_selection(MoveSelection::Left)); // move to 7
+ assert_eq!(res.selection, Some(7));
+
+ assert!(res.move_selection(MoveSelection::Left)); // folds 7
+ assert_eq!(res.selection, Some(7));
+
+ assert!(res.move_selection(MoveSelection::Left)); // jump to 0
+ assert_eq!(res.selection, Some(0));
+ }
+}
diff --git a/src/input.rs b/src/input.rs
new file mode 100644
index 0000000..794bb2c
--- /dev/null
+++ b/src/input.rs
@@ -0,0 +1,111 @@
+use crate::notify_mutex::NotifyableMutex;
+use crossbeam_channel::{unbounded, Receiver};
+use crossterm::event::{self, Event};
+use std::{
+ sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+ },
+ thread,
+ time::Duration,
+};
+
+static POLL_DURATION: Duration = Duration::from_millis(1000);
+
+///
+#[derive(Clone, Copy, Debug)]
+pub enum InputState {
+ Paused,
+ Polling,
+}
+
+///
+#[derive(Clone, Copy, Debug)]
+pub enum InputEvent {
+ Input(Event),
+ State(InputState),
+}
+
+///
+pub struct Input {
+ desired_state: Arc<NotifyableMutex<bool>>,
+ current_state: Arc<AtomicBool>,
+ receiver: Receiver<InputEvent>,
+}
+
+impl Input {
+ ///
+ pub fn new() -> Self {
+ let (tx, rx) = unbounded();
+
+ let desired_state = Arc::new(NotifyableMutex::new(true));
+ let current_state = Arc::new(AtomicBool::new(true));
+
+ let arc_desired = Arc::clone(&desired_state);
+ let arc_current = Arc::clone(&current_state);
+
+ thread::spawn(move || loop {
+ if arc_desired.get() {
+ if !arc_current.load(Ordering::Relaxed) {
+ log::info!("input polling resumed");
+
+ tx.send(InputEvent::State(InputState::Polling))
+ .expect("send state failed");
+ }
+ arc_current.store(true, Ordering::Relaxed);
+
+ if let Some(e) = Self::poll(POLL_DURATION)
+ .expect("failed to pull events.")
+ {
+ tx.send(InputEvent::Input(e))
+ .expect("send input failed");
+ }
+ } else {
+ if arc_current.load(Ordering::Relaxed) {
+ log::info!("input polling suspended");
+
+ tx.send(InputEvent::State(InputState::Paused))
+ .expect("send state failed");
+ }
+
+ arc_current.store(false, Ordering::Relaxed);
+
+ arc_desired.wait(true);
+ }
+ });
+
+ Self {
+ receiver: rx,
+ desired_state,
+ current_state,
+ }
+ }
+
+ ///
+ pub fn receiver(&self) -> Receiver<InputEvent> {
+ self.receiver.clone()
+ }
+
+ ///
+ pub fn set_polling(&mut self, enabled: bool) {
+ self.desired_state.set_and_notify(enabled)
+ }
+
+ fn shall_poll(&self) -> bool {
+ self.desired_state.get()
+ }
+
+ ///
+ pub fn is_state_changing(&self) -> bool {
+ self.shall_poll()
+ != self.current_state.load(Ordering::Relaxed)
+ }
+
+ fn poll(dur: Duration) -> anyhow::Result<Option<Event>> {
+ if event::poll(dur)? {
+ Ok(Some(event::read()?))
+ } else {
+ Ok(None)
+ }
+ }
+}
diff --git a/src/keys.rs b/src/keys.rs
new file mode 100644
index 0000000..6bdc1f1
--- /dev/null
+++ b/src/keys.rs
@@ -0,0 +1,266 @@
+use crate::get_app_config_path;
+use anyhow::Result;
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use ron::{
+ de::from_bytes,
+ ser::{to_string_pretty, PrettyConfig},
+};
+use serde::{Deserialize, Serialize};
+use std::{
+ fs::File,
+ io::{Read, Write},
+ path::PathBuf,
+ rc::Rc,
+};
+
+pub type SharedKeyConfig = Rc<KeyConfig>;
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct KeyConfig {
+ pub tab_status: KeyEvent,
+ pub tab_log: KeyEvent,
+ pub tab_stashing: KeyEvent,
+ pub tab_stashes: KeyEvent,
+ pub tab_toggle: KeyEvent,
+ pub tab_toggle_reverse: KeyEvent,
+ pub focus_workdir: KeyEvent,
+ pub focus_stage: KeyEvent,
+ pub focus_right: KeyEvent,
+ pub focus_left: KeyEvent,
+ pub focus_above: KeyEvent,
+ pub focus_below: KeyEvent,
+ pub exit: KeyEvent,
+ pub exit_popup: KeyEvent,
+ pub open_commit: KeyEvent,
+ pub open_commit_editor: KeyEvent,
+ pub open_help: KeyEvent,
+ pub move_left: KeyEvent,
+ pub move_right: KeyEvent,
+ pub home: KeyEvent,
+ pub end: KeyEvent,
+ pub move_up: KeyEvent,
+ pub move_down: KeyEvent,
+ pub page_down: KeyEvent,
+ pub page_up: KeyEvent,
+ pub shift_up: KeyEvent,
+ pub shift_down: KeyEvent,
+ pub enter: KeyEvent,
+ pub edit_file: KeyEvent,
+ pub status_stage_all: KeyEvent,
+ pub status_reset_item: KeyEvent,
+ pub status_ignore_file: KeyEvent,
+ pub stashing_save: KeyEvent,
+ pub stashing_toggle_untracked: KeyEvent,
+ pub stashing_toggle_index: KeyEvent,
+ pub stash_open: KeyEvent,
+ pub stash_drop: KeyEvent,
+ pub cmd_bar_toggle: KeyEvent,
+ pub log_tag_commit: KeyEvent,
+ pub commit_amend: KeyEvent,
+ pub copy: KeyEvent,
+ pub create_branch: KeyEvent,
+ pub rename_branch: KeyEvent,
+ pub select_branch: KeyEvent,
+ pub delete_branch: KeyEvent,
+ pub push: KeyEvent,
+ pub fetch: KeyEvent,
+}
+
+#[rustfmt::skip]
+impl Default for KeyConfig {
+ fn default() -> Self {
+ Self {
+ tab_status: KeyEvent { code: KeyCode::Char('1'), modifiers: KeyModifiers::empty()},
+ tab_log: KeyEvent { code: KeyCode::Char('2'), modifiers: KeyModifiers::empty()},
+ tab_stashing: KeyEvent { code: KeyCode::Char('3'), modifiers: KeyModifiers::empty()},
+ tab_stashes: KeyEvent { code: KeyCode::Char('4'), modifiers: KeyModifiers::empty()},
+ tab_toggle: KeyEvent { code: KeyCode::Tab, modifiers: KeyModifiers::empty()},
+ tab_toggle_reverse: KeyEvent { code: KeyCode::BackTab, modifiers: KeyModifiers::SHIFT},
+ focus_workdir: KeyEvent { code: KeyCode::Char('w'), modifiers: KeyModifiers::empty()},
+ focus_stage: KeyEvent { code: KeyCode::Char('s'), modifiers: KeyModifiers::empty()},
+ focus_right: KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::empty()},
+ focus_left: KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::empty()},
+ focus_above: KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::empty()},
+ focus_below: KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::empty()},
+ exit: KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL},
+ exit_popup: KeyEvent { code: KeyCode::Esc, modifiers: KeyModifiers::empty()},
+ open_commit: KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::empty()},
+ open_commit_editor: KeyEvent { code: KeyCode::Char('e'), modifiers:KeyModifiers::CONTROL},
+ open_help: KeyEvent { code: KeyCode::Char('h'), modifiers: KeyModifiers::empty()},
+ move_left: KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::empty()},
+ move_right: KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::empty()},
+ home: KeyEvent { code: KeyCode::Home, modifiers: KeyModifiers::empty()},
+ end: KeyEvent { code: KeyCode::End, modifiers: KeyModifiers::empty()},
+ move_up: KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::empty()},
+ move_down: KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::empty()},
+ page_down: KeyEvent { code: KeyCode::PageDown, modifiers: KeyModifiers::empty()},
+ page_up: KeyEvent { code: KeyCode::PageUp, modifiers: KeyModifiers::empty()},
+ shift_up: KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::SHIFT},
+ shift_down: KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::SHIFT},
+ enter: KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::empty()},
+ edit_file: KeyEvent { code: KeyCode::Char('e'), modifiers: KeyModifiers::empty()},
+ status_stage_all: KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::empty()},
+ status_reset_item: KeyEvent { code: KeyCode::Char('D'), modifiers: KeyModifiers::SHIFT},
+ status_ignore_file: KeyEvent { code: KeyCode::Char('i'), modifiers: KeyModifiers::empty()},
+ stashing_save: KeyEvent { code: KeyCode::Char('s'), modifiers: KeyModifiers::empty()},
+ stashing_toggle_untracked: KeyEvent { code: KeyCode::Char('u'), modifiers: KeyModifiers::empty()},
+ stashing_toggle_index: KeyEvent { code: KeyCode::Char('i'), modifiers: KeyModifiers::empty()},
+ stash_open: KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::empty()},
+ stash_drop: KeyEvent { code: KeyCode::Char('D'), modifiers: KeyModifiers::SHIFT},
+ cmd_bar_toggle: KeyEvent { code: KeyCode::Char('.'), modifiers: KeyModifiers::empty()},
+ log_tag_commit: KeyEvent { code: KeyCode::Char('t'), modifiers: KeyModifiers::empty()},
+ commit_amend: KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::CONTROL},
+ copy: KeyEvent { code: KeyCode::Char('y'), modifiers: KeyModifiers::empty()},
+ create_branch: KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::NONE},
+ rename_branch: KeyEvent { code: KeyCode::Char('r'), modifiers: KeyModifiers::NONE},
+ select_branch: KeyEvent { code: KeyCode::Char('b'), modifiers: KeyModifiers::NONE},
+ delete_branch: KeyEvent{code: KeyCode::Char('D'), modifiers: KeyModifiers::SHIFT},
+ push: KeyEvent { code: KeyCode::Char('p'), modifiers: KeyModifiers::empty()},
+ fetch: KeyEvent { code: KeyCode::Char('f'), modifiers: KeyModifiers::empty()},
+ }
+ }
+}
+impl KeyConfig {
+ fn save(&self) -> Result<()> {
+ let config_file = Self::get_config_file()?;
+ let mut file = File::create(config_file)?;
+ let data = to_string_pretty(self, PrettyConfig::default())?;
+ file.write_all(data.as_bytes())?;
+ Ok(())
+ }
+
+ fn get_config_file() -> Result<PathBuf> {
+ let app_home = get_app_config_path()?;
+ Ok(app_home.join("key_config.ron"))
+ }
+
+ fn read_file(config_file: PathBuf) -> Result<Self> {
+ let mut f = File::open(config_file)?;
+ let mut buffer = Vec::new();
+ f.read_to_end(&mut buffer)?;
+ Ok(from_bytes(&buffer)?)
+ }
+
+ fn init_internal() -> Result<Self> {
+ let file = Self::get_config_file()?;
+ if file.exists() {
+ Ok(Self::read_file(file)?)
+ } else {
+ let def = Self::default();
+ if def.save().is_err() {
+ log::warn!(
+ "failed to store default key config to disk."
+ )
+ }
+ Ok(def)
+ }
+ }
+
+ pub fn init() -> Self {
+ match Self::init_internal() {
+ Ok(v) => v,
+ Err(e) => {
+ log::error!("failed loading key binding: {}", e);
+ Self::default()
+ }
+ }
+ }
+}
+
+// The hint follows apple design
+// http://xahlee.info/comp/unicode_computing_symbols.html
+pub fn get_hint(ev: KeyEvent) -> String {
+ match ev.code {
+ KeyCode::Char(c) => {
+ format!("{}{}", get_modifier_hint(ev.modifiers), c)
+ }
+ KeyCode::Enter => {
+ format!("{}\u{23ce}", get_modifier_hint(ev.modifiers)) //⏎
+ }
+ KeyCode::Left => {
+ format!("{}\u{2190}", get_modifier_hint(ev.modifiers)) //←
+ }
+ KeyCode::Right => {
+ format!("{}\u{2192}", get_modifier_hint(ev.modifiers)) //→
+ }
+ KeyCode::Up => {
+ format!("{}\u{2191}", get_modifier_hint(ev.modifiers)) //↑
+ }
+ KeyCode::Down => {
+ format!("{}\u{2193}", get_modifier_hint(ev.modifiers)) //↓
+ }
+ KeyCode::Backspace => {
+ format!("{}\u{232b}", get_modifier_hint(ev.modifiers)) //⌫
+ }
+ KeyCode::Home => {
+ format!("{}\u{2912}", get_modifier_hint(ev.modifiers)) //⤒
+ }
+ KeyCode::End => {
+ format!("{}\u{2913}", get_modifier_hint(ev.modifiers)) //⤓
+ }
+ KeyCode::PageUp => {
+ format!("{}\u{21de}", get_modifier_hint(ev.modifiers)) //⇞
+ }
+ KeyCode::PageDown => {
+ format!("{}\u{21df}", get_modifier_hint(ev.modifiers)) //⇟
+ }
+ KeyCode::Tab => {
+ format!("{}\u{21e5}", get_modifier_hint(ev.modifiers)) //⇥
+ }
+ KeyCode::BackTab => {
+ format!("{}\u{21e4}", get_modifier_hint(ev.modifiers)) //⇤
+ }
+ KeyCode::Delete => {
+ format!("{}\u{2326}", get_modifier_hint(ev.modifiers)) //⌦
+ }
+ KeyCode::Insert => {
+ format!("{}\u{2380}", get_modifier_hint(ev.modifiers)) //⎀
+ }
+ KeyCode::Esc => {
+ format!("{}\u{238b}", get_modifier_hint(ev.modifiers)) //⎋
+ }
+ KeyCode::F(u) => {
+ format!("{}F{}", get_modifier_hint(ev.modifiers), u)
+ }
+ KeyCode::Null => get_modifier_hint(ev.modifiers),
+ }
+}
+
+fn get_modifier_hint(modifier: KeyModifiers) -> String {
+ match modifier {
+ KeyModifiers::CONTROL => "^".to_string(),
+ KeyModifiers::SHIFT => {
+ "\u{21e7}".to_string() //⇧
+ }
+ KeyModifiers::ALT => {
+ "\u{2325}".to_string() //⌥
+ }
+ _ => String::new(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{get_hint, KeyConfig};
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ #[test]
+ fn test_get_hint() {
+ let h = get_hint(KeyEvent {
+ code: KeyCode::Char('c'),
+ modifiers: KeyModifiers::CONTROL,
+ });
+ assert_eq!(h, "^c");
+ }
+
+ #[test]
+ fn test_load_vim_style_example() {
+ assert_eq!(
+ KeyConfig::read_file(
+ "assets/vim_style_key_config.ron".into()
+ )
+ .is_ok(),
+ true
+ );
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..560d52e
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,320 @@
+#![forbid(unsafe_code)]
+#![deny(unused_imports)]
+#![deny(clippy::cargo)]
+#![deny(clippy::pedantic)]
+#![deny(clippy::perf)]
+#![deny(clippy::nursery)]
+#![deny(clippy::unwrap_used)]
+#![deny(clippy::panic)]
+#![deny(clippy::match_like_matches_macro)]
+#![allow(clippy::module_name_repetitions)]
+#![allow(clippy::multiple_crate_versions)]
+
+mod app;
+mod clipboard;
+mod cmdbar;
+mod components;
+mod input;
+mod keys;
+mod notify_mutex;
+mod profiler;
+mod queue;
+mod spinner;
+mod strings;
+mod tabs;
+mod ui;
+mod version;
+
+use crate::app::App;
+use anyhow::{anyhow, bail, Result};
+use asyncgit::AsyncNotification;
+use backtrace::Backtrace;
+use clap::{
+ crate_authors, crate_description, crate_name, crate_version,
+ App as ClapApp, Arg,
+};
+use crossbeam_channel::{tick, unbounded, Receiver, Select};
+use crossterm::{
+ terminal::{
+ disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
+ LeaveAlternateScreen,
+ },
+ ExecutableCommand,
+};
+use input::{Input, InputEvent, InputState};
+use profiler::Profiler;
+use scopeguard::defer;
+use scopetime::scope_time;
+use simplelog::{Config, LevelFilter, WriteLogger};
+use spinner::Spinner;
+use std::{
+ env, fs,
+ fs::File,
+ io::{self, Write},
+ panic,
+ path::PathBuf,
+ process,
+ time::{Duration, Instant},
+};
+use tui::{
+ backend::{Backend, CrosstermBackend},
+ Terminal,
+};
+
+static TICK_INTERVAL: Duration = Duration::from_secs(5);
+static SPINNER_INTERVAL: Duration = Duration::from_millis(80);
+
+///
+#[derive(Clone, Copy)]
+pub enum QueueEvent {
+ Tick,
+ SpinnerUpdate,
+ GitEvent(AsyncNotification),
+ InputEvent(InputEvent),
+}
+
+fn main() -> Result<()> {
+ process_cmdline()?;
+
+ let _profiler = Profiler::new();
+
+ if !valid_path()? {
+ eprintln!("invalid path\nplease run gitui inside of a non-bare git repository");
+ return Ok(());
+ }
+
+ setup_terminal()?;
+ defer! {
+ shutdown_terminal().expect("shutdown failed");
+ }
+
+ set_panic_handlers()?;
+
+ let mut terminal = start_terminal(io::stdout())?;
+
+ let (tx_git, rx_git) = unbounded();
+
+ let input = Input::new();
+
+ let rx_input = input.receiver();
+ let ticker = tick(TICK_INTERVAL);
+ let spinner_ticker = tick(SPINNER_INTERVAL);
+
+ let mut app = App::new(&tx_git, input);
+
+ let mut spinner = Spinner::default();
+ let mut first_update = true;
+
+ loop {
+ let event = if first_update {
+ first_update = false;
+ QueueEvent::Tick
+ } else {
+ select_event(
+ &rx_input,
+ &rx_git,
+ &ticker,
+ &spinner_ticker,
+ )?
+ };
+
+ {
+ if let QueueEvent::SpinnerUpdate = event {
+ spinner.update();
+ spinner.draw(&mut terminal)?;
+ continue;
+ }
+
+ scope_time!("loop");
+
+ match event {
+ QueueEvent::InputEvent(ev) => {
+ if let InputEvent::State(InputState::Polling) = ev
+ {
+ //Note: external ed closed, we need to re-hide cursor
+ terminal.hide_cursor()?;
+ }
+ app.event(ev)?
+ }
+ QueueEvent::Tick => app.update()?,
+ QueueEvent::GitEvent(ev)
+ if ev != AsyncNotification::FinishUnchanged =>
+ {
+ app.update_git(ev)?
+ }
+ QueueEvent::GitEvent(..) => (),
+ QueueEvent::SpinnerUpdate => unreachable!(),
+ }
+
+ draw(&mut terminal, &app)?;
+
+ spinner.set_state(app.any_work_pending());
+ spinner.draw(&mut terminal)?;
+
+ if app.is_quit() {
+ break;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn setup_terminal() -> Result<()> {
+ enable_raw_mode()?;
+ io::stdout().execute(EnterAlternateScreen)?;
+ Ok(())
+}
+
+fn shutdown_terminal() -> Result<()> {
+ io::stdout().execute(LeaveAlternateScreen)?;
+ disable_raw_mode()?;
+ Ok(())
+}
+
+fn draw<B: Backend>(
+ terminal: &mut Terminal<B>,
+ app: &App,
+) -> io::Result<()> {
+ if app.requires_redraw() {
+ terminal.resize(terminal.size()?)?;
+ }
+
+ terminal.draw(|mut f| {
+ if let Err(e) = app.draw(&mut f) {
+ log::error!("failed to draw: {:?}", e)
+ }
+ })
+}
+
+fn valid_path() -> Result<bool> {
+ Ok(asyncgit::sync::is_repo(asyncgit::CWD)
+ && !asyncgit::sync::is_bare_repo(asyncgit::CWD)?)
+}
+
+fn select_event(
+ rx_input: &Receiver<InputEvent>,
+ rx_git: &Receiver<AsyncNotification>,
+ rx_ticker: &Receiver<Instant>,
+ rx_spinner: &Receiver<Instant>,
+) -> Result<QueueEvent> {
+ let mut sel = Select::new();
+
+ sel.recv(rx_input);
+ sel.recv(rx_git);
+ sel.recv(rx_ticker);
+ sel.recv(rx_spinner);
+
+ let oper = sel.select();
+ let index = oper.index();
+
+ let ev = match index {
+ 0 => oper.recv(rx_input).map(QueueEvent::InputEvent),
+ 1 => oper.recv(rx_git).map(QueueEvent::GitEvent),
+ 2 => oper.recv(rx_ticker).map(|_| QueueEvent::Tick),
+ 3 => oper.recv(rx_spinner).map(|_| QueueEvent::SpinnerUpdate),
+ _ => bail!("unknown select source"),
+ }?;
+
+ Ok(ev)
+}
+
+fn start_terminal<W: Write>(
+ buf: W,
+) -> io::Result<Terminal<CrosstermBackend<W>>> {
+ let backend = CrosstermBackend::new(buf);
+ let mut terminal = Terminal::new(backend)?;
+ terminal.hide_cursor()?;
+ terminal.clear()?;
+
+ Ok(terminal)
+}
+
+fn get_app_cache_path() -> Result<PathBuf> {
+ let mut path = dirs_next::cache_dir()
+ .ok_or_else(|| anyhow!("failed to find os cache dir."))?;
+
+ path.push("gitui");
+ fs::create_dir_all(&path)?;
+ Ok(path)
+}
+
+fn get_app_config_path() -> Result<PathBuf> {
+ let mut path = dirs_next::config_dir()
+ .ok_or_else(|| anyhow!("failed to find os config dir."))?;
+
+ path.push("gitui");
+ fs::create_dir_all(&path)?;
+ Ok(path)
+}
+
+fn setup_logging() -> Result<()> {
+ let mut path = get_app_cache_path()?;
+ path.push("gitui.log");
+
+ let _ = WriteLogger::init(
+ LevelFilter::Trace,
+ Config::default(),
+ File::create(path)?,
+ );
+
+ Ok(())
+}
+
+fn process_cmdline() -> Result<()> {
+ let app = ClapApp::new(crate_name!())
+ .author(crate_authors!())
+ .version(crate_version!())
+ .about(crate_description!())
+ .arg(
+ Arg::with_name("logging")
+ .help("Stores logging output into a cache directory")
+ .short("l")
+ .long("logging"),
+ )
+ .arg(
+ Arg::with_name("directory")
+ .help("Set the working directory")
+ .short("d")
+ .long("directory")
+ .takes_value(true),
+ );
+
+ let arg_matches = app.get_matches();
+ if arg_matches.is_present("logging") {
+ setup_logging()?;
+ }
+
+ if arg_matches.is_present("directory") {
+ let directory =
+ arg_matches.value_of("directory").unwrap_or(".");
+ env::set_current_dir(directory)?;
+ }
+
+ Ok(())
+}
+
+fn set_panic_handlers() -> Result<()> {
+ // regular panic handler
+ panic::set_hook(Box::new(|e| {
+ let backtrace = Backtrace::new();
+ log::error!("panic: {:?}\ntrace:\n{:?}", e, backtrace);
+ shutdown_terminal().expect("shutdown failed inside panic");
+ eprintln!("panic: {:?}\ntrace:\n{:?}", e, backtrace);
+ }));
+
+ // global threadpool
+ rayon_core::ThreadPoolBuilder::new()
+ .panic_handler(|e| {
+ let backtrace = Backtrace::new();
+ log::error!("panic: {:?}\ntrace:\n{:?}", e, backtrace);
+ shutdown_terminal()
+ .expect("shutdown failed inside panic");
+ eprintln!("panic: {:?}\ntrace:\n{:?}", e, backtrace);
+ process::abort();
+ })
+ .num_threads(4)
+ .build_global()?;
+
+ Ok(())
+}
diff --git a/src/notify_mutex.rs b/src/notify_mutex.rs
new file mode 100644
index 0000000..aacf603
--- /dev/null
+++ b/src/notify_mutex.rs
@@ -0,0 +1,42 @@
+use std::sync::{Arc, Condvar, Mutex};
+
+/// combines a `Mutex` and `Condvar` to allow waiting for a change in the variable protected by the `Mutex`
+#[derive(Clone, Debug)]
+pub struct NotifyableMutex<T> {
+ data: Arc<(Mutex<T>, Condvar)>,
+}
+
+impl<T> NotifyableMutex<T> {
+ ///
+ pub fn new(start_value: T) -> Self {
+ Self {
+ data: Arc::new((Mutex::new(start_value), Condvar::new())),
+ }
+ }
+
+ ///
+ #[allow(clippy::needless_pass_by_value)]
+ pub fn wait(&self, condition: T)
+ where
+ T: PartialEq,
+ {
+ let mut data = self.data.0.lock().expect("lock err");
+ while *data != condition {
+ data = self.data.1.wait(data).expect("wait err");
+ }
+ }
+
+ ///
+ pub fn set_and_notify(&self, value: T) {
+ *self.data.0.lock().expect("set err") = value;
+ self.data.1.notify_one();
+ }
+
+ ///
+ pub fn get(&self) -> T
+ where
+ T: Copy,
+ {
+ *self.data.0.lock().expect("get err")
+ }
+}
diff --git a/src/profiler.rs b/src/profiler.rs
new file mode 100644
index 0000000..7527839
--- /dev/null
+++ b/src/profiler.rs
@@ -0,0 +1,39 @@
+/// helper struct to not pollute main with feature flags shenanigans for the profiler
+/// also we make sure to generate a flamegraph on program exit
+pub struct Profiler {
+ #[cfg(feature = "pprof")]
+ #[cfg(not(windows))]
+ guard: pprof::ProfilerGuard<'static>,
+}
+
+impl Profiler {
+ #[allow(clippy::missing_const_for_fn)]
+ pub fn new() -> Self {
+ Self {
+ #[cfg(feature = "pprof")]
+ #[cfg(not(windows))]
+ guard: pprof::ProfilerGuard::new(100)
+ .expect("profiler launch error"),
+ }
+ }
+
+ #[allow(clippy::unused_self)]
+ fn report(&mut self) {
+ #[cfg(feature = "pprof")]
+ #[cfg(not(windows))]
+ if let Ok(report) = self.guard.report().build() {
+ let file = std::fs::File::create("flamegraph.svg")
+ .expect("flamegraph file err");
+
+ report.flamegraph(&file).expect("flamegraph write err");
+
+ log::info!("profiler reported");
+ }
+ }
+}
+
+impl Drop for Profiler {
+ fn drop(&mut self) {
+ self.report();
+ }
+}
diff --git a/src/queue.rs b/src/queue.rs
new file mode 100644
index 0000000..678baf6
--- /dev/null
+++ b/src/queue.rs
@@ -0,0 +1,67 @@
+use crate::tabs::StashingOptions;
+use asyncgit::sync::{CommitId, CommitTags};
+use bitflags::bitflags;
+use std::{cell::RefCell, collections::VecDeque, rc::Rc};
+
+bitflags! {
+ /// flags defining what part of the app need to update
+ pub struct NeedsUpdate: u32 {
+ /// app::update
+ const ALL = 0b001;
+ /// diff may have changed (app::update_diff)
+ const DIFF = 0b010;
+ /// commands might need updating (app::update_commands)
+ const COMMANDS = 0b100;
+ }
+}
+
+/// data of item that is supposed to be reset
+pub struct ResetItem {
+ /// path to the item (folder/file)
+ pub path: String,
+ /// are talking about a folder here? otherwise it's a single file
+ pub is_folder: bool,
+}
+
+///
+pub enum Action {
+ Reset(ResetItem),
+ ResetHunk(String, u64),
+ StashDrop(CommitId),
+ DeleteBranch(String),
+}
+
+///
+pub enum InternalEvent {
+ ///
+ ConfirmAction(Action),
+ ///
+ ConfirmedAction(Action),
+ ///
+ ShowErrorMsg(String),
+ ///
+ Update(NeedsUpdate),
+ /// open commit msg input
+ OpenCommit,
+ ///
+ PopupStashing(StashingOptions),
+ ///
+ TabSwitch,
+ ///
+ InspectCommit(CommitId, Option<CommitTags>),
+ ///
+ TagCommit(CommitId),
+ ///
+ CreateBranch,
+ ///
+ RenameBranch(String, String),
+ ///
+ SelectBranch,
+ ///
+ OpenExternalEditor(Option<String>),
+ ///
+ Push(String),
+}
+
+///
+pub type Queue = Rc<RefCell<VecDeque<InternalEvent>>>;
diff --git a/src/spinner.rs b/src/spinner.rs
new file mode 100644
index 0000000..f0e994c
--- /dev/null
+++ b/src/spinner.rs
@@ -0,0 +1,49 @@
+use std::io;
+use tui::{backend::Backend, buffer::Cell, Terminal};
+
+// static SPINNER_CHARS: &[char] = &['◢', '◣', '◤', '◥'];
+// static SPINNER_CHARS: &[char] = &['⢹', '⢺', '⢼', '⣸', '⣇', '⡧', '⡗', '⡏'];
+static SPINNER_CHARS: &[char] =
+ &['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'];
+
+///
+#[derive(Default)]
+pub struct Spinner {
+ idx: usize,
+ pending: bool,
+}
+
+impl Spinner {
+ /// increment spinner graphic by one
+ pub fn update(&mut self) {
+ self.idx += 1;
+ self.idx %= SPINNER_CHARS.len();
+ }
+
+ ///
+ pub fn set_state(&mut self, pending: bool) {
+ self.pending = pending;
+ }
+
+ /// draws or removes spinner char depending on `pending` state
+ pub fn draw<B: Backend>(
+ &self,
+ terminal: &mut Terminal<B>,
+ ) -> io::Result<()> {
+ let idx = self.idx;
+
+ let c: Cell = Cell::default()
+ .set_char(if self.pending {
+ SPINNER_CHARS[idx]
+ } else {
+ ' '
+ })
+ .clone();
+ terminal
+ .backend_mut()
+ .draw(vec![(0_u16, 0_u16, &c)].into_iter())?;
+ tui::backend::Backend::flush(terminal.backend_mut())?;
+
+ Ok(())
+ }
+}
diff --git a/src/strings.rs b/src/strings.rs
new file mode 100644
index 0000000..99c37a3
--- /dev/null
+++ b/src/strings.rs
@@ -0,0 +1,709 @@
+use crate::keys::{get_hint, SharedKeyConfig};
+
+pub mod order {
+ pub static NAV: i8 = 1;
+}
+
+pub static PUSH_POPUP_MSG: &str = "Push";
+pub static PUSH_POPUP_PROGRESS_NONE: &str = "preparing...";
+pub static PUSH_POPUP_STATES_ADDING: &str = "adding objects (1/3)";
+pub static PUSH_POPUP_STATES_DELTAS: &str = "deltas (2/3)";
+pub static PUSH_POPUP_STATES_PUSHING: &str = "pushing (3/3)";
+
+pub static SELECT_BRANCH_POPUP_MSG: &str = "Switch Branch";
+
+pub fn title_status(key_config: &SharedKeyConfig) -> String {
+ format!(
+ "Unstaged Changes [{}]",
+ get_hint(key_config.focus_workdir)
+ )
+}
+pub fn title_diff(_key_config: &SharedKeyConfig) -> String {
+ "Diff: ".to_string()
+}
+pub fn title_index(key_config: &SharedKeyConfig) -> String {
+ format!("Staged Changes [{}]", get_hint(key_config.focus_stage))
+}
+pub fn tab_status(key_config: &SharedKeyConfig) -> String {
+ format!("Status [{}]", get_hint(key_config.tab_status))
+}
+pub fn tab_log(key_config: &SharedKeyConfig) -> String {
+ format!("Log [{}]", get_hint(key_config.tab_log))
+}
+pub fn tab_stashing(key_config: &SharedKeyConfig) -> String {
+ format!("Stashing [{}]", get_hint(key_config.tab_stashing))
+}
+pub fn tab_stashes(key_config: &SharedKeyConfig) -> String {
+ format!("Stashes [{}]", get_hint(key_config.tab_stashes))
+}
+pub fn tab_divider(_key_config: &SharedKeyConfig) -> String {
+ " | ".to_string()
+}
+pub fn cmd_splitter(_key_config: &SharedKeyConfig) -> String {
+ " ".to_string()
+}
+pub fn msg_opening_editor(_key_config: &SharedKeyConfig) -> String {
+ "opening editor...".to_string()
+}
+pub fn msg_title_error(_key_config: &SharedKeyConfig) -> String {
+ "Error".to_string()
+}
+pub fn commit_title(_key_config: &SharedKeyConfig) -> String {
+ "Commit".to_string()
+}
+pub fn commit_title_amend(_key_config: &SharedKeyConfig) -> String {
+ "Commit (Amend)".to_string()
+}
+pub fn commit_msg(_key_config: &SharedKeyConfig) -> String {
+ "type commit message..".to_string()
+}
+pub fn commit_editor_msg(_key_config: &SharedKeyConfig) -> String {
+ r##"
+# Edit your commit message
+# Lines starting with '#' will be ignored"##
+ .to_string()
+}
+pub fn stash_popup_title(_key_config: &SharedKeyConfig) -> String {
+ "Stash".to_string()
+}
+pub fn stash_popup_msg(_key_config: &SharedKeyConfig) -> String {
+ "type name (optional)".to_string()
+}
+pub fn confirm_title_reset(_key_config: &SharedKeyConfig) -> String {
+ "Reset".to_string()
+}
+pub fn confirm_title_stashdrop(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "Drop".to_string()
+}
+pub fn confirm_msg_reset(_key_config: &SharedKeyConfig) -> String {
+ "confirm file reset?".to_string()
+}
+pub fn confirm_msg_stashdrop(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "confirm stash drop?".to_string()
+}
+pub fn confirm_msg_resethunk(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "confirm reset hunk?".to_string()
+}
+pub fn confirm_title_delete_branch(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "Delete Branch".to_string()
+}
+pub fn confirm_msg_delete_branch(
+ _key_config: &SharedKeyConfig,
+ branch_ref: &str,
+) -> String {
+ format!("Confirm deleting branch: '{}' ?", branch_ref)
+}
+pub fn log_title(_key_config: &SharedKeyConfig) -> String {
+ "Commit".to_string()
+}
+pub fn tag_commit_popup_title(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "Tag".to_string()
+}
+pub fn tag_commit_popup_msg(_key_config: &SharedKeyConfig) -> String {
+ "type tag".to_string()
+}
+pub fn stashlist_title(_key_config: &SharedKeyConfig) -> String {
+ "Stashes".to_string()
+}
+pub fn help_title(_key_config: &SharedKeyConfig) -> String {
+ "Help: all commands".to_string()
+}
+pub fn stashing_files_title(_key_config: &SharedKeyConfig) -> String {
+ "Files to Stash".to_string()
+}
+pub fn stashing_options_title(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "Options".to_string()
+}
+pub fn loading_text(_key_config: &SharedKeyConfig) -> String {
+ "Loading ...".to_string()
+}
+pub fn create_branch_popup_title(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "Branch".to_string()
+}
+pub fn create_branch_popup_msg(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "type branch name".to_string()
+}
+pub fn username_popup_title(_key_config: &SharedKeyConfig) -> String {
+ "Username".to_string()
+}
+pub fn username_popup_msg(_key_config: &SharedKeyConfig) -> String {
+ "type username".to_string()
+}
+pub fn password_popup_title(_key_config: &SharedKeyConfig) -> String {
+ "Password".to_string()
+}
+pub fn password_popup_msg(_key_config: &SharedKeyConfig) -> String {
+ "type password".to_string()
+}
+
+pub fn rename_branch_popup_title(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "Rename Branch".to_string()
+}
+pub fn rename_branch_popup_msg(
+ _key_config: &SharedKeyConfig,
+) -> String {
+ "new branch name".to_string()
+}
+
+pub mod commit {
+ use crate::keys::SharedKeyConfig;
+ pub fn details_author(_key_config: &SharedKeyConfig) -> String {
+ "Author: ".to_string()
+ }
+ pub fn details_committer(
+ _key_config: &SharedKeyConfig,
+ ) -> String {
+ "Committer: ".to_string()
+ }
+ pub fn details_sha(_key_config: &SharedKeyConfig) -> String {
+ "Sha: ".to_string()
+ }
+ pub fn details_date(_key_config: &SharedKeyConfig) -> String {
+ "Date: ".to_string()
+ }
+ pub fn details_tags(_key_config: &SharedKeyConfig) -> String {
+ "Tags: ".to_string()
+ }
+ pub fn details_info_title(
+ _key_config: &SharedKeyConfig,
+ ) -> String {
+ "Info".to_string()
+ }
+ pub fn details_message_title(
+ _key_config: &SharedKeyConfig,
+ ) -> String {
+ "Message".to_string()
+ }
+ pub fn details_files_title(
+ _key_config: &SharedKeyConfig,
+ ) -> String {
+ "Files:".to_string()
+ }
+}
+
+pub mod commands {
+ use crate::components::CommandText;
+ use crate::keys::{get_hint, SharedKeyConfig};
+
+ static CMD_GROUP_GENERAL: &str = "-- General --";
+ static CMD_GROUP_DIFF: &str = "-- Diff --";
+ static CMD_GROUP_CHANGES: &str = "-- Changes --";
+ static CMD_GROUP_COMMIT: &str = "-- Commit --";
+ static CMD_GROUP_STASHING: &str = "-- Stashing --";
+ static CMD_GROUP_STASHES: &str = "-- Stashes --";
+ static CMD_GROUP_LOG: &str = "-- Log --";
+
+ pub fn toggle_tabs(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Next [{}]", get_hint(key_config.tab_toggle)),
+ "switch to next tab",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn toggle_tabs_direct(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Tab [{}{}{}{}]",
+ get_hint(key_config.tab_status),
+ get_hint(key_config.tab_log),
+ get_hint(key_config.tab_stashing),
+ get_hint(key_config.tab_stashes),
+ ),
+ "switch top level tabs directly",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn help_open(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Help [{}]", get_hint(key_config.open_help)),
+ "open this help screen",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn navigate_commit_message(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Nav [{}{}]",
+ get_hint(key_config.move_up),
+ get_hint(key_config.move_down)
+ ),
+ "navigate commit message",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn navigate_tree(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Nav [{}{}{}{}]",
+ get_hint(key_config.move_up),
+ get_hint(key_config.move_down),
+ get_hint(key_config.move_right),
+ get_hint(key_config.move_left)
+ ),
+ "navigate tree view",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn scroll(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!(
+ "Scroll [{}{}]",
+ get_hint(key_config.focus_above),
+ get_hint(key_config.focus_below)
+ ),
+ "scroll up or down in focused view",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn copy(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Copy [{}]", get_hint(key_config.copy),),
+ "copy selected lines to clipboard",
+ CMD_GROUP_DIFF,
+ )
+ }
+ pub fn copy_hash(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Copy Hash [{}]", get_hint(key_config.copy),),
+ "copy selected commit hash to clipboard",
+ CMD_GROUP_DIFF,
+ )
+ }
+ pub fn diff_home_end(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Jump up/down [{},{},{},{}]",
+ get_hint(key_config.home),
+ get_hint(key_config.end),
+ get_hint(key_config.move_up),
+ get_hint(key_config.move_down)
+ ),
+ "scroll to top or bottom of diff",
+ CMD_GROUP_DIFF,
+ )
+ }
+ pub fn diff_hunk_add(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Add hunk [{}]", get_hint(key_config.enter),),
+ "adds selected hunk to stage",
+ CMD_GROUP_DIFF,
+ )
+ }
+ pub fn diff_hunk_revert(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Revert hunk [{}]",
+ get_hint(key_config.status_reset_item),
+ ),
+ "reverts selected hunk",
+ CMD_GROUP_DIFF,
+ )
+ }
+ pub fn diff_hunk_remove(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Remove hunk [{}]", get_hint(key_config.enter),),
+ "removes selected hunk from stage",
+ CMD_GROUP_DIFF,
+ )
+ }
+ pub fn close_popup(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Close [{}]", get_hint(key_config.exit_popup),),
+ "close overlay (e.g commit, help)",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn close_msg(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Close [{}]", get_hint(key_config.enter),),
+ "close msg popup (e.g msg)",
+ CMD_GROUP_GENERAL,
+ )
+ .hide_help()
+ }
+ pub fn validate_msg(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Validate [{}]", get_hint(key_config.enter),),
+ "validate msg",
+ CMD_GROUP_GENERAL,
+ )
+ .hide_help()
+ }
+ pub fn select_staging(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "To stage [{}]",
+ get_hint(key_config.focus_stage),
+ ),
+ "focus/select staging area",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn select_status(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "To files [{},{}]",
+ get_hint(key_config.tab_status),
+ get_hint(key_config.tab_log),
+ ),
+ "focus/select file tree of staged or unstaged files",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn select_unstaged(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "To unstaged [{}]",
+ get_hint(key_config.focus_workdir),
+ ),
+ "focus/select unstaged area",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn commit_open(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Commit [{}]", get_hint(key_config.open_commit),),
+ "open commit popup (available in non-empty stage)",
+ CMD_GROUP_COMMIT,
+ )
+ }
+ pub fn commit_open_editor(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Open editor [{}]",
+ get_hint(key_config.open_commit_editor),
+ ),
+ "open commit editor (available in non-empty stage)",
+ CMD_GROUP_COMMIT,
+ )
+ }
+ pub fn commit_enter(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Commit [{}]", get_hint(key_config.enter),),
+ "commit (available when commit message is non-empty)",
+ CMD_GROUP_COMMIT,
+ )
+ }
+ pub fn commit_amend(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Amend [{}]", get_hint(key_config.commit_amend),),
+ "amend last commit",
+ CMD_GROUP_COMMIT,
+ )
+ }
+ pub fn edit_item(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Edit Item [{}]", get_hint(key_config.edit_file),),
+ "edit the currently selected file in an external editor",
+ CMD_GROUP_CHANGES,
+ )
+ }
+ pub fn stage_item(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Stage Item [{}]", get_hint(key_config.enter),),
+ "stage currently selected file or entire path",
+ CMD_GROUP_CHANGES,
+ )
+ }
+ pub fn stage_all(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!(
+ "Stage All [{}]",
+ get_hint(key_config.status_stage_all),
+ ),
+ "stage all changes (in unstaged files)",
+ CMD_GROUP_CHANGES,
+ )
+ }
+ pub fn unstage_item(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Unstage Item [{}]", get_hint(key_config.enter),),
+ "unstage currently selected file or entire path",
+ CMD_GROUP_CHANGES,
+ )
+ }
+ pub fn unstage_all(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!(
+ "Unstage all [{}]",
+ get_hint(key_config.status_stage_all),
+ ),
+ "unstage all files (in staged files)",
+ CMD_GROUP_CHANGES,
+ )
+ }
+ pub fn reset_item(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!(
+ "Reset Item [{}]",
+ get_hint(key_config.stash_drop),
+ ),
+ "revert changes in selected file or entire path",
+ CMD_GROUP_CHANGES,
+ )
+ }
+ pub fn ignore_item(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!(
+ "Ignore [{}]",
+ get_hint(key_config.status_ignore_file),
+ ),
+ "Add file or path to .gitignore",
+ CMD_GROUP_CHANGES,
+ )
+ }
+
+ pub fn diff_focus_left(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Back [{}]", get_hint(key_config.focus_left),),
+ "view and select changed files",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn diff_focus_right(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Diff [{}]", get_hint(key_config.focus_right),),
+ "inspect file diff",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn quit(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Quit [{}]", get_hint(key_config.exit),),
+ "quit gitui application",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn reset_confirm(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Confirm [{}]", get_hint(key_config.enter),),
+ "resets the file in question",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn stashing_save(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Save [{}]", get_hint(key_config.stashing_save),),
+ "opens stash name input popup",
+ CMD_GROUP_STASHING,
+ )
+ }
+ pub fn stashing_toggle_indexed(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Toggle Staged [{}]",
+ get_hint(key_config.stashing_toggle_index),
+ ),
+ "toggle including staged files into stash",
+ CMD_GROUP_STASHING,
+ )
+ }
+ pub fn stashing_toggle_untracked(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Toggle Untracked [{}]",
+ get_hint(key_config.stashing_toggle_untracked),
+ ),
+ "toggle including untracked files into stash",
+ CMD_GROUP_STASHING,
+ )
+ }
+ pub fn stashing_confirm_msg(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Stash [{}]", get_hint(key_config.enter),),
+ "save files to stash",
+ CMD_GROUP_STASHING,
+ )
+ }
+ pub fn stashlist_apply(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Apply [{}]", get_hint(key_config.enter),),
+ "apply selected stash",
+ CMD_GROUP_STASHES,
+ )
+ }
+ pub fn stashlist_drop(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Drop [{}]", get_hint(key_config.stash_drop),),
+ "drop selected stash",
+ CMD_GROUP_STASHES,
+ )
+ }
+ pub fn stashlist_inspect(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Inspect [{}]", get_hint(key_config.focus_right),),
+ "open stash commit details (allows to diff files)",
+ CMD_GROUP_STASHES,
+ )
+ }
+ pub fn log_details_toggle(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Details [{}]", get_hint(key_config.enter),),
+ "open details of selected commit",
+ CMD_GROUP_LOG,
+ )
+ }
+ pub fn log_details_open(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Inspect [{}]", get_hint(key_config.focus_right),),
+ "inspect selected commit in detail",
+ CMD_GROUP_LOG,
+ )
+ }
+ pub fn log_tag_commit(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Tag [{}]", get_hint(key_config.log_tag_commit),),
+ "tag commit",
+ CMD_GROUP_LOG,
+ )
+ }
+ pub fn tag_commit_confirm_msg(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Tag [{}]", get_hint(key_config.enter),),
+ "tag commit",
+ CMD_GROUP_LOG,
+ )
+ }
+ pub fn create_branch_confirm_msg(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Create Branch [{}]", get_hint(key_config.enter),),
+ "create branch",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn open_branch_create_popup(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Create [{}]",
+ get_hint(key_config.create_branch),
+ ),
+ "open create branch popup",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn rename_branch_confirm_msg(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!("Rename Branch [{}]", get_hint(key_config.enter),),
+ "rename branch",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn rename_branch_popup(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Rename Branch [{}]",
+ get_hint(key_config.rename_branch),
+ ),
+ "rename branch",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn delete_branch_popup(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Delete [{}]",
+ get_hint(key_config.delete_branch),
+ ),
+ "delete a branch",
+ CMD_GROUP_GENERAL,
+ )
+ }
+ pub fn open_branch_select_popup(
+ key_config: &SharedKeyConfig,
+ ) -> CommandText {
+ CommandText::new(
+ format!(
+ "Branches [{}]",
+ get_hint(key_config.select_branch),
+ ),
+ "open select branch popup",
+ CMD_GROUP_GENERAL,
+ )
+ }
+
+ pub fn status_push(key_config: &SharedKeyConfig) -> CommandText {
+ CommandText::new(
+ format!("Push [{}]", get_hint(key_config.push),),
+ "push to origin",
+ CMD_GROUP_GENERAL,
+ )
+ }
+}
diff --git a/src/tabs/mod.rs b/src/tabs/mod.rs
new file mode 100644
index 0000000..3a546fd
--- /dev/null
+++ b/src/tabs/mod.rs
@@ -0,0 +1,9 @@
+mod revlog;
+mod stashing;
+mod stashlist;
+mod status;
+
+pub use revlog::Revlog;
+pub use stashing::{Stashing, StashingOptions};
+pub use stashlist::StashList;
+pub use status::Status;
diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs
new file mode 100644
index 0000000..9a2a673
--- /dev/null
+++ b/src/tabs/revlog.rs
@@ -0,0 +1,315 @@
+use crate::{
+ components::{
+ visibility_blocking, CommandBlocking, CommandInfo,
+ CommitDetailsComponent, CommitList, Component,
+ DrawableComponent,
+ },
+ keys::SharedKeyConfig,
+ queue::{InternalEvent, Queue},
+ strings,
+ ui::style::SharedTheme,
+};
+use anyhow::Result;
+use asyncgit::{
+ cached,
+ sync::{self, CommitId},
+ AsyncLog, AsyncNotification, AsyncTags, FetchStatus, CWD,
+};
+use crossbeam_channel::Sender;
+use crossterm::event::Event;
+use std::time::Duration;
+use sync::CommitTags;
+use tui::{
+ backend::Backend,
+ layout::{Constraint, Direction, Layout, Rect},
+ Frame,
+};
+
+const SLICE_SIZE: usize = 1200;
+
+///
+pub struct Revlog {
+ commit_details: CommitDetailsComponent,
+ list: CommitList,
+ git_log: AsyncLog,
+ git_tags: AsyncTags,
+ queue: Queue,
+ visible: bool,
+ branch_name: cached::BranchName,
+ key_config: SharedKeyConfig,
+}
+
+impl Revlog {
+ ///
+ pub fn new(
+ queue: &Queue,
+ sender: &Sender<AsyncNotification>,
+ theme: SharedTheme,
+ key_config: SharedKeyConfig,
+ ) -> Self {
+ Self {
+ queue: queue.clone(),
+ commit_details: CommitDetailsComponent::new(
+ queue,
+ sender,
+ theme.clone(),
+ key_config.clone(),
+ ),
+ list: CommitList::new(
+ &strings::log_title(&key_config),