Merge branch 'master' into ndom91/add-nix-flake

This commit is contained in:
ndom91 2024-05-05 22:27:07 +02:00
commit 214667b17b
No known key found for this signature in database
184 changed files with 6184 additions and 3117 deletions

View File

@ -15,6 +15,10 @@ runs:
steps: steps:
- uses: ./.github/actions/init-env-rust - uses: ./.github/actions/init-env-rust
- run: |
cargo build --locked -p gitbutler-git --bins
shell: bash
- run: | - run: |
printf '%s\n' "$JSON_DOC" > /tmp/features.json printf '%s\n' "$JSON_DOC" > /tmp/features.json
cat /tmp/features.json | jq -r 'if . == "*" then "--all-features" elif . == "" then "" elif type == "array" then if length == 0 then "--no-default-features" else "--no-default-features --features " + join(",") end else . end' > /tmp/features cat /tmp/features.json | jq -r 'if . == "*" then "--all-features" elif . == "" then "" elif type == "array" then if length == 0 then "--no-default-features" else "--no-default-features --features " + join(",") end else . end' > /tmp/features
@ -23,7 +27,9 @@ runs:
FEATURES: ${{ inputs.features }} FEATURES: ${{ inputs.features }}
shell: bash shell: bash
- run: cargo test --locked -p ${{ inputs.crate }} --all-targets $(cat /tmp/features) - run: |
cargo build -p gitbutler-git --bins
cargo test --locked -p ${{ inputs.crate }} --all-targets $(cat /tmp/features)
if: inputs.action == 'test' if: inputs.action == 'test'
env: env:
GITBUTLER_TESTS_NO_CLEANUP: "1" GITBUTLER_TESTS_NO_CLEANUP: "1"

View File

@ -3,13 +3,6 @@ description: prepare runner for rust related tasks
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Cache rust dependencies
if: runner.name != 'ScottsMacStudio' # internet in berlin is very slow
uses: Swatinem/rust-cache@v2
with:
prefix-key: gitbutler-client
shared-key: rust
- name: Check versions - name: Check versions
shell: bash shell: bash
run: | run: |

View File

@ -29,9 +29,10 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
# https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-private-repositories
platform: platform:
- macos-latest # [macOs, x64] - macos-13 # [macOs, x64]
- macos-latest-xlarge # [macOs, ARM64] - macos-latest # [macOs, ARM64]
- ubuntu-20.04 # [linux, x64] - ubuntu-20.04 # [linux, x64]
- windows-latest # [windows, x64] - windows-latest # [windows, x64]
@ -191,9 +192,10 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
# https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-private-repositories
platform: platform:
- macos-latest # [macOs, x64] - macos-13 # [macOs, x64]
- macos-latest-xlarge # [macOs, ARM64] - macos-latest # [macOs, ARM64]
- ubuntu-20.04 # [linux, x64] - ubuntu-20.04 # [linux, x64]
- windows-latest # [windows, x64] - windows-latest # [windows, x64]
steps: steps:

View File

@ -18,6 +18,7 @@ jobs:
gitbutler-tauri: ${{ steps.filter.outputs.gitbutler-tauri }} gitbutler-tauri: ${{ steps.filter.outputs.gitbutler-tauri }}
gitbutler-changeset: ${{ steps.filter.outputs.gitbutler-changeset }} gitbutler-changeset: ${{ steps.filter.outputs.gitbutler-changeset }}
gitbutler-git: ${{ steps.filter.outputs.gitbutler-git }} gitbutler-git: ${{ steps.filter.outputs.gitbutler-git }}
gitbutler-cli: ${{ steps.filter.outputs.gitbutler-cli }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dorny/paths-filter@v3 - uses: dorny/paths-filter@v3
@ -51,6 +52,9 @@ jobs:
gitbutler-git: gitbutler-git:
- *rust - *rust
- 'crates/gitbutler-git/**' - 'crates/gitbutler-git/**'
gitbutler-cli:
- *rust
- 'crates/gitbutler-cli/**'
lint-node: lint-node:
needs: changes needs: changes
@ -79,7 +83,7 @@ jobs:
- uses: ./.github/actions/init-env-node - uses: ./.github/actions/init-env-node
- run: pnpm test - run: pnpm test
rust-init: rust-lint:
needs: changes needs: changes
if: ${{ needs.changes.outputs.rust == 'true' }} if: ${{ needs.changes.outputs.rust == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -89,7 +93,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust - uses: ./.github/actions/init-env-rust
- run: cargo fmt --check --all - run: cargo fmt --check --all
- run: cargo build --locked --all-targets --tests
rust-docs: rust-docs:
needs: changes needs: changes
@ -107,7 +110,7 @@ jobs:
RUSTDOCFLAGS: -Dwarnings RUSTDOCFLAGS: -Dwarnings
check-gitbutler-tauri: check-gitbutler-tauri:
needs: [changes, rust-init] needs: changes
if: ${{ needs.changes.outputs.gitbutler-tauri == 'true' }} if: ${{ needs.changes.outputs.gitbutler-tauri == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@ -123,6 +126,7 @@ jobs:
- [devtools] - [devtools]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate - uses: ./.github/actions/check-crate
with: with:
crate: gitbutler-tauri crate: gitbutler-tauri
@ -130,7 +134,7 @@ jobs:
action: ${{ matrix.action }} action: ${{ matrix.action }}
check-gitbutler-changeset: check-gitbutler-changeset:
needs: [changes, rust-init] needs: changes
if: ${{ needs.changes.outputs.gitbutler-changeset == 'true' }} if: ${{ needs.changes.outputs.gitbutler-changeset == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@ -147,6 +151,7 @@ jobs:
- [] - []
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate - uses: ./.github/actions/check-crate
with: with:
crate: gitbutler-changeset crate: gitbutler-changeset
@ -154,7 +159,7 @@ jobs:
action: ${{ matrix.action }} action: ${{ matrix.action }}
check-gitbutler-git: check-gitbutler-git:
needs: [changes, rust-init] needs: changes
if: ${{ needs.changes.outputs.gitbutler-git == 'true' }} if: ${{ needs.changes.outputs.gitbutler-git == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@ -171,6 +176,7 @@ jobs:
- [tokio] - [tokio]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate - uses: ./.github/actions/check-crate
with: with:
crate: gitbutler-git crate: gitbutler-git
@ -178,7 +184,7 @@ jobs:
action: ${{ matrix.action }} action: ${{ matrix.action }}
check-gitbutler-core: check-gitbutler-core:
needs: [changes, rust-init] needs: changes
if: ${{ needs.changes.outputs.gitbutler-core == 'true' }} if: ${{ needs.changes.outputs.gitbutler-core == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@ -194,12 +200,38 @@ jobs:
- [] - []
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate - uses: ./.github/actions/check-crate
with: with:
crate: gitbutler-core crate: gitbutler-core
features: ${{ toJson(matrix.features) }} features: ${{ toJson(matrix.features) }}
action: ${{ matrix.action }} action: ${{ matrix.action }}
check-gitbutler-cli:
needs: changes
if: ${{ needs.changes.outputs.gitbutler-cli == 'true' }}
runs-on: ubuntu-latest
container:
image: ghcr.io/gitbutlerapp/ci-base-image:latest
strategy:
matrix:
action:
- test
- check
- check-tests
features:
- ''
- '*'
- []
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate
with:
crate: gitbutler-cli
features: ${{ toJson(matrix.features) }}
action: ${{ matrix.action }}
check-rust: check-rust:
if: always() if: always()
needs: needs:
@ -208,7 +240,9 @@ jobs:
- check-gitbutler-core - check-gitbutler-core
- check-gitbutler-changeset - check-gitbutler-changeset
- check-gitbutler-git - check-gitbutler-git
- check-gitbutler-cli
- check-rust-windows - check-rust-windows
- rust-lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Decide whether the needed jobs succeeded or failed - name: Decide whether the needed jobs succeeded or failed
@ -223,6 +257,6 @@ jobs:
if: ${{ needs.changes.outputs.rust == 'true' }} if: ${{ needs.changes.outputs.rust == 'true' }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: Swatinem/rust-cache@v2 - uses: ./.github/actions/init-env-rust
- name: "cargo check" - name: "cargo check"
run: cargo check --all --bins --examples run: cargo check --all --bins --examples --features windows

3
.gitignore vendored
View File

@ -7,3 +7,6 @@
.idea .idea
.DS_Store .DS_Store
.env
.env.*

470
Cargo.lock generated
View File

@ -97,14 +97,60 @@ dependencies = [
] ]
[[package]] [[package]]
name = "anyhow" name = "anstream"
version = "1.0.81" version = "0.6.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
dependencies = [ dependencies = [
"backtrace", "anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
] ]
[[package]]
name = "anstyle"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
[[package]]
name = "anstyle-parse"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19"
dependencies = [
"anstyle",
"windows-sys 0.52.0",
]
[[package]]
name = "anyhow"
version = "1.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.7.1" version = "1.7.1"
@ -300,9 +346,9 @@ checksum = "fbb36e985947064623dbd357f727af08ffd077f93d696782f3c56365fa2e2799"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.79" version = "0.1.80"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681" checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -434,6 +480,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.6.0" version = "1.6.0"
@ -752,9 +804,9 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.37" version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [ dependencies = [
"android-tzdata", "android-tzdata",
"iana-time-zone", "iana-time-zone",
@ -775,6 +827,33 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "clap"
version = "4.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim 0.11.1",
]
[[package]]
name = "clap_lex"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce"
[[package]] [[package]]
name = "clru" name = "clru"
version = "0.6.1" version = "0.6.1"
@ -817,6 +896,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.6" version = "4.6.6"
@ -1095,7 +1180,7 @@ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim 0.10.0",
"syn 2.0.58", "syn 2.0.58",
] ]
@ -1123,16 +1208,6 @@ dependencies = [
"parking_lot_core 0.9.9", "parking_lot_core 0.9.9",
] ]
[[package]]
name = "debugid"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"serde",
"uuid",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.9" version = "0.7.9"
@ -1415,6 +1490,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "errno"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1"
dependencies = [
"errno-dragonfly",
"libc",
"winapi 0.3.9",
]
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.8" version = "0.3.8"
@ -1425,6 +1511,16 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "errno-dragonfly"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
dependencies = [
"cc",
"libc",
]
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "2.5.3" version = "2.5.3"
@ -1598,18 +1694,6 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "findshlibs"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64"
dependencies = [
"cc",
"lazy_static",
"libc",
"winapi 0.3.9",
]
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.0.28" version = "1.0.28"
@ -2031,21 +2115,6 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "gitbutler-analytics"
version = "0.0.0"
dependencies = [
"async-trait",
"chrono",
"gitbutler-core",
"reqwest 0.12.2",
"serde",
"serde_json",
"thiserror",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "gitbutler-changeset" name = "gitbutler-changeset"
version = "0.0.0" version = "0.0.0"
@ -2054,6 +2123,17 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "gitbutler-cli"
version = "0.0.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"gitbutler-core",
"pager",
]
[[package]] [[package]]
name = "gitbutler-core" name = "gitbutler-core"
version = "0.0.0" version = "0.0.0"
@ -2084,7 +2164,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"refinery", "refinery",
"regex", "regex",
"reqwest 0.12.2", "reqwest 0.12.4",
"resolve-path", "resolve-path",
"rusqlite", "rusqlite",
"serde", "serde",
@ -2094,6 +2174,7 @@ dependencies = [
"slug", "slug",
"ssh-key", "ssh-key",
"ssh2", "ssh2",
"strum",
"tempfile", "tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
@ -2131,7 +2212,6 @@ dependencies = [
"console-subscriber", "console-subscriber",
"futures", "futures",
"git2", "git2",
"gitbutler-analytics",
"gitbutler-core", "gitbutler-core",
"gitbutler-testsupport", "gitbutler-testsupport",
"gitbutler-watcher", "gitbutler-watcher",
@ -2140,9 +2220,7 @@ dependencies = [
"nonzero_ext", "nonzero_ext",
"once_cell", "once_cell",
"pretty_assertions", "pretty_assertions",
"reqwest 0.12.2", "reqwest 0.12.4",
"sentry",
"sentry-tracing",
"serde", "serde",
"serde_json", "serde_json",
"slug", "slug",
@ -2182,7 +2260,6 @@ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"futures", "futures",
"git2", "git2",
"gitbutler-analytics",
"gitbutler-core", "gitbutler-core",
"gitbutler-testsupport", "gitbutler-testsupport",
"itertools 0.12.1", "itertools 0.12.1",
@ -3117,6 +3194,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.3.9" version = "0.3.9"
@ -3147,17 +3230,6 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "hostname"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
dependencies = [
"libc",
"match_cfg",
"winapi 0.3.9",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.26.0" version = "0.26.0"
@ -3526,6 +3598,12 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.11.0" version = "0.11.0"
@ -3860,12 +3938,6 @@ dependencies = [
"tendril", "tendril",
] ]
[[package]]
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.1.0"
@ -4399,6 +4471,16 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "pager"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2599211a5c97fbbb1061d3dc751fa15f404927e4846e07c643287d6d1f462880"
dependencies = [
"errno 0.2.8",
"libc",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.15.10" version = "0.15.10"
@ -5291,7 +5373,7 @@ dependencies = [
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls-pemfile", "rustls-pemfile 1.0.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
@ -5311,11 +5393,11 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.2" version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d66674f2b6fb864665eea7a3c1ac4e3dfacd2fda83cf6f935a612e01b0e3338" checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
@ -5335,7 +5417,7 @@ dependencies = [
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls-pemfile", "rustls-pemfile 2.1.2",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
@ -5348,7 +5430,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"winreg 0.50.0", "winreg 0.52.0",
] ]
[[package]] [[package]]
@ -5496,7 +5578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"errno", "errno 0.3.8",
"io-lifetimes", "io-lifetimes",
"libc", "libc",
"linux-raw-sys 0.3.8", "linux-raw-sys 0.3.8",
@ -5510,7 +5592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89"
dependencies = [ dependencies = [
"bitflags 2.5.0", "bitflags 2.5.0",
"errno", "errno 0.3.8",
"libc", "libc",
"linux-raw-sys 0.4.13", "linux-raw-sys 0.4.13",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@ -5525,6 +5607,22 @@ dependencies = [
"base64 0.21.7", "base64 0.21.7",
] ]
[[package]]
name = "rustls-pemfile"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d"
dependencies = [
"base64 0.22.1",
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beb461507cee2c2ff151784c52762cf4d9ff6a61f3e80968600ed24fa837fa54"
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.14" version = "1.0.14"
@ -5648,140 +5746,20 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sentry"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "766448f12e44d68e675d5789a261515c46ac6ccd240abdd451a9c46c84a49523"
dependencies = [
"httpdate",
"native-tls",
"reqwest 0.11.27",
"sentry-anyhow",
"sentry-backtrace",
"sentry-contexts",
"sentry-core",
"sentry-debug-images",
"sentry-panic",
"sentry-tracing",
"tokio",
"ureq",
]
[[package]]
name = "sentry-anyhow"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4da4015667c99f88d68ca7ff02b90c762d6154a4ceb7c02922b9a1dbd3959eeb"
dependencies = [
"anyhow",
"sentry-backtrace",
"sentry-core",
]
[[package]]
name = "sentry-backtrace"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32701cad8b3c78101e1cd33039303154791b0ff22e7802ed8cc23212ef478b45"
dependencies = [
"backtrace",
"once_cell",
"regex",
"sentry-core",
]
[[package]]
name = "sentry-contexts"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17ddd2a91a13805bd8dab4ebf47323426f758c35f7bf24eacc1aded9668f3824"
dependencies = [
"hostname",
"libc",
"os_info",
"rustc_version",
"sentry-core",
"uname",
]
[[package]]
name = "sentry-core"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1189f68d7e7e102ef7171adf75f83a59607fafd1a5eecc9dc06c026ff3bdec4"
dependencies = [
"once_cell",
"rand 0.8.5",
"sentry-types",
"serde",
"serde_json",
]
[[package]]
name = "sentry-debug-images"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4d0a615e5eeca5699030620c119a094e04c14cf6b486ea1030460a544111a7"
dependencies = [
"findshlibs",
"once_cell",
"sentry-core",
]
[[package]]
name = "sentry-panic"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c18d0b5fba195a4950f2f4c31023725c76f00aabb5840b7950479ece21b5ca"
dependencies = [
"sentry-backtrace",
"sentry-core",
]
[[package]]
name = "sentry-tracing"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3012699a9957d7f97047fd75d116e22d120668327db6e7c59824582e16e791b2"
dependencies = [
"sentry-backtrace",
"sentry-core",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "sentry-types"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7173fd594569091f68a7c37a886e202f4d0c1db1e1fa1d18a051ba695b2e2ec"
dependencies = [
"debugid",
"hex",
"rand 0.8.5",
"serde",
"serde_json",
"thiserror",
"time",
"url",
"uuid",
]
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.197" version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.197" version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -5790,9 +5768,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.115" version = "1.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813"
dependencies = [ dependencies = [
"indexmap 2.2.6", "indexmap 2.2.6",
"itoa 1.0.11", "itoa 1.0.11",
@ -6129,9 +6107,9 @@ dependencies = [
[[package]] [[package]]
name = "ssh-key" name = "ssh-key"
version = "0.6.5" version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b71299a724c8d84956caaf8fc3b3ea57c3587fe2d0b800cd0dc1f3599905d7e" checksum = "ca9b366a80cf18bb6406f4cf4d10aebfb46140a8c0c33f666a144c5c76ecbafc"
dependencies = [ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"p256", "p256",
@ -6213,6 +6191,34 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.58",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.5.0" version = "2.5.0"
@ -6274,9 +6280,9 @@ dependencies = [
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.30.8" version = "0.30.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b1a378e48fb3ce3a5cf04359c456c9c98ff689bcf1c1bc6e6a31f247686f275" checksum = "87341a165d73787554941cd5ef55ad728011566fe714e987d1b976c15dbc3a83"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"core-foundation-sys", "core-foundation-sys",
@ -6417,9 +6423,9 @@ checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "1.6.1" version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f078117725e36d55d29fafcbb4b1e909073807ca328ae8deb8c0b3843aac0fed" checksum = "047aefcc7721bfb8024a9bc39d4719112262610502de7a224fa62c4570cd78d4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -6433,7 +6439,7 @@ dependencies = [
"glib", "glib",
"glob", "glob",
"gtk", "gtk",
"heck 0.4.1", "heck 0.5.0",
"http 0.2.12", "http 0.2.12",
"ignore", "ignore",
"indexmap 1.9.3", "indexmap 1.9.3",
@ -6551,7 +6557,7 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-log" name = "tauri-plugin-log"
version = "0.0.0" version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#773b4983928831b90c16304b51d2fdded9d75066" source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5e3900e682e13f3759b439116ae2f77a6d389ca2"
dependencies = [ dependencies = [
"byte-unit", "byte-unit",
"fern", "fern",
@ -6566,7 +6572,7 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "0.0.0" version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#773b4983928831b90c16304b51d2fdded9d75066" source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5e3900e682e13f3759b439116ae2f77a6d389ca2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@ -6580,7 +6586,7 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-store" name = "tauri-plugin-store"
version = "0.0.0" version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#773b4983928831b90c16304b51d2fdded9d75066" source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5e3900e682e13f3759b439116ae2f77a6d389ca2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@ -6592,7 +6598,7 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-window-state" name = "tauri-plugin-window-state"
version = "0.1.1" version = "0.1.1"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#773b4983928831b90c16304b51d2fdded9d75066" source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5e3900e682e13f3759b439116ae2f77a6d389ca2"
dependencies = [ dependencies = [
"bincode", "bincode",
"bitflags 2.5.0", "bitflags 2.5.0",
@ -6715,18 +6721,18 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.58" version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "1.0.58" version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -7122,15 +7128,6 @@ dependencies = [
"winapi 0.3.9", "winapi 0.3.9",
] ]
[[package]]
name = "uname"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.15" version = "0.3.15"
@ -7164,19 +7161,6 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
[[package]]
name = "ureq"
version = "2.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f214ce18d8b2cbe84ed3aa6486ed3f5b285cf8d8fbdbce9f3f767a724adc35"
dependencies = [
"base64 0.21.7",
"log",
"native-tls",
"once_cell",
"url",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.0" version = "2.5.0"
@ -7207,6 +7191,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]]
name = "utf8parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.8.0" version = "1.8.0"

View File

@ -1,12 +1,12 @@
[workspace] [workspace]
members = [ members = [
"crates/gitbutler-analytics",
"crates/gitbutler-core", "crates/gitbutler-core",
"crates/gitbutler-tauri", "crates/gitbutler-tauri",
"crates/gitbutler-changeset", "crates/gitbutler-changeset",
"crates/gitbutler-git", "crates/gitbutler-git",
"crates/gitbutler-watcher", "crates/gitbutler-watcher",
"crates/gitbutler-testsupport", "crates/gitbutler-testsupport",
"crates/gitbutler-cli",
] ]
resolver = "2" resolver = "2"
@ -15,18 +15,18 @@ gix = { version = "0.62.0", default-features = false, features = [] } # add perf
git2 = { version = "0.18.3", features = ["vendored-openssl", "vendored-libgit2"] } git2 = { version = "0.18.3", features = ["vendored-openssl", "vendored-libgit2"] }
uuid = { version = "1.8.0", features = ["serde"] } uuid = { version = "1.8.0", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0.58" thiserror = "1.0.59"
rusqlite = { version = "0.29.0", features = [ "bundled", "blob" ] } rusqlite = { version = "0.29.0", features = [ "bundled", "blob" ] }
tokio = { version = "1.37.0", default-features = false } tokio = { version = "1.37.0", default-features = false }
gitbutler-git = { path = "crates/gitbutler-git" } gitbutler-git = { path = "crates/gitbutler-git" }
gitbutler-core = { path = "crates/gitbutler-core" } gitbutler-core = { path = "crates/gitbutler-core" }
gitbutler-analytics = { path = "crates/gitbutler-analytics" }
gitbutler-watcher = { path = "crates/gitbutler-watcher" } gitbutler-watcher = { path = "crates/gitbutler-watcher" }
gitbutler-testsupport = { path = "crates/gitbutler-testsupport" } gitbutler-testsupport = { path = "crates/gitbutler-testsupport" }
gitbutler-cli ={ path = "crates/gitbutler-cli" }
[profile.release] [profile.release]
codegen-units = 1 # Compile crates one after another so the compiler can optimize better codegen-units = 1 # Compile crates one after another so the compiler can optimize better
lto = true # Enables link to optimizations lto = true # Enables link to optimizations
opt-level = "s" # Optimize for binary size opt-level = "s" # Optimize for binary size
debug = true # Enable debug symbols, for sentry debug = true # Enable debug symbols, for profiling

View File

@ -168,29 +168,21 @@ We use `pnpm`, which requires a relatively recent version of Node.js.
Make sure that the latest stable version of Node.js is installed and Make sure that the latest stable version of Node.js is installed and
on the PATH, and then `npm i -g pnpm`. on the PATH, and then `npm i -g pnpm`.
This often causes file permissions. First, the AppData folder may not Sometimes npm's prefix is incorrect on Windows, we can check this via:
be present. Be sure to create it if it isn't.
``` ```sh
mkdir %APPDATA%\npm npm config get prefix
``` ```
Secondly, typically folders within `Program Files` are not writable. If it's not `C:\Users\<username>\AppData\Roaming\npm` or another folder that is
You'll need to fix the security permissions for the `nodejs` folder. normally writable, then we can set it in Powershell:
> **NOTE:** Under specific circumstances, depending on your usage of ```sh
> Node.js, this may pose a security concern. Be sure to understand mkdir -p $APPDATA\npm
> the implications of this before proceeding. npm config set prefix $env:APPDATA\npm
```
1. Right-click on the `nodejs` folder in `Program Files`. Afterwards, add this folder to your PATH.
2. Click on `Properties`.
3. Click on the `Security` tab.
4. Click on `Edit` next to "change permissions".
5. Click on `Add`.
6. Type in the name of your user account, or type `Everyone` (case-sensitive).
Click `Check Names` to verify (they will be underlined if correct).
7. Make sure that `Full Control` is checked under `Allow`.
8. Apply / click OK as needed to close the dialogs.
### Perl ### Perl
@ -198,6 +190,7 @@ A Perl interpreter is required to be installed in order to configure the `openss
crate. We've used [Strawberry Perl](https://strawberryperl.com/) without issue. crate. We've used [Strawberry Perl](https://strawberryperl.com/) without issue.
Make sure it's installed and `perl` is available on the `PATH` (it is by default Make sure it's installed and `perl` is available on the `PATH` (it is by default
after installation, just make sure to restart the terminal after installing). after installation, just make sure to restart the terminal after installing).
[Scoop](https://scoop.sh/) users can install this via `scoop install perl`.
Note that it might appear that the build has hung or frozen on the `openssl-sys` crate. Note that it might appear that the build has hung or frozen on the `openssl-sys` crate.
It's not, it's just that Cargo can't report the status of a C/C++ build happening It's not, it's just that Cargo can't report the status of a C/C++ build happening

View File

@ -40,17 +40,17 @@
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@octokit/rest": "^20.1.0", "@octokit/rest": "^20.1.0",
"@replit/codemirror-lang-svelte": "^6.0.0", "@replit/codemirror-lang-svelte": "^6.0.0",
"@sentry/sveltekit": "^7.111.0", "@sentry/sveltekit": "^7.112.2",
"@sveltejs/adapter-static": "^2.0.3", "@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.30.4", "@sveltejs/kit": "^1.30.4",
"@tauri-apps/api": "^1.5.3", "@tauri-apps/api": "^1.5.4",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/diff": "^5.2.0", "@types/diff": "^5.2.0",
"@types/diff-match-patch": "^1.0.36", "@types/diff-match-patch": "^1.0.36",
"@types/lscache": "^1.3.4", "@types/lscache": "^1.3.4",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@typescript-eslint/eslint-plugin": "^7.7.0", "@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.0", "@typescript-eslint/parser": "^7.7.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
@ -61,7 +61,7 @@
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-square-svelte-store": "^1.0.0", "eslint-plugin-square-svelte-store": "^1.0.0",
"eslint-plugin-svelte": "^2.37.0", "eslint-plugin-svelte": "^2.38.0",
"inter-ui": "^4.0.2", "inter-ui": "^4.0.2",
"leven": "^4.0.0", "leven": "^4.0.0",
"lscache": "^1.3.2", "lscache": "^1.3.2",
@ -71,14 +71,14 @@
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-load-config": "^5.0.3", "postcss-load-config": "^5.0.3",
"posthog-js": "1.128.2", "posthog-js": "1.130.1",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.3", "prettier-plugin-svelte": "^3.2.3",
"prettier-plugin-tailwindcss": "^0.5.14", "prettier-plugin-tailwindcss": "^0.5.14",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"svelte": "^4.2.15", "svelte": "^4.2.15",
"svelte-check": "^3.6.9", "svelte-check": "^3.7.0",
"svelte-floating-ui": "^1.5.8", "svelte-floating-ui": "^1.5.8",
"svelte-french-toast": "^1.2.0", "svelte-french-toast": "^1.2.0",
"svelte-loadable-store": "^2.0.1", "svelte-loadable-store": "^2.0.1",
@ -95,6 +95,6 @@
"vitest": "^0.34.6" "vitest": "^0.34.6"
}, },
"dependencies": { "dependencies": {
"openai": "^4.38.2" "openai": "^4.38.5"
} }
} }

9
app/src/global.d.ts vendored
View File

@ -1,9 +0,0 @@
declare type Item = import('svelte-dnd-action').Item;
declare type DndEvent<ItemType = Item> = import('svelte-dnd-action/typings').DndEvent<ItemType>;
declare namespace svelte.JSX {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> {
onconsider?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void;
onfinalize?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void;
}
}

View File

@ -0,0 +1,34 @@
import { initPostHog } from '$lib/analytics/posthog';
import { initSentry } from '$lib/analytics/sentry';
import { appAnalyticsConfirmed } from '$lib/config/appSettings';
import {
appMetricsEnabled,
appErrorReportingEnabled,
appNonAnonMetricsEnabled
} from '$lib/config/appSettings';
import posthog from 'posthog-js';
export function initAnalyticsIfEnabled() {
const analyticsConfirmed = appAnalyticsConfirmed();
analyticsConfirmed.onDisk().then((confirmed) => {
if (confirmed) {
appErrorReportingEnabled()
.onDisk()
.then((enabled) => {
if (enabled) initSentry();
});
appMetricsEnabled()
.onDisk()
.then((enabled) => {
if (enabled) initPostHog();
});
appNonAnonMetricsEnabled()
.onDisk()
.then((enabled) => {
enabled
? posthog.capture('nonAnonMetricsEnabled')
: posthog.capture('nonAnonMetricsDisabled');
});
}
});
}

View File

@ -7,7 +7,7 @@ export async function initPostHog() {
const [appName, appVersion] = await Promise.all([getName(), getVersion()]); const [appName, appVersion] = await Promise.all([getName(), getVersion()]);
posthog.init(PUBLIC_POSTHOG_API_KEY, { posthog.init(PUBLIC_POSTHOG_API_KEY, {
api_host: 'https://eu.posthog.com', api_host: 'https://eu.posthog.com',
disable_session_recording: appName !== 'GitButler', // only record sessions in production disable_session_recording: true,
capture_performance: false, capture_performance: false,
request_batching: true, request_batching: true,
persistence: 'localStorage', persistence: 'localStorage',

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import AnalyticsSettings from './AnalyticsSettings.svelte'; import AnalyticsSettings from './AnalyticsSettings.svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
import { initAnalyticsIfEnabled } from '$lib/analytics/analytics';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
export let analyticsConfirmed: Writable<boolean>; export let analyticsConfirmed: Writable<boolean>;
@ -17,6 +18,7 @@
icon="chevron-right-small" icon="chevron-right-small"
on:click={() => { on:click={() => {
$analyticsConfirmed = true; $analyticsConfirmed = true;
initAnalyticsIfEnabled();
}} }}
> >
Continue Continue

View File

@ -1,22 +1,27 @@
<script lang="ts"> <script lang="ts">
import InfoMessage from './InfoMessage.svelte';
import Link from './Link.svelte'; import Link from './Link.svelte';
import SectionCard from './SectionCard.svelte'; import SectionCard from './SectionCard.svelte';
import Toggle from './Toggle.svelte'; import Toggle from './Toggle.svelte';
import { appErrorReportingEnabled, appMetricsEnabled } from '$lib/config/appSettings'; import {
appErrorReportingEnabled,
appMetricsEnabled,
appNonAnonMetricsEnabled
} from '$lib/config/appSettings';
const errorReportingEnabled = appErrorReportingEnabled(); const errorReportingEnabled = appErrorReportingEnabled();
const metricsEnabled = appMetricsEnabled(); const metricsEnabled = appMetricsEnabled();
let updatedTelemetrySettings = false; const nonAnonMetricsEnabled = appNonAnonMetricsEnabled();
function toggleErrorReporting() { function toggleErrorReporting() {
$errorReportingEnabled = !$errorReportingEnabled; $errorReportingEnabled = !$errorReportingEnabled;
updatedTelemetrySettings = true;
} }
function toggleMetrics() { function toggleMetrics() {
$metricsEnabled = !$metricsEnabled; $metricsEnabled = !$metricsEnabled;
updatedTelemetrySettings = true; }
function toggleNonAnonMetrics() {
$nonAnonMetricsEnabled = !$nonAnonMetricsEnabled;
} }
</script> </script>
@ -24,7 +29,13 @@
<div class="analytics-settings__content"> <div class="analytics-settings__content">
<p class="text-base-body-13 analytics-settings__text"> <p class="text-base-body-13 analytics-settings__text">
GitButler uses telemetry strictly to help us improve the client. We do not collect any GitButler uses telemetry strictly to help us improve the client. We do not collect any
personal information. personal information (<Link
target="_blank"
rel="noreferrer"
href="https://gitbutler.com/privacy"
>
privacy policy
</Link>).
</p> </p>
<p class="text-base-body-13 analytics-settings__text"> <p class="text-base-body-13 analytics-settings__text">
We kindly ask you to consider keeping these settings enabled as it helps us catch issues more We kindly ask you to consider keeping these settings enabled as it helps us catch issues more
@ -61,13 +72,19 @@
</svelte:fragment> </svelte:fragment>
</SectionCard> </SectionCard>
{#if updatedTelemetrySettings} <SectionCard labelFor="nonAnonMetricsEnabledToggle" on:click={toggleMetrics} orientation="row">
<InfoMessage> <svelte:fragment slot="title">Non-anonymous usage metrics</svelte:fragment>
<svelte:fragment slot="content" <svelte:fragment slot="caption"
>Changes will take effect on the next application start.</svelte:fragment >Toggle sharing of identifiable usage statistics.</svelte:fragment
> >
</InfoMessage> <svelte:fragment slot="actions">
{/if} <Toggle
id="nonAnonMetricsEnabledToggle"
checked={$nonAnonMetricsEnabled}
on:change={toggleNonAnonMetrics}
/>
</svelte:fragment>
</SectionCard>
</div> </div>
</section> </section>

View File

@ -5,7 +5,7 @@
export let help = ''; export let help = '';
</script> </script>
<div class="badge text-base-10 text-semibold" use:tooltip={help}> <div class="badge text-base-10 text-bold" use:tooltip={help}>
{count} {count}
</div> </div>
@ -15,12 +15,12 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
height: var(--size-16); height: var(--size-14);
min-width: var(--size-16); min-width: var(--size-14);
border-radius: var(--size-16); border-radius: var(--size-14);
padding: 0 var(--size-4); padding: 0 var(--size-4);
color: var(--clr-scale-ntrl-100); color: var(--clr-scale-ntrl-100);
background-color: var(--clr-scale-ntrl-50); background-color: var(--clr-scale-ntrl-40);
line-height: 90%; line-height: 90%;
} }
</style> </style>

View File

@ -48,7 +48,7 @@
</Button> </Button>
<div class="commits-list"> <div class="commits-list">
{#each base.upstreamCommits as commit} {#each base.upstreamCommits as commit}
<CommitCard {commit} commitUrl={base.commitUrl(commit.id)} /> <CommitCard {commit} isUnapplied={true} commitUrl={base.commitUrl(commit.id)} />
{/each} {/each}
</div> </div>
<Spacer margin={2} /> <Spacer margin={2} />
@ -62,7 +62,7 @@
Local Local
</h1> </h1>
{#each base.recentCommits as commit} {#each base.recentCommits as commit}
<CommitCard {commit} commitUrl={base.commitUrl(commit.id)} /> <CommitCard {commit} isUnapplied={true} commitUrl={base.commitUrl(commit.id)} />
{/each} {/each}
</div> </div>
</div> </div>

View File

@ -102,7 +102,7 @@
} }
.row_1 { .row_1 {
display: flex; display: flex;
gap: var(--size-6); gap: var(--size-4);
align-items: center; align-items: center;
color: var(--clr-scale-ntrl-10); color: var(--clr-scale-ntrl-10);
} }

View File

@ -0,0 +1,110 @@
<script lang="ts">
import Button from './Button.svelte';
import InfoMessage from './InfoMessage.svelte';
import Select from './Select.svelte';
import { Project } from '$lib/backend/projects';
import SectionCard from '$lib/components/SectionCard.svelte';
import SelectItem from '$lib/components/SelectItem.svelte';
import Spacer from '$lib/components/Spacer.svelte';
import { getContext, getContextStore } from '$lib/utils/context';
import { getRemoteBranches } from '$lib/vbranches/baseBranch';
import { BranchController } from '$lib/vbranches/branchController';
import { BaseBranch } from '$lib/vbranches/types';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
const baseBranch = getContextStore(BaseBranch);
const vbranchService = getContext(VirtualBranchService);
const branchController = getContext(BranchController);
const activeBranches = vbranchService.activeBranches;
let project = getContext(Project);
let selectedBranch: {
name: string;
} = {
name: $baseBranch.branchName
};
let isSwitching = false;
async function onSetBaseBranchClick() {
if (!selectedBranch) return;
// while target is setting, display loading
isSwitching = true;
await branchController
.setTarget(selectedBranch.name)
.catch((err) => {
console.log('error', err);
})
.finally(() => {
isSwitching = false;
});
}
$: console.log('selectedBranch', selectedBranch);
</script>
{#if $activeBranches}
<SectionCard>
<svelte:fragment slot="title">Current base branch</svelte:fragment>
<form class="form-wrapper">
{#await getRemoteBranches(project.id)}
loading remote branches...
{:then remoteBranches}
<div class="fields-wrapper">
<Select
items={remoteBranches}
bind:value={selectedBranch}
itemId="name"
labelId="name"
selectedItemId={$baseBranch.branchName}
wide
disabled={$activeBranches.length > 0}
>
<SelectItem slot="template" let:item let:selected {selected}>
{item.name}
</SelectItem>
</Select>
<Button
size="cta"
style="ghost"
kind="solid"
on:click={onSetBaseBranchClick}
id="set-base-branch"
loading={isSwitching}
disabled={selectedBranch.name === $baseBranch.branchName}
>
Switch branch
</Button>
</div>
{#if $activeBranches.length > 0}
<InfoMessage filled outlined={false}>
<svelte:fragment slot="content">
You have {$activeBranches.length === 1
? '1 active branch'
: `${$activeBranches.length} active branches`} in your workspace. Please clear the workspace
before switching the base branch.
</svelte:fragment>
</InfoMessage>
{/if}
{/await}
</form>
</SectionCard>
<Spacer />
{/if}
<style>
.fields-wrapper {
display: flex;
gap: var(--size-8);
}
.form-wrapper {
display: flex;
flex-direction: column;
gap: var(--size-16);
}
</style>

View File

@ -16,7 +16,6 @@
const branchController = getContext(BranchController); const branchController = getContext(BranchController);
const baseBranch = getContextStore(BaseBranch); const baseBranch = getContextStore(BaseBranch);
const project = getContext(Project); const project = getContext(Project);
const activeBranchesError = vbranchService.activeBranchesError; const activeBranchesError = vbranchService.activeBranchesError;
const activeBranches = vbranchService.activeBranches; const activeBranches = vbranchService.activeBranches;
@ -27,12 +26,16 @@
let dragHandle: any; let dragHandle: any;
let clone: any; let clone: any;
let isSwitching = false;
</script> </script>
{#if $activeBranchesError} {#if $activeBranchesError}
<div class="p-4" data-tauri-drag-region>Something went wrong...</div> <div class="p-4" data-tauri-drag-region>Something went wrong...</div>
{:else if !$activeBranches} {:else if !$activeBranches}
<FullviewLoading /> <FullviewLoading />
{:else if isSwitching}
<div class="middle-message">switching base branch...</div>
{:else} {:else}
<div <div
class="board" class="board"
@ -202,6 +205,12 @@
height: 100%; height: 100%;
} }
.spacer {
display: flex;
flex-direction: column;
gap: var(--size-16);
}
.branch { .branch {
height: 100%; height: 100%;
} }
@ -255,6 +264,38 @@
padding-left: var(--size-4); padding-left: var(--size-4);
} }
.branch-switcher {
margin-top: 8px;
padding: 8px;
background-color: var(--clr-bg-2);
border-width: 1px;
border-color: var(--clr-border-2);
border-radius: 4px;
}
.branch-display {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
margin-bottom: 2px;
}
.branch-name {
font-weight: 600;
font-family: monospace;
}
.middle-message {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
font-size: 2em;
color: #888888;
}
.empty-board__image-frame { .empty-board__image-frame {
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;

View File

@ -41,7 +41,7 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--clr-theme-container-light); background: var(--clr-bg-1);
border-radius: var(--radius-m) var(--radius-m) 0 0; border-radius: var(--radius-m) var(--radius-m) 0 0;
padding: 0 var(--size-14) var(--size-14); padding: 0 var(--size-14) var(--size-14);
} }

View File

@ -88,7 +88,7 @@
branchName={branch.upstreamName ?? branchName} branchName={branch.upstreamName ?? branchName}
{isUnapplied} {isUnapplied}
{hasIntegratedCommits} {hasIntegratedCommits}
remoteExists={!!branch.upstreamName} remoteExists={!!branch.upstream}
isLaneCollapsed={$isLaneCollapsed} isLaneCollapsed={$isLaneCollapsed}
/> />
{#if branch.selectedForChanges} {#if branch.selectedForChanges}
@ -118,7 +118,7 @@
branchName={branch.upstreamName ?? branchName} branchName={branch.upstreamName ?? branchName}
{isUnapplied} {isUnapplied}
{hasIntegratedCommits} {hasIntegratedCommits}
remoteExists={!!branch.upstreamName} remoteExists={!!branch.upstream}
isLaneCollapsed={$isLaneCollapsed} isLaneCollapsed={$isLaneCollapsed}
/> />

View File

@ -61,11 +61,9 @@
border: 1px solid transparent; border: 1px solid transparent;
} }
.branch-name-mesure-el { .branch-name-mesure-el {
pointer-events: auto; pointer-events: none;
visibility: hidden; visibility: hidden;
border: 2px solid transparent; border: 2px solid transparent;
top: -9999px;
left: -9999px;
color: black; color: black;
position: fixed; position: fixed;
display: inline-block; display: inline-block;

View File

@ -42,6 +42,10 @@
async function setAIConfigurationValid(user: User | undefined) { async function setAIConfigurationValid(user: User | undefined) {
aiConfigurationValid = await aiService.validateConfiguration(user?.access_token); aiConfigurationValid = await aiService.validateConfiguration(user?.access_token);
} }
function close() {
visible = false;
}
</script> </script>
{#if visible} {#if visible}
@ -52,7 +56,7 @@
label="Unapply" label="Unapply"
on:click={() => { on:click={() => {
if (branch.id) branchController.unapplyBranch(branch.id); if (branch.id) branchController.unapplyBranch(branch.id);
visible = false; close();
}} }}
/> />
{/if} {/if}
@ -69,7 +73,7 @@
} else { } else {
deleteBranchModal.show(branch); deleteBranchModal.show(branch);
} }
visible = false; close();
}} }}
/> />
@ -77,7 +81,7 @@
label="Generate branch name" label="Generate branch name"
on:click={() => { on:click={() => {
dispatch('action', 'generate-branch-name'); dispatch('action', 'generate-branch-name');
visible = false; close();
}} }}
disabled={isUnapplied || disabled={isUnapplied ||
!($aiGenEnabled && aiConfigurationValid) || !($aiGenEnabled && aiConfigurationValid) ||
@ -91,7 +95,7 @@
disabled={isUnapplied || hasIntegratedCommits} disabled={isUnapplied || hasIntegratedCommits}
on:click={() => { on:click={() => {
newRemoteName = branch.upstreamName || normalizeBranchName(branch.name) || ''; newRemoteName = branch.upstreamName || normalizeBranchName(branch.name) || '';
visible = false; close();
renameRemoteModal.show(branch); renameRemoteModal.show(branch);
}} }}
/> />
@ -101,7 +105,7 @@
label="Create branch to the left" label="Create branch to the left"
on:click={() => { on:click={() => {
branchController.createBranch({ order: branch.order }); branchController.createBranch({ order: branch.order });
visible = false; close();
}} }}
/> />
@ -109,7 +113,7 @@
label="Create branch to the right" label="Create branch to the right"
on:click={() => { on:click={() => {
branchController.createBranch({ order: branch.order + 1 }); branchController.createBranch({ order: branch.order + 1 });
visible = false; close();
}} }}
/> />
</ContextMenuSection> </ContextMenuSection>
@ -122,7 +126,6 @@
on:submit={() => { on:submit={() => {
branchController.updateBranchRemoteName(branch.id, newRemoteName); branchController.updateBranchRemoteName(branch.id, newRemoteName);
renameRemoteModal.close(); renameRemoteModal.close();
visible = false;
}} }}
> >
<svelte:fragment> <svelte:fragment>
@ -136,9 +139,9 @@
</Modal> </Modal>
<Modal width="small" title="Delete branch" bind:this={deleteBranchModal} let:item={branch}> <Modal width="small" title="Delete branch" bind:this={deleteBranchModal} let:item={branch}>
<div> <svelte:fragment>
Deleting <code class="code-string">{branch.name}</code> cannot be undone. Deleting <code class="code-string">{branch.name}</code> cannot be undone.
</div> </svelte:fragment>
<svelte:fragment slot="controls" let:close let:item={branch}> <svelte:fragment slot="controls" let:close let:item={branch}>
<Button style="ghost" kind="solid" on:click={close}>Cancel</Button> <Button style="ghost" kind="solid" on:click={close}>Cancel</Button>
<Button <Button
@ -146,7 +149,7 @@
kind="solid" kind="solid"
on:click={async () => { on:click={async () => {
await branchController.deleteBranch(branch.id); await branchController.deleteBranch(branch.id);
visible = false; deleteBranchModal.close();
}} }}
> >
Delete Delete

View File

@ -12,7 +12,7 @@
export let id: string | undefined = undefined; export let id: string | undefined = undefined;
export let loading = false; export let loading = false;
export let tabindex: number | undefined = undefined; export let tabindex: number | undefined = undefined;
export let type: 'submit' | 'reset' | undefined = undefined; export let type: 'submit' | 'reset' | 'button' | undefined = undefined;
// Layout props // Layout props
export let reversedDirection: boolean = false; export let reversedDirection: boolean = false;
export let width: number | undefined = undefined; export let width: number | undefined = undefined;
@ -102,39 +102,7 @@
&:disabled { &:disabled {
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
opacity: 0.7; opacity: 0.5;
&.neutral.solid,
&.pop.solid,
&.success.solid,
&.error.solid,
&.warning.solid,
&.purple.solid {
--btn-clr: var(--clr-text-2);
--btn-bg: var(--clr-bg-3);
& .badge {
--btn-bg: var(--clr-scale-ntrl-100);
}
}
&.neutral.soft,
&.pop.soft,
&.success.soft,
&.error.soft,
&.warning.soft,
&.purple.soft {
--btn-clr: var(--clr-text-2);
--btn-bg: var(--clr-bg-3);
}
&.ghost {
--btn-clr: var(--clr-text-2);
}
&.ghost.solid {
border-color: var(--clr-bg-3);
}
} }
&.wide { &.wide {
display: flex; display: flex;
@ -238,7 +206,7 @@
.pop { .pop {
&.soft { &.soft {
--btn-clr: var(--clr-theme-pop-on-container); --btn-clr: var(--clr-theme-pop-on-soft);
--btn-bg: var(--clr-scale-pop-80); --btn-bg: var(--clr-scale-pop-80);
/* if button */ /* if button */
&:not(.not-clickable, &:disabled):hover { &:not(.not-clickable, &:disabled):hover {
@ -261,7 +229,7 @@
.success { .success {
&.soft { &.soft {
--btn-clr: var(--clr-theme-succ-on-container); --btn-clr: var(--clr-theme-succ-on-soft);
--btn-bg: var(--clr-scale-succ-80); --btn-bg: var(--clr-scale-succ-80);
/* if button */ /* if button */
&:not(.not-clickable, &:disabled):hover { &:not(.not-clickable, &:disabled):hover {
@ -284,7 +252,7 @@
.error { .error {
&.soft { &.soft {
--btn-clr: var(--clr-theme-err-on-container); --btn-clr: var(--clr-theme-err-on-soft);
--btn-bg: var(--clr-scale-err-80); --btn-bg: var(--clr-scale-err-80);
/* if button */ /* if button */
&:not(.not-clickable, &:disabled):hover { &:not(.not-clickable, &:disabled):hover {
@ -307,7 +275,7 @@
.warning { .warning {
&.soft { &.soft {
--btn-clr: var(--clr-theme-warn-on-container); --btn-clr: var(--clr-theme-warn-on-soft);
--btn-bg: var(--clr-scale-warn-80); --btn-bg: var(--clr-scale-warn-80);
/* if button */ /* if button */
&:not(.not-clickable, &:disabled):hover { &:not(.not-clickable, &:disabled):hover {
@ -330,7 +298,7 @@
.purple { .purple {
&.soft { &.soft {
--btn-clr: var(--clr-theme-purp-on-container); --btn-clr: var(--clr-theme-purp-on-soft);
--btn-bg: var(--clr-scale-purp-80); --btn-bg: var(--clr-scale-purp-80);
/* if button */ /* if button */
&:not(.not-clickable, &:disabled):hover { &:not(.not-clickable, &:disabled):hover {

View File

@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import BranchFilesList from './BranchFilesList.svelte'; import BranchFilesList from './BranchFilesList.svelte';
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import Button from '$lib/components/Button.svelte';
import CommitMessageInput from '$lib/components/CommitMessageInput.svelte';
import Modal from '$lib/components/Modal.svelte';
import Tag from '$lib/components/Tag.svelte'; import Tag from '$lib/components/Tag.svelte';
import TimeAgo from '$lib/components/TimeAgo.svelte'; import TimeAgo from '$lib/components/TimeAgo.svelte';
import { persistedCommitMessage } from '$lib/config/config'; import { persistedCommitMessage } from '$lib/config/config';
import { featureAdvancedCommitOperations } from '$lib/config/uiFeatureFlags';
import { draggable } from '$lib/dragging/draggable'; import { draggable } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables'; import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import { getContext, getContextStore } from '$lib/utils/context'; import { getContext, getContextStore } from '$lib/utils/context';
@ -26,6 +30,7 @@
const project = getContext(Project); const project = getContext(Project);
const selectedFiles = getSelectedFiles(); const selectedFiles = getSelectedFiles();
const fileIdSelection = getContext(FileIdSelection); const fileIdSelection = getContext(FileIdSelection);
const advancedCommitOperations = featureAdvancedCommitOperations();
const commitStore = createCommitStore(commit); const commitStore = createCommitStore(commit);
$: commitStore.set(commit); $: commitStore.set(commit);
@ -47,6 +52,7 @@
function toggleFiles() { function toggleFiles() {
showFiles = !showFiles; showFiles = !showFiles;
if (showFiles) loadFiles(); if (showFiles) loadFiles();
} }
@ -56,22 +62,76 @@
} }
} }
function resetHeadCommit() { function undoCommit(commit: Commit | RemoteCommit) {
if (!branch || !$baseBranch) { if (!branch || !$baseBranch) {
console.error('Unable to reset head commit'); console.error('Unable to undo commit');
return; return;
} }
if (branch.commits.length > 1) { branchController.undoCommit(branch.id, commit.id);
branchController.resetBranch(branch.id, branch.commits[1].id);
} else if (branch.commits.length === 1 && $baseBranch) {
branchController.resetBranch(branch.id, $baseBranch.baseSha);
}
} }
const isUndoable = isHeadCommit && !isUnapplied; function insertBlankCommit(commit: Commit | RemoteCommit, offset: number) {
if (!branch || !$baseBranch) {
console.error('Unable to insert commit');
return;
}
branchController.insertBlankCommit(branch.id, commit.id, offset);
}
function reorderCommit(commit: Commit | RemoteCommit, offset: number) {
if (!branch || !$baseBranch) {
console.error('Unable to move commit');
return;
}
branchController.reorderCommit(branch.id, commit.id, offset);
}
let isUndoable = false;
$: if ($advancedCommitOperations) {
isUndoable = !!branch?.active && commit instanceof Commit;
} else {
isUndoable = isHeadCommit;
}
const hasCommitUrl = !commit.isLocal && commitUrl; const hasCommitUrl = !commit.isLocal && commitUrl;
let commitMessageModal: Modal;
let commitMessageValid = false;
let description = '';
function openCommitMessageModal(e: Event) {
e.stopPropagation();
description = commit.description;
commitMessageModal.show();
}
function submitCommitMessageModal() {
commit.description = description;
if (branch) {
branchController.updateCommitMessage(branch.id, commit.id, description);
}
commitMessageModal.close();
}
</script> </script>
<Modal bind:this={commitMessageModal}>
<CommitMessageInput bind:commitMessage={description} bind:valid={commitMessageValid} />
<svelte:fragment slot="controls">
<Button style="ghost" kind="solid" on:click={() => commitMessageModal.close()}>Cancel</Button>
<Button
style="pop"
kind="solid"
grow
disabled={!commitMessageValid}
on:click={submitCommitMessageModal}>Submit</Button
>
</svelte:fragment>
</Modal>
<div <div
use:draggable={commit instanceof Commit use:draggable={commit instanceof Commit
? { ? {
@ -83,11 +143,28 @@
> >
<div class="commit__header" on:click={toggleFiles} on:keyup={onKeyup} role="button" tabindex="0"> <div class="commit__header" on:click={toggleFiles} on:keyup={onKeyup} role="button" tabindex="0">
<div class="commit__message"> <div class="commit__message">
{#if $advancedCommitOperations}
{#if !showFiles}
<div class="commit__id">
<code>{commit.id.substring(0, 6)}</code>
</div>
{/if}
{/if}
<div class="commit__row"> <div class="commit__row">
{#if isUndoable}
{#if commit.descriptionTitle}
<span class="commit__title text-semibold text-base-12" class:truncate={!showFiles}> <span class="commit__title text-semibold text-base-12" class:truncate={!showFiles}>
{commit.descriptionTitle} {commit.descriptionTitle}
</span> </span>
{#if isUndoable && !showFiles} {:else}
<span
class="commit__title_no_desc text-base-12 text-zinc-400"
class:truncate={!showFiles}
>
<i>empty commit message</i>
</span>
{/if}
{#if !showFiles}
<Tag <Tag
style="ghost" style="ghost"
kind="solid" kind="solid"
@ -96,18 +173,29 @@
on:click={(e) => { on:click={(e) => {
currentCommitMessage.set(commit.description); currentCommitMessage.set(commit.description);
e.stopPropagation(); e.stopPropagation();
resetHeadCommit(); undoCommit(commit);
}}>Undo</Tag }}>Undo</Tag
> >
{/if} {/if}
{:else}
<span class="commit__title text-base-12" class:truncate={!showFiles}>
{commit.descriptionTitle}
</span>
{/if}
</div> </div>
{#if showFiles && commit.descriptionBody} {#if showFiles}
{#if commit.descriptionBody}
<div class="commit__row" transition:slide={{ duration: 100 }}> <div class="commit__row" transition:slide={{ duration: 100 }}>
<span class="commit__body text-base-body-12"> <span class="commit__body text-base-body-12">
{commit.descriptionBody} {commit.descriptionBody}
</span> </span>
</div> </div>
{/if} {/if}
{#if $advancedCommitOperations && isUndoable}
<Tag clickable on:click={openCommitMessageModal}>Edit</Tag>
{/if}
{/if}
</div> </div>
<div class="commit__row"> <div class="commit__row">
<div class="commit__author"> <div class="commit__author">
@ -130,12 +218,50 @@
{#if showFiles} {#if showFiles}
<div class="files-container" transition:slide={{ duration: 100 }}> <div class="files-container" transition:slide={{ duration: 100 }}>
<BranchFilesList {files} {isUnapplied} readonly /> <BranchFilesList {files} {isUnapplied} />
</div> </div>
{#if hasCommitUrl || isUndoable} {#if hasCommitUrl || isUndoable}
<div class="files__footer"> <div class="files__footer">
{#if isUndoable} {#if isUndoable}
{#if $advancedCommitOperations}
<Tag
style="ghost"
kind="solid"
clickable
on:click={(e) => {
e.stopPropagation();
reorderCommit(commit, -1);
}}>Move Up</Tag
>
<Tag
style="ghost"
kind="solid"
clickable
on:click={(e) => {
e.stopPropagation();
reorderCommit(commit, 1);
}}>Move Down</Tag
>
<Tag
style="ghost"
kind="solid"
clickable
on:click={(e) => {
e.stopPropagation();
insertBlankCommit(commit, -1);
}}>Add Before</Tag
>
<Tag
style="ghost"
kind="solid"
clickable
on:click={(e) => {
e.stopPropagation();
insertBlankCommit(commit, 1);
}}>Add After</Tag
>
{/if}
<Tag <Tag
style="ghost" style="ghost"
kind="solid" kind="solid"
@ -144,7 +270,7 @@
on:click={(e) => { on:click={(e) => {
currentCommitMessage.set(commit.description); currentCommitMessage.set(commit.description);
e.stopPropagation(); e.stopPropagation();
resetHeadCommit(); undoCommit(commit);
}}>Undo</Tag }}>Undo</Tag
> >
{/if} {/if}
@ -221,6 +347,11 @@
color: var(--clr-scale-ntrl-0); color: var(--clr-scale-ntrl-0);
width: 100%; width: 100%;
} }
.commit__title_no_desc {
flex: 1;
display: block;
width: 100%;
}
.commit__body { .commit__body {
flex: 1; flex: 1;
@ -237,6 +368,21 @@
gap: var(--size-8); gap: var(--size-8);
} }
.commit__id {
display: flex;
align-items: center;
justify-content: center;
margin-top: -14px;
}
.commit__id > code {
background-color: #eeeeee;
padding: 1px 12px;
color: #888888;
font-size: x-small;
border-radius: 0px 0px 6px 6px;
margin-bottom: -8px;
}
.commit__author { .commit__author {
display: block; display: block;
flex: 1; flex: 1;
@ -268,6 +414,7 @@
.files__footer { .files__footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap;
gap: var(--size-8); gap: var(--size-8);
padding: var(--size-14); padding: var(--size-14);
background-color: var(--clr-bg-1); background-color: var(--clr-bg-1);

View File

@ -1,80 +1,31 @@
<script lang="ts"> <script lang="ts">
import Button from './Button.svelte'; import Button from './Button.svelte';
import { AIService } from '$lib/ai/service'; import CommitMessageInput from '$lib/components/CommitMessageInput.svelte';
import Checkbox from '$lib/components/Checkbox.svelte'; import { projectRunCommitHooks, persistedCommitMessage } from '$lib/config/config';
import DropDownButton from '$lib/components/DropDownButton.svelte';
import Icon from '$lib/components/Icon.svelte';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import {
projectAiGenEnabled,
projectCommitGenerationExtraConcise,
projectCommitGenerationUseEmojis,
projectRunCommitHooks,
persistedCommitMessage
} from '$lib/config/config';
import { showError } from '$lib/notifications/toasts';
import { User } from '$lib/stores/user';
import { splitMessage } from '$lib/utils/commitMessage';
import { getContext, getContextStore } from '$lib/utils/context'; import { getContext, getContextStore } from '$lib/utils/context';
import { tooltip } from '$lib/utils/tooltip';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { useResize } from '$lib/utils/useResize';
import { BranchController } from '$lib/vbranches/branchController'; import { BranchController } from '$lib/vbranches/branchController';
import { Ownership } from '$lib/vbranches/ownership'; import { Ownership } from '$lib/vbranches/ownership';
import { Branch, type LocalFile } from '$lib/vbranches/types'; import { Branch } from '$lib/vbranches/types';
import { createEventDispatcher, onMount } from 'svelte';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import { fly, slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
const aiService = getContext(AIService);
const dispatch = createEventDispatcher<{
action: 'generate-branch-name';
}>();
export let projectId: string; export let projectId: string;
export let expanded: Writable<boolean>; export let expanded: Writable<boolean>;
const branchController = getContext(BranchController); const branchController = getContext(BranchController);
const selectedOwnership = getContextStore(Ownership); const selectedOwnership = getContextStore(Ownership);
const branch = getContextStore(Branch); const branch = getContextStore(Branch);
const user = getContextStore(User);
const aiGenEnabled = projectAiGenEnabled(projectId);
const runCommitHooks = projectRunCommitHooks(projectId); const runCommitHooks = projectRunCommitHooks(projectId);
const commitMessage = persistedCommitMessage(projectId, $branch.id); const commitMessage = persistedCommitMessage(projectId, $branch.id);
const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(projectId);
const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(projectId);
let isCommitting = false; let isCommitting = false;
let aiLoading = false;
let contextMenu: ContextMenu; let commitMessageValid = false;
let titleTextArea: HTMLTextAreaElement;
let descriptionTextArea: HTMLTextAreaElement;
$: ({ title, description } = splitMessage($commitMessage));
$: if ($commitMessage) updateHeights();
function concatMessage(title: string, description: string) {
return `${title}\n\n${description}`;
}
function focusTextareaOnMount(el: HTMLTextAreaElement) {
el.focus();
}
function updateHeights() {
useAutoHeight(titleTextArea);
useAutoHeight(descriptionTextArea);
}
async function commit() { async function commit() {
const message = concatMessage(title, description); const message = $commitMessage;
isCommitting = true; isCommitting = true;
try { try {
await branchController.commitBranch( await branchController.commitBranch(
@ -88,158 +39,16 @@
isCommitting = false; isCommitting = false;
} }
} }
async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) =>
f.hunks.filter((h) => $selectedOwnership.contains(f.id, h.id))
);
// Branches get their names generated only if there are at least 4 lines of code
// If the change is a 'one-liner', the branch name is either left as "virtual branch"
// or the user has to manually trigger the name generation from the meatball menu
// This saves people this extra click
if ($branch.name.toLowerCase().includes('virtual branch')) {
dispatch('action', 'generate-branch-name');
}
aiLoading = true;
try {
const generatedMessage = await aiService.summarizeCommit({
hunks,
useEmojiStyle: $commitGenerationUseEmojis,
useBriefStyle: $commitGenerationExtraConcise,
userToken: $user?.access_token
});
if (generatedMessage) {
$commitMessage = generatedMessage;
} else {
throw new Error('Prompt generated no response');
}
} catch (e: any) {
showError('Failed to generate commit message', e);
} finally {
aiLoading = false;
}
setTimeout(() => {
updateHeights();
descriptionTextArea.focus();
}, 0);
}
let aiConfigurationValid = false;
onMount(async () => {
aiConfigurationValid = await aiService.validateConfiguration($user?.access_token);
});
</script> </script>
<div class="commit-box" class:commit-box__expanded={$expanded}> <div class="commit-box" class:commit-box__expanded={$expanded}>
{#if $expanded} {#if $expanded}
<div class="commit-box__expander" transition:slide={{ duration: 150, easing: quintOut }}> <div class="commit-box__expander" transition:slide={{ duration: 150, easing: quintOut }}>
<div class="commit-box__textarea-wrapper text-input"> <CommitMessageInput
<textarea bind:commitMessage={$commitMessage}
value={title} bind:valid={commitMessageValid}
placeholder="Commit summary" {commit}
disabled={aiLoading}
class="text-base-body-13 text-semibold commit-box__textarea commit-box__textarea__title"
spellcheck="false"
rows="1"
bind:this={titleTextArea}
use:focusTextareaOnMount
use:useResize={() => {
useAutoHeight(titleTextArea);
}}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
$commitMessage = concatMessage(e.currentTarget.value, description);
}}
on:keydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') commit();
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
descriptionTextArea.focus();
}
}}
/> />
{#if title.length > 0 || description}
<textarea
value={description}
disabled={aiLoading}
placeholder="Commit description (optional)"
class="text-base-body-13 commit-box__textarea commit-box__textarea__description"
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
use:useResize={() => useAutoHeight(descriptionTextArea)}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
$commitMessage = concatMessage(title, e.currentTarget.value);
}}
on:keydown={(e) => {
const value = e.currentTarget.value;
if (e.key == 'Backspace' && value.length == 0) {
e.preventDefault();
titleTextArea.focus();
useAutoHeight(e.currentTarget);
} else if (e.key == 'a' && (e.metaKey || e.ctrlKey) && value.length == 0) {
// select previous textarea on cmd+a if this textarea is empty
e.preventDefault();
titleTextArea.select();
}
}}
/>
{/if}
{#if title.length > 50}
<div
transition:fly={{ y: 2, duration: 150 }}
class="commit-box__textarea-tooltip"
use:tooltip={{
text: '50 characters or less is best. Extra info can be added in the description.',
delay: 200
}}
>
<Icon name="blitz" />
</div>
{/if}
<div
class="commit-box__texarea-actions"
use:tooltip={$aiGenEnabled && aiConfigurationValid
? ''
: 'You must be logged in or have provided your own API key and have summary generation enabled to use this feature'}
>
<DropDownButton
style="ghost"
kind="solid"
icon="ai-small"
disabled={!($aiGenEnabled && aiConfigurationValid)}
loading={aiLoading}
on:click={async () => await generateCommitMessage($branch.files)}
>
Generate message
<ContextMenu type="checklist" slot="context-menu" bind:this={contextMenu}>
<ContextMenuSection>
<ContextMenuItem
label="Extra concise"
on:click={() => ($commitGenerationExtraConcise = !$commitGenerationExtraConcise)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationExtraConcise} />
</ContextMenuItem>
<ContextMenuItem
label="Use emojis 😎"
on:click={() => ($commitGenerationUseEmojis = !$commitGenerationUseEmojis)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationUseEmojis} />
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
</DropDownButton>
</div>
</div>
</div> </div>
{/if} {/if}
<div class="actions"> <div class="actions">
@ -260,7 +69,7 @@
kind="solid" kind="solid"
grow grow
loading={isCommitting} loading={isCommitting}
disabled={(isCommitting || !title || $selectedOwnership.isEmpty()) && $expanded} disabled={(isCommitting || !commitMessageValid || $selectedOwnership.isEmpty()) && $expanded}
id="commit-to-branch" id="commit-to-branch"
on:click={() => { on:click={() => {
if ($expanded) { if ($expanded) {
@ -292,57 +101,6 @@
margin-bottom: var(--size-12); margin-bottom: var(--size-12);
} }
.commit-box__textarea-wrapper {
display: flex;
position: relative;
padding: 0 0 var(--size-48);
flex-direction: column;
gap: var(--size-4);
}
.commit-box__textarea {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--size-16);
background: none;
resize: none;
&:focus {
outline: none;
}
&::placeholder {
color: oklch(from var(--clr-scale-ntrl-30) l c h / 0.4);
}
}
.commit-box__textarea-tooltip {
position: absolute;
display: flex;
bottom: var(--size-12);
left: var(--size-12);
padding: var(--size-2);
border-radius: 100%;
background: var(--clr-bg-2);
color: var(--clr-scale-ntrl-40);
}
.commit-box__textarea__title {
padding: var(--size-12) var(--size-12) 0 var(--size-12);
}
.commit-box__textarea__description {
padding: 0 var(--size-12) 0 var(--size-12);
}
.commit-box__texarea-actions {
position: absolute;
display: flex;
right: var(--size-12);
bottom: var(--size-12);
}
.actions { .actions {
display: flex; display: flex;
justify-content: right; justify-content: right;

View File

@ -6,8 +6,15 @@
import { dropzone } from '$lib/dragging/dropzone'; import { dropzone } from '$lib/dragging/dropzone';
import { getContext, getContextStore } from '$lib/utils/context'; import { getContext, getContextStore } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController'; import { BranchController } from '$lib/vbranches/branchController';
import { filesToOwnership } from '$lib/vbranches/ownership'; import { filesToOwnership, filesToSimpleOwnership } from '$lib/vbranches/ownership';
import { RemoteCommit, Branch, type Commit, BaseBranch } from '$lib/vbranches/types'; import {
RemoteCommit,
Branch,
type Commit,
BaseBranch,
LocalFile,
RemoteFile
} from '$lib/vbranches/types';
export let commit: Commit | RemoteCommit; export let commit: Commit | RemoteCommit;
export let isHeadCommit: boolean; export let isHeadCommit: boolean;
@ -32,11 +39,6 @@
return false; return false;
} }
// only allow to amend the head commit
if (commit.id != $branch.commits.at(0)?.id) {
return false;
}
if (data instanceof DraggableHunk && data.branchId == $branch.id) { if (data instanceof DraggableHunk && data.branchId == $branch.id) {
return true; return true;
} else if (data instanceof DraggableFile && data.branchId == $branch.id) { } else if (data instanceof DraggableFile && data.branchId == $branch.id) {
@ -47,15 +49,26 @@
}; };
} }
function onAmend(data: DraggableFile | DraggableHunk) { function onAmend(commit: Commit | RemoteCommit) {
return (data: any) => {
if (data instanceof DraggableHunk) { if (data instanceof DraggableHunk) {
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`; const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
branchController.amendBranch($branch.id, newOwnership); branchController.amendBranch($branch.id, commit.id, newOwnership);
} else if (data instanceof DraggableFile) { } else if (data instanceof DraggableFile) {
if (data.file instanceof LocalFile) {
// this is an uncommitted file change being amended to a previous commit
const newOwnership = filesToOwnership(data.files); const newOwnership = filesToOwnership(data.files);
branchController.amendBranch($branch.id, newOwnership); branchController.amendBranch($branch.id, commit.id, newOwnership);
} else if (data.file instanceof RemoteFile) {
// this is a file from a commit, rather than an uncommitted file
const newOwnership = filesToSimpleOwnership(data.files);
if (data.commit) {
branchController.moveCommitFile($branch.id, data.commit.id, commit.id, newOwnership);
} }
} }
}
};
}
function acceptSquash(commit: Commit | RemoteCommit) { function acceptSquash(commit: Commit | RemoteCommit) {
if (commit instanceof RemoteCommit) { if (commit instanceof RemoteCommit) {
@ -104,7 +117,7 @@
active: 'amend-dz-active', active: 'amend-dz-active',
hover: 'amend-dz-hover', hover: 'amend-dz-hover',
accepts: acceptAmend(commit), accepts: acceptAmend(commit),
onDrop: onAmend onDrop: onAmend(commit)
}} }}
use:dropzone={{ use:dropzone={{
active: 'squash-dz-active', active: 'squash-dz-active',

View File

@ -0,0 +1,268 @@
<script lang="ts">
import { AIService } from '$lib/ai/service';
import { Project } from '$lib/backend/projects';
import Checkbox from '$lib/components/Checkbox.svelte';
import DropDownButton from '$lib/components/DropDownButton.svelte';
import Icon from '$lib/components/Icon.svelte';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import {
projectAiGenEnabled,
projectCommitGenerationExtraConcise,
projectCommitGenerationUseEmojis
} from '$lib/config/config';
import { showError } from '$lib/notifications/toasts';
import { User } from '$lib/stores/user';
import { splitMessage } from '$lib/utils/commitMessage';
import { getContext, getContextStore } from '$lib/utils/context';
import { tooltip } from '$lib/utils/tooltip';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { useResize } from '$lib/utils/useResize';
import { Ownership } from '$lib/vbranches/ownership';
import { Branch, LocalFile } from '$lib/vbranches/types';
import { createEventDispatcher, onMount } from 'svelte';
import { fly } from 'svelte/transition';
export let commitMessage: string;
export let valid: boolean = false;
export let commit: (() => void) | undefined = undefined;
const user = getContextStore(User);
const selectedOwnership = getContextStore(Ownership);
const aiService = getContext(AIService);
const branch = getContextStore(Branch);
const project = getContext(Project);
const dispatch = createEventDispatcher<{
action: 'generate-branch-name';
}>();
const aiGenEnabled = projectAiGenEnabled(project.id);
const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(project.id);
const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(project.id);
let aiLoading = false;
let aiConfigurationValid = false;
let contextMenu: ContextMenu;
let titleTextArea: HTMLTextAreaElement;
let descriptionTextArea: HTMLTextAreaElement;
$: ({ title, description } = splitMessage(commitMessage));
$: if (commitMessage) updateHeights();
$: valid = !!title;
function concatMessage(title: string, description: string) {
return `${title}\n\n${description}`;
}
function updateHeights() {
useAutoHeight(titleTextArea);
useAutoHeight(descriptionTextArea);
}
function focusTextareaOnMount(el: HTMLTextAreaElement) {
el.focus();
}
async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) =>
f.hunks.filter((h) => $selectedOwnership.contains(f.id, h.id))
);
// Branches get their names generated only if there are at least 4 lines of code
// If the change is a 'one-liner', the branch name is either left as "virtual branch"
// or the user has to manually trigger the name generation from the meatball menu
// This saves people this extra click
if ($branch.name.toLowerCase().includes('virtual branch')) {
dispatch('action', 'generate-branch-name');
}
aiLoading = true;
try {
const generatedMessage = await aiService.summarizeCommit({
hunks,
useEmojiStyle: $commitGenerationUseEmojis,
useBriefStyle: $commitGenerationExtraConcise,
userToken: $user?.access_token
});
if (generatedMessage) {
commitMessage = generatedMessage;
} else {
throw new Error('Prompt generated no response');
}
} catch (e: any) {
showError('Failed to generate commit message', e);
} finally {
aiLoading = false;
}
setTimeout(() => {
updateHeights();
descriptionTextArea.focus();
}, 0);
}
onMount(async () => {
aiConfigurationValid = await aiService.validateConfiguration($user?.access_token);
});
</script>
<div class="commit-box__textarea-wrapper text-input">
<textarea
value={title}
placeholder="Commit summary"
disabled={aiLoading}
class="text-base-body-13 text-semibold commit-box__textarea commit-box__textarea__title"
spellcheck="false"
rows="1"
bind:this={titleTextArea}
use:focusTextareaOnMount
use:useResize={() => {
useAutoHeight(titleTextArea);
}}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(e.currentTarget.value, description);
}}
on:keydown={(e) => {
if (commit && (e.ctrlKey || e.metaKey) && e.key === 'Enter') commit();
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
descriptionTextArea.focus();
}
}}
/>
{#if title.length > 0 || description}
<textarea
value={description}
disabled={aiLoading}
placeholder="Commit description (optional)"
class="text-base-body-13 commit-box__textarea commit-box__textarea__description"
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
use:useResize={() => useAutoHeight(descriptionTextArea)}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(title, e.currentTarget.value);
}}
on:keydown={(e) => {
const value = e.currentTarget.value;
if (e.key == 'Backspace' && value.length == 0) {
e.preventDefault();
titleTextArea.focus();
useAutoHeight(e.currentTarget);
} else if (e.key == 'a' && (e.metaKey || e.ctrlKey) && value.length == 0) {
// select previous textarea on cmd+a if this textarea is empty
e.preventDefault();
titleTextArea.select();
}
}}
/>
{/if}
{#if title.length > 50}
<div
transition:fly={{ y: 2, duration: 150 }}
class="commit-box__textarea-tooltip"
use:tooltip={{
text: '50 characters or less is best. Extra info can be added in the description.',
delay: 200
}}
>
<Icon name="blitz" />
</div>
{/if}
<div
class="commit-box__texarea-actions"
use:tooltip={$aiGenEnabled && aiConfigurationValid
? ''
: 'You must be logged in or have provided your own API key and have summary generation enabled to use this feature'}
>
<DropDownButton
style="ghost"
kind="solid"
icon="ai-small"
disabled={!($aiGenEnabled && aiConfigurationValid)}
loading={aiLoading}
on:click={async () => await generateCommitMessage($branch.files)}
>
Generate message
<ContextMenu type="checklist" slot="context-menu" bind:this={contextMenu}>
<ContextMenuSection>
<ContextMenuItem
label="Extra concise"
on:click={() => ($commitGenerationExtraConcise = !$commitGenerationExtraConcise)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationExtraConcise} />
</ContextMenuItem>
<ContextMenuItem
label="Use emojis 😎"
on:click={() => ($commitGenerationUseEmojis = !$commitGenerationUseEmojis)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationUseEmojis} />
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
</DropDownButton>
</div>
</div>
<style lang="postcss">
.commit-box__textarea-wrapper {
display: flex;
position: relative;
padding: 0 0 var(--size-48);
flex-direction: column;
gap: var(--size-4);
}
.commit-box__textarea {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--size-16);
background: none;
resize: none;
&:focus {
outline: none;
}
&::placeholder {
color: oklch(from var(--clr-scale-ntrl-30) l c h / 0.4);
}
}
.commit-box__textarea-tooltip {
position: absolute;
display: flex;
bottom: var(--size-12);
left: var(--size-12);
padding: var(--size-2);
border-radius: 100%;
background: var(--clr-bg-2);
color: var(--clr-scale-ntrl-40);
}
.commit-box__textarea__title {
padding: var(--size-12) var(--size-12) 0 var(--size-12);
}
.commit-box__textarea__description {
padding: 0 var(--size-12) 0 var(--size-12);
}
.commit-box__texarea-actions {
position: absolute;
display: flex;
right: var(--size-12);
bottom: var(--size-12);
}
</style>

View File

@ -4,7 +4,7 @@
import LargeDiffMessage from './LargeDiffMessage.svelte'; import LargeDiffMessage from './LargeDiffMessage.svelte';
import { computeAddedRemovedByHunk } from '$lib/utils/metrics'; import { computeAddedRemovedByHunk } from '$lib/utils/metrics';
import { tooltip } from '$lib/utils/tooltip'; import { tooltip } from '$lib/utils/tooltip';
import { getLocalCommits } from '$lib/vbranches/contexts'; import { getLocalCommits, getRemoteCommits } from '$lib/vbranches/contexts';
import { getLockText } from '$lib/vbranches/tooltip'; import { getLockText } from '$lib/vbranches/tooltip';
import type { HunkSection, ContentSection } from '$lib/utils/fileSections'; import type { HunkSection, ContentSection } from '$lib/utils/fileSections';
@ -21,6 +21,9 @@
$: minWidth = getGutterMinWidth(maxLineNumber); $: minWidth = getGutterMinWidth(maxLineNumber);
const localCommits = isFileLocked ? getLocalCommits() : undefined; const localCommits = isFileLocked ? getLocalCommits() : undefined;
const remoteCommits = isFileLocked ? getRemoteCommits() : undefined;
const commits = isFileLocked ? ($localCommits || []).concat($remoteCommits || []) : undefined;
let alwaysShow = false; let alwaysShow = false;
function getGutterMinWidth(max: number) { function getGutterMinWidth(max: number) {
@ -52,10 +55,10 @@
<div class="indicators text-base-11"> <div class="indicators text-base-11">
<span class="added">+{added}</span> <span class="added">+{added}</span>
<span class="removed">-{removed}</span> <span class="removed">-{removed}</span>
{#if section.hunk.lockedTo && $localCommits} {#if section.hunk.lockedTo && section.hunk.lockedTo.length > 0 && commits}
<div <div
use:tooltip={{ use:tooltip={{
text: getLockText(section.hunk.lockedTo, $localCommits), text: getLockText(section.hunk.lockedTo, commits),
delay: 500 delay: 500
}} }}
> >

View File

@ -111,7 +111,7 @@
} }
}} }}
use:draggable={{ use:draggable={{
data: new DraggableFile($branch?.id || '', file, selectedFiles), data: new DraggableFile($branch?.id || '', file, $commit, selectedFiles),
disabled: readonly || isUnapplied, disabled: readonly || isUnapplied,
viewportId: 'board-viewport', viewportId: 'board-viewport',
selector: '.selected-draggable' selector: '.selected-draggable'

View File

@ -0,0 +1,90 @@
<script lang="ts">
import Button from './Button.svelte';
import { invoke } from '$lib/backend/ipc';
import { getContext } from '$lib/utils/context';
import { toHumanReadableTime } from '$lib/utils/time';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { onMount } from 'svelte';
export let projectId: string;
const snapshotsLimit = 30;
const vbranchService = getContext(VirtualBranchService);
vbranchService.activeBranches.subscribe(() => {
listSnapshots(projectId, snapshotsLimit);
});
type Trailer = {
key: string;
value: string;
};
type SnapshotDetails = {
title: string;
operation: string;
body: string | undefined;
trailers: Trailer[];
};
type Snapshot = {
id: string;
details: SnapshotDetails | undefined;
createdAt: number;
};
let snapshots: Snapshot[] = [];
async function listSnapshots(projectId: string, limit: number) {
const resp = await invoke<Snapshot[]>('list_snapshots', {
projectId: projectId,
limit: limit
});
console.log(resp);
snapshots = resp;
}
async function restoreSnapshot(projectId: string, sha: string) {
const resp = await invoke<string>('restore_snapshot', {
projectId: projectId,
sha: sha
});
console.log(resp);
}
onMount(async () => {
listSnapshots(projectId, snapshotsLimit);
});
</script>
<div class="container">
{#each snapshots as entry, idx}
<div class="card">
<div class="entry">
<div>
{entry.details?.operation}
</div>
<div>
<span>
{toHumanReadableTime(entry.createdAt)}
</span>
{#if idx != 0}
<Button
style="pop"
size="tag"
icon="undo-small"
on:click={async () => await restoreSnapshot(projectId, entry.id)}>restore</Button
>
{/if}
</div>
</div>
</div>
{/each}
</div>
<style>
.container {
width: 50rem;
padding: 0.5rem;
border-left-width: 1px;
overflow-y: auto;
}
.entry {
flex: auto;
flex-direction: column;
}
</style>

View File

@ -158,19 +158,19 @@
} }
&.error { &.error {
background-color: var(--clr-theme-err-container); background-color: var(--clr-theme-err-bg);
} }
&.pop { &.pop {
background-color: var(--clr-theme-pop-container); background-color: var(--clr-theme-pop-bg);
} }
&.warning { &.warning {
background-color: var(--clr-theme-warn-container); background-color: var(--clr-theme-warn-bg);
} }
&.success { &.success {
background-color: var(--clr-theme-succ-container); background-color: var(--clr-theme-succ-bg);
} }
} }

View File

@ -1,44 +1,55 @@
<script lang="ts"> <script lang="ts">
import Overlay from './Overlay.svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import { onMount } from 'svelte';
import OutClick from 'svelte-outclick';
import type iconsJson from '$lib/icons/icons.json'; import type iconsJson from '$lib/icons/icons.json';
export function show(newItem?: any) { let dialog: HTMLDialogElement;
item = newItem; let item: any;
modal.show(); let open = false;
}
export function close() {
item = undefined;
modal.close();
}
export let width: 'default' | 'small' | 'large' = 'default'; export let width: 'default' | 'small' | 'large' = 'default';
export let title: string | undefined = undefined; export let title: string | undefined = undefined;
export let icon: keyof typeof iconsJson | undefined = undefined; export let icon: keyof typeof iconsJson | undefined = undefined;
export let hoverText: string | undefined = undefined;
let item: any; export function show(newItem?: any) {
let modal: Overlay; item = newItem;
dialog.showModal();
open = true;
}
export function close() {
item = undefined;
dialog.close();
open = false;
}
onMount(() => {
document.body.appendChild(dialog);
});
</script> </script>
<Overlay bind:this={modal} let:close on:close {width}> <dialog
<form on:submit> class="dialog-wrap"
class:s-default={width == 'default'}
class:s-small={width == 'small'}
class:s-large={width == 'large'}
bind:this={dialog}
on:close={close}
>
{#if open}
<OutClick on:outclick={close}>
<div class="dialog">
<form class="modal-content" on:submit>
{#if title} {#if title}
<div class="modal__header"> <div class="modal__header">
<div class="modal__header__content" class:adjust-header={$$slots.header_controls}>
{#if icon} {#if icon}
<Icon name={icon} /> <Icon name={icon} />
{/if} {/if}
<h2 class="text-base-14 text-semibold" title={hoverText}> <h2 class="text-base-14 text-semibold">
{title} {title}
</h2> </h2>
</div> </div>
{#if $$slots.header_controls}
<div class="modal__header__actions">
<slot name="header_controls" />
</div>
{/if}
</div>
{/if} {/if}
<div class="modal__body custom-scrollbar"> <div class="modal__body custom-scrollbar">
@ -51,9 +62,41 @@
</div> </div>
{/if} {/if}
</form> </form>
</Overlay> </div>
</OutClick>
{/if}
</dialog>
<style lang="postcss"> <style lang="postcss">
.dialog-wrap {
position: relative;
width: 100%;
max-height: calc(100vh - 5rem);
border-radius: var(--radius-l);
background-color: var(--clr-bg-1);
border: 1px solid var(--clr-border-2);
box-shadow: var(--fx-shadow-l);
}
.dialog {
display: flex;
flex-direction: column;
}
/* modifiers */
.s-large {
max-width: calc(var(--size-64) * 13);
}
.s-default {
max-width: calc(var(--size-64) * 9);
}
.s-small {
max-width: calc(var(--size-64) * 6);
}
.modal__header { .modal__header {
display: flex; display: flex;
padding: var(--size-16); padding: var(--size-16);
@ -61,17 +104,6 @@
border-bottom: 1px solid var(--clr-border-2); border-bottom: 1px solid var(--clr-border-2);
} }
.modal__header__content {
display: flex;
gap: var(--size-8);
flex: 1;
}
.modal__header__actions {
display: flex;
gap: var(--size-8);
}
.modal__body { .modal__body {
overflow: auto; overflow: auto;
padding: var(--size-16); padding: var(--size-16);
@ -86,8 +118,4 @@
border-top: 1px solid var(--clr-border-2); border-top: 1px solid var(--clr-border-2);
background-color: var(--clr-bg-1); background-color: var(--clr-bg-1);
} }
.adjust-header {
margin-top: var(--size-6);
}
</style> </style>

View File

@ -1,69 +0,0 @@
<script lang="ts">
import OutClick from 'svelte-outclick';
let dialog: HTMLDialogElement;
let open = false;
export let width: 'default' | 'small' | 'large' = 'default';
export function show() {
if (open) return;
dialog.showModal();
open = true;
}
export function close() {
if (!open) return;
dialog.close();
open = false;
}
</script>
<dialog
class="dialog"
class:open-modal={open}
class:s-default={width == 'default'}
class:s-small={width == 'small'}
class:s-large={width == 'large'}
bind:this={dialog}
on:close={close}
on:close
>
{#if open}
<OutClick on:outclick={close}>
<slot {close} isOpen={open} />
</OutClick>
{/if}
</dialog>
<style lang="postcss">
.dialog {
flex-direction: column;
position: relative;
width: 100%;
max-height: calc(100vh - 5rem);
border-radius: var(--radius-l);
background-color: var(--clr-bg-1);
border: 1px solid var(--clr-border-2);
box-shadow: var(--fx-shadow-l);
}
/* modifiers */
.s-large {
max-width: calc(var(--size-64) * 13);
}
.s-default {
max-width: calc(var(--size-64) * 9);
}
.s-small {
max-width: calc(var(--size-64) * 6);
}
.open-modal {
display: flex;
}
</style>

View File

@ -87,7 +87,7 @@
color: var(--clr-scale-ntrl-0); color: var(--clr-scale-ntrl-0);
gap: var(--size-12); gap: var(--size-12);
padding: var(--size-20); padding: var(--size-20);
background-color: var(--clr-theme-err-container); background-color: var(--clr-theme-err-bg);
border-radius: var(--radius-m); border-radius: var(--radius-m);
margin-bottom: var(--size-12); margin-bottom: var(--size-12);
} }

View File

@ -122,11 +122,11 @@
function getChecksCount(status: ChecksStatus): string { function getChecksCount(status: ChecksStatus): string {
if (!status) return 'Running checks'; if (!status) return 'Running checks';
const completed = status.completed || 0;
const skipped = status.skipped || 0; const skipped = status.skipped || 0;
const total = (status.totalCount || 0) - skipped; const total = (status.totalCount || 0) - skipped;
const queued = total - (status.queued || 0);
return `Running checks ${queued}/${total}`; return `Checks completed ${completed}/${total}`;
} }
function getChecksTagInfo( function getChecksTagInfo(

View File

@ -48,7 +48,9 @@
reversedDirection reversedDirection
loading={isDeleting} loading={isDeleting}
icon="bin-small" icon="bin-small"
on:click={onDeleteClicked}>Remove</Button on:click={() => {
onDeleteClicked().then(close);
}}>Remove</Button
> >
<Button style="pop" kind="solid" on:click={close}>Cancel</Button> <Button style="pop" kind="solid" on:click={close}>Cancel</Button>
</svelte:fragment> </svelte:fragment>

View File

@ -93,11 +93,11 @@
} }
.success { .success {
background: var(--clr-theme-pop-container); background: var(--clr-theme-pop-bg);
} }
.error { .error {
background: var(--clr-theme-warn-container); background: var(--clr-theme-warn-bg);
} }
.extra-padding { .extra-padding {
padding: var(--size-20); padding: var(--size-20);

View File

@ -34,12 +34,6 @@
dispatch('select', { value }); dispatch('select', { value });
listOpen = false; listOpen = false;
} }
function scrollIntoView() {
const selected = element.querySelector('.selected');
if (selected) selected.scrollIntoView();
}
function setMaxHeight() { function setMaxHeight() {
maxHeight = window.innerHeight - element.getBoundingClientRect().bottom - maxPadding; maxHeight = window.innerHeight - element.getBoundingClientRect().bottom - maxPadding;
} }
@ -52,7 +46,6 @@
function openList() { function openList() {
setMaxHeight(); setMaxHeight();
listOpen = true; listOpen = true;
setTimeout(() => scrollIntoView(), 50);
} }
function closeList() { function closeList() {

View File

@ -59,4 +59,12 @@
overflow-x: hidden; overflow-x: hidden;
} }
} }
.selected {
background-color: var(--clr-bg-2);
& .label {
opacity: 0.5;
}
}
</style> </style>

View File

@ -44,7 +44,7 @@
opacity: 0.5; opacity: 0.5;
} }
.success.setup-feature { .success.setup-feature {
background: var(--clr-theme-pop-container, #f3fcfb); background: var(--clr-theme-pop-bg);
} }
.setup-feature__content { .setup-feature__content {

View File

@ -40,7 +40,7 @@
color: var(--clr-scale-ntrl-0); color: var(--clr-scale-ntrl-0);
gap: var(--size-12); gap: var(--size-12);
padding: var(--size-20); padding: var(--size-20);
background-color: var(--clr-theme-err-container); background-color: var(--clr-theme-err-bg);
border-radius: var(--radius-m); border-radius: var(--radius-m);
margin-bottom: var(--size-12); margin-bottom: var(--size-12);
} }

View File

@ -114,7 +114,7 @@
.pop { .pop {
&.soft { &.soft {
color: var(--clr-theme-pop-on-container); color: var(--clr-theme-pop-on-soft);
background: var(--clr-scale-pop-80); background: var(--clr-scale-pop-80);
/* if button */ /* if button */
&:not(.not-button, &:disabled):hover { &:not(.not-button, &:disabled):hover {
@ -134,7 +134,7 @@
.success { .success {
&.soft { &.soft {
color: var(--clr-theme-succ-on-container); color: var(--clr-theme-succ-on-soft);
background: var(--clr-scale-succ-80); background: var(--clr-scale-succ-80);
/* if button */ /* if button */
&:not(.not-button, &:disabled):hover { &:not(.not-button, &:disabled):hover {
@ -154,7 +154,7 @@
.error { .error {
&.soft { &.soft {
color: var(--clr-theme-err-on-container); color: var(--clr-theme-err-on-soft);
background: var(--clr-scale-err-80); background: var(--clr-scale-err-80);
/* if button */ /* if button */
&:not(.not-button, &:disabled):hover { &:not(.not-button, &:disabled):hover {
@ -174,7 +174,7 @@
.warning { .warning {
&.soft { &.soft {
color: var(--clr-theme-warn-on-container); color: var(--clr-theme-warn-on-soft);
background: var(--clr-scale-warn-80); background: var(--clr-scale-warn-80);
/* if button */ /* if button */
&:not(.not-button, &:disabled):hover { &:not(.not-button, &:disabled):hover {
@ -194,7 +194,7 @@
.purple { .purple {
&.soft { &.soft {
color: var(--clr-theme-purp-on-container); color: var(--clr-theme-purp-on-soft);
background: var(--clr-scale-purp-80); background: var(--clr-scale-purp-80);
/* if button */ /* if button */
&:not(.not-button, &:disabled):hover { &:not(.not-button, &:disabled):hover {
@ -214,43 +214,15 @@
/* modifiers */ /* modifiers */
.not-button {
cursor: default;
user-select: none;
}
.disabled { .disabled {
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
/* opacity: 0.5; */ opacity: 0.6;
&.neutral.solid,
&.pop.solid,
&.success.solid,
&.error.solid,
&.warning.solid,
&.purple.solid {
color: var(--clr-text-2);
background: oklch(from var(--clr-scale-ntrl-60) l c h / 0.15);
} }
&.neutral.soft, .not-button {
&.pop.soft, cursor: default;
&.success.soft, user-select: none;
&.error.soft,
&.warning.soft,
&.purple.soft {
color: var(--clr-text-2);
background: oklch(from var(--clr-scale-ntrl-60) l c h / 0.15);
}
&.ghost {
color: var(--clr-text-2);
}
&.ghost.solid {
border: 1px solid oklch(from var(--clr-scale-ntrl-0) l c h / 0.1);
}
} }
.reversedDirection { .reversedDirection {

View File

@ -65,7 +65,7 @@
> >
{#if icon} {#if icon}
<div class="textbox__icon"> <div class="textbox__icon">
<Icon name={icon} /> <Icon name={!disabled ? icon : 'locked'} />
</div> </div>
{/if} {/if}
@ -152,9 +152,19 @@
.textbox__input-wrap { .textbox__input-wrap {
position: relative; position: relative;
&.disabled .textbox__icon {
&.disabled {
/* background-color: var(--clr-bg-1); */
& .textbox__icon {
color: var(--clr-scale-ntrl-60); color: var(--clr-scale-ntrl-60);
} }
& .textbox__input {
color: var(--clr-text-2);
background-color: var(--clr-bg-2);
border: 1px solid var(--clr-border-3);
}
}
} }
.textbox__input { .textbox__input {
@ -198,7 +208,8 @@
} }
/* select */ /* select */
.textbox__input[type='select'] { .textbox__input[type='select']:not([disabled]),
.textbox__input[type='select']:not([readonly]) {
cursor: pointer; cursor: pointer;
} }

View File

@ -101,6 +101,16 @@
<span class="text-base-14 text-semibold">Telemetry</span> <span class="text-base-14 text-semibold">Telemetry</span>
</button> </button>
</li> </li>
<li>
<button
class="profile-sidebar__menu-item"
class:item_selected={currentSection == 'experimental'}
on:mousedown={() => onMenuClick('experimental')}
>
<Icon name="idea" />
<span class="text-base-14 text-semibold">Experimental</span>
</button>
</li>
</ul> </ul>
</div> </div>
</section> </section>

View File

@ -35,6 +35,15 @@ export function appErrorReportingEnabled() {
return persisted(true, 'appErrorReportingEnabled'); return persisted(true, 'appErrorReportingEnabled');
} }
/**
* Provides a writable store for obtaining or setting the current state of non-anonemous application metrics.
* The setting can be enabled or disabled by setting the value of the store to true or false.
* @returns A writable store with the appNonAnonMetricsEnabled config.
*/
export function appNonAnonMetricsEnabled() {
return persisted(false, 'appNonAnonMetricsEnabled');
}
function persisted<T>(initial: T, key: string): Writable<T> & { onDisk: () => Promise<T> } { function persisted<T>(initial: T, key: string): Writable<T> & { onDisk: () => Promise<T> } {
async function setAndPersist(value: T, set: (value: T) => void) { async function setAndPersist(value: T, set: (value: T) => void) {
await store.set(key, value); await store.set(key, value);

View File

@ -0,0 +1,17 @@
/**
* This file contains functions for managing ui-specific feature flags.
* The values are persisted in local storage. Entries are prefixed with 'feature'.
*
* @module appSettings
*/
import { persisted, type Persisted } from '$lib/persisted/persisted';
export function featureBaseBranchSwitching(): Persisted<boolean> {
const key = 'featureBaseBranchSwitching';
return persisted(false, key);
}
export function featureAdvancedCommitOperations(): Persisted<boolean> {
const key = 'featureAdvancedCommitOperations';
return persisted(false, key);
}

View File

@ -1,5 +1,5 @@
import { get, type Readable } from 'svelte/store'; import { get, type Readable } from 'svelte/store';
import type { AnyFile, Commit, Hunk, RemoteCommit } from '../vbranches/types'; import type { AnyCommit, AnyFile, Commit, Hunk, RemoteCommit } from '../vbranches/types';
export function nonDraggable() { export function nonDraggable() {
return { return {
@ -18,7 +18,8 @@ export class DraggableHunk {
export class DraggableFile { export class DraggableFile {
constructor( constructor(
public readonly branchId: string, public readonly branchId: string,
private file: AnyFile, public file: AnyFile,
public commit: AnyCommit | undefined,
private selection: Readable<AnyFile[]> | undefined private selection: Readable<AnyFile[]> | undefined
) {} ) {}

View File

@ -22,7 +22,8 @@ export function showToast(toast: Toast) {
} }
export function showError(title: string, err: any) { export function showError(title: string, err: any) {
if (err.message) showToast({ title, errorMessage: err.message, style: 'error' }); const errorMessage = err.message ? err.message : err;
showToast({ title, errorMessage: errorMessage, style: 'error' });
} }
export function dismissToast(messageId: string | undefined) { export function dismissToast(messageId: string | undefined) {

View File

@ -17,6 +17,7 @@ export interface Settings {
zoom: number; zoom: number;
scrollbarVisabilityOnHover: boolean; scrollbarVisabilityOnHover: boolean;
tabSize: number; tabSize: number;
showHistoryView: boolean;
} }
const defaults: Settings = { const defaults: Settings = {
@ -31,7 +32,8 @@ const defaults: Settings = {
stashedBranchesHeight: 150, stashedBranchesHeight: 150,
zoom: 1, zoom: 1,
scrollbarVisabilityOnHover: false, scrollbarVisabilityOnHover: false,
tabSize: 4 tabSize: 4,
showHistoryView: false
}; };
export function loadUserSettings(): Writable<Settings> { export function loadUserSettings(): Writable<Settings> {

View File

@ -0,0 +1,16 @@
import { normalizeBranchName } from '$lib/utils/branch';
import { describe, expect, test } from 'vitest';
describe.concurrent('normalizeBranchName', () => {
test('it should remove undesirable symbols', () => {
expect(normalizeBranchName('a£^&*() b')).toEqual('a-b');
});
test('it should preserve capital letters', () => {
expect(normalizeBranchName('Hello World')).toEqual('Hello-World');
});
test('it should preserve `#`, `_`, `/`, and `.`', () => {
expect(normalizeBranchName('hello#_./world')).toEqual('hello#_./world');
});
});

View File

@ -1,3 +1,3 @@
export function normalizeBranchName(value: string) { export function normalizeBranchName(value: string) {
return value.toLowerCase().replace(/[^0-9a-z/_.]+/g, '-'); return value.replace(/[^A-Za-z0-9_/.#]+/g, '-');
} }

View File

@ -0,0 +1,4 @@
// If a value occurs > 1 times then all but one will fail this condition.
export function unique(value: any, index: number, array: any[]) {
return array.indexOf(value) === index;
}

View File

@ -1,3 +1,7 @@
export function isDefined<T>(file: T | undefined): file is T { export function isDefined<T>(file: T | undefined | null): file is T {
return file !== undefined; return file !== undefined;
} }
export function notNull<T>(file: T | undefined | null): file is T {
return file !== null;
}

View File

@ -18,6 +18,7 @@ export class BranchController {
async setTarget(branch: string) { async setTarget(branch: string) {
try { try {
await this.targetBranchService.setTarget(branch); await this.targetBranchService.setTarget(branch);
return branch;
// TODO: Reloading seems to trigger 4 invocations of `list_virtual_branches` // TODO: Reloading seems to trigger 4 invocations of `list_virtual_branches`
} catch (err: any) { } catch (err: any) {
showError('Failed to set base branch', err); showError('Failed to set base branch', err);
@ -293,11 +294,12 @@ export class BranchController {
} }
} }
async amendBranch(branchId: string, ownership: string) { async amendBranch(branchId: string, commitOid: string, ownership: string) {
try { try {
await invoke<void>('amend_virtual_branch', { await invoke<void>('amend_virtual_branch', {
projectId: this.projectId, projectId: this.projectId,
branchId, branchId,
commitOid,
ownership ownership
}); });
} catch (err: any) { } catch (err: any) {
@ -305,6 +307,76 @@ export class BranchController {
} }
} }
async moveCommitFile(
branchId: string,
fromCommitOid: string,
toCommitOid: string,
ownership: string
) {
try {
await invoke<void>('move_commit_file', {
projectId: this.projectId,
branchId,
fromCommitOid,
toCommitOid,
ownership
});
} catch (err: any) {
showError('Failed to amend commit', err);
}
}
async undoCommit(branchId: string, commitOid: string) {
try {
await invoke<void>('undo_commit', {
projectId: this.projectId,
branchId,
commitOid
});
} catch (err: any) {
showError('Failed to amend commit', err);
}
}
async updateCommitMessage(branchId: string, commitOid: string, message: string) {
try {
await invoke<void>('update_commit_message', {
projectId: this.projectId,
branchId,
commitOid,
message
});
} catch (err: any) {
showError('Failed to change commit message', err);
}
}
async insertBlankCommit(branchId: string, commitOid: string, offset: number) {
try {
await invoke<void>('insert_blank_commit', {
projectId: this.projectId,
branchId,
commitOid,
offset
});
} catch (err: any) {
showError('Failed to insert blank commit', err);
}
}
async reorderCommit(branchId: string, commitOid: string, offset: number) {
try {
await invoke<void>('reorder_commit', {
projectId: this.projectId,
branchId,
commitOid,
offset
});
} catch (err: any) {
showError('Failed to reorder blank commit', err);
}
}
async moveCommit(targetBranchId: string, commitOid: string) { async moveCommit(targetBranchId: string, commitOid: string) {
try { try {
await invoke<void>('move_commit', { await invoke<void>('move_commit', {

View File

@ -1,4 +1,4 @@
import type { Branch, AnyFile, Hunk, RemoteHunk } from './types'; import type { Branch, AnyFile, Hunk, RemoteHunk, RemoteFile } from './types';
export function filesToOwnership(files: AnyFile[]) { export function filesToOwnership(files: AnyFile[]) {
return files return files
@ -6,6 +6,15 @@ export function filesToOwnership(files: AnyFile[]) {
.join('\n'); .join('\n');
} }
export function filesToSimpleOwnership(files: RemoteFile[]) {
return files
.map(
(f) =>
`${f.path}:${f.hunks.map(({ new_start, new_lines }) => `${new_start}-${new_start + new_lines}`).join(',')}`
)
.join('\n');
}
// These types help keep track of what maps to what. // These types help keep track of what maps to what.
// TODO: refactor code for clarity, these types should not be needed // TODO: refactor code for clarity, these types should not be needed
export type AnyHunk = Hunk | RemoteHunk; export type AnyHunk = Hunk | RemoteHunk;

View File

@ -1,13 +1,17 @@
import type { Commit } from './types'; import { HunkLock, type Commit } from './types';
import { unique } from '$lib/utils/filters';
export function getLockText(commitId: string[] | string, commits: Commit[]): string { export function getLockText(hunkLocks: HunkLock | HunkLock[] | string, commits: Commit[]): string {
if (!commitId || commits === undefined) return 'Depends on a committed change'; if (!hunkLocks || commits === undefined) return 'Depends on a committed change';
const lockedIds = typeof commitId == 'string' ? [commitId] : (commitId as string[]); const locks = hunkLocks instanceof HunkLock ? [hunkLocks] : (hunkLocks as HunkLock[]);
const descriptions = lockedIds const descriptions = locks
.map((id) => { .filter(unique)
const commit = commits.find((commit) => commit.id == id); .map((lock) => {
const commit = commits.find((c) => {
return c.id == lock.commitId;
});
const shortCommitId = commit?.id.slice(0, 7); const shortCommitId = commit?.id.slice(0, 7);
if (commit) { if (commit) {
const shortTitle = commit.descriptionTitle?.slice(0, 35) + '...'; const shortTitle = commit.descriptionTitle?.slice(0, 35) + '...';
@ -17,5 +21,13 @@ export function getLockText(commitId: string[] | string, commits: Commit[]): str
} }
}) })
.join('\n'); .join('\n');
return 'Locked due to dependency on:\n' + descriptions; const branchCount = locks.map((lock) => lock.branchId).filter(unique).length;
if (branchCount > 1) {
return (
'Warning, undefined behavior due to lock on multiple branches!\n\n' +
'Locked because changes depend on:\n' +
descriptions
);
}
return 'Locked because changes depend on:\n' + descriptions;
} }

View File

@ -1,6 +1,7 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { splitMessage } from '$lib/utils/commitMessage'; import { splitMessage } from '$lib/utils/commitMessage';
import { hashCode } from '$lib/utils/string'; import { hashCode } from '$lib/utils/string';
import { isDefined, notNull } from '$lib/utils/typeguards';
import { Type, Transform } from 'class-transformer'; import { Type, Transform } from 'class-transformer';
export type ChangeType = export type ChangeType =
@ -21,8 +22,16 @@ export class Hunk {
filePath!: string; filePath!: string;
hash?: string; hash?: string;
locked!: boolean; locked!: boolean;
lockedTo!: string | undefined; @Type(() => HunkLock)
lockedTo!: HunkLock[];
changeType!: ChangeType; changeType!: ChangeType;
new_start!: number;
new_lines!: number;
}
export class HunkLock {
branchId!: string;
commitId!: string;
} }
export type AnyFile = LocalFile | RemoteFile; export type AnyFile = LocalFile | RemoteFile;
@ -58,14 +67,15 @@ export class LocalFile {
get locked(): boolean { get locked(): boolean {
return this.hunks return this.hunks
? this.hunks.map((hunk) => hunk.lockedTo).reduce((a, b) => !!(a || b), false) ? this.hunks.map((hunk) => hunk.locked).reduce((a, b) => !!(a || b), false)
: false; : false;
} }
get lockedIds(): string[] { get lockedIds(): HunkLock[] {
return this.hunks return this.hunks
.map((hunk) => hunk.lockedTo) .flatMap((hunk) => hunk.lockedTo)
.filter((lockedTo): lockedTo is string => !!lockedTo); .filter(notNull)
.filter(isDefined);
} }
} }
@ -210,6 +220,8 @@ export const UNKNOWN_COMMITS = Symbol('UnknownCommits');
export class RemoteHunk { export class RemoteHunk {
diff!: string; diff!: string;
hash?: string; hash?: string;
new_start!: number;
new_lines!: number;
get id(): string { get id(): string {
return hashCode(this.diff); return hashCode(this.diff);
@ -250,7 +262,7 @@ export class RemoteFile {
return this.hunks.map((h) => h.id); return this.hunks.map((h) => h.id);
} }
get lockedIds(): string[] { get lockedIds(): HunkLock[] {
return []; return [];
} }

View File

@ -67,6 +67,12 @@
hotkeys.on('Backspace', (e) => { hotkeys.on('Backspace', (e) => {
// This prevent backspace from navigating back // This prevent backspace from navigating back
e.preventDefault(); e.preventDefault();
}),
hotkeys.on('$mod+Shift+H', () => {
userSettings.update((s) => ({
...s,
showHistoryView: !$userSettings.showHistoryView
}));
}) })
); );
}); });

View File

@ -1,13 +1,11 @@
import { AIService } from '$lib/ai/service'; import { AIService } from '$lib/ai/service';
import { initPostHog } from '$lib/analytics/posthog'; import { initAnalyticsIfEnabled } from '$lib/analytics/analytics';
import { initSentry } from '$lib/analytics/sentry';
import { AuthService } from '$lib/backend/auth'; import { AuthService } from '$lib/backend/auth';
import { GitConfigService } from '$lib/backend/gitConfigService'; import { GitConfigService } from '$lib/backend/gitConfigService';
import { HttpClient } from '$lib/backend/httpClient'; import { HttpClient } from '$lib/backend/httpClient';
import { ProjectService } from '$lib/backend/projects'; import { ProjectService } from '$lib/backend/projects';
import { PromptService } from '$lib/backend/prompt'; import { PromptService } from '$lib/backend/prompt';
import { UpdaterService } from '$lib/backend/updater'; import { UpdaterService } from '$lib/backend/updater';
import { appMetricsEnabled, appErrorReportingEnabled } from '$lib/config/appSettings';
import { GitHubService } from '$lib/github/service'; import { GitHubService } from '$lib/github/service';
import { UserService } from '$lib/stores/user'; import { UserService } from '$lib/stores/user';
import lscache from 'lscache'; import lscache from 'lscache';
@ -24,16 +22,7 @@ export const prerender = false;
export const csr = true; export const csr = true;
export async function load() { export async function load() {
appErrorReportingEnabled() initAnalyticsIfEnabled();
.onDisk()
.then((enabled) => {
if (enabled) initSentry();
});
appMetricsEnabled()
.onDisk()
.then((enabled) => {
if (enabled) initPostHog();
});
// TODO: Find a workaround to avoid this dynamic import // TODO: Find a workaround to avoid this dynamic import
// https://github.com/sveltejs/kit/issues/905 // https://github.com/sveltejs/kit/issues/905

View File

@ -2,11 +2,14 @@
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import { syncToCloud } from '$lib/backend/sync'; import { syncToCloud } from '$lib/backend/sync';
import { BranchService } from '$lib/branches/service'; import { BranchService } from '$lib/branches/service';
import History from '$lib/components/History.svelte';
import Navigation from '$lib/components/Navigation.svelte'; import Navigation from '$lib/components/Navigation.svelte';
import NoBaseBranch from '$lib/components/NoBaseBranch.svelte'; import NoBaseBranch from '$lib/components/NoBaseBranch.svelte';
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte'; import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte'; import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
import ProjectSettingsMenuAction from '$lib/components/ProjectSettingsMenuAction.svelte'; import ProjectSettingsMenuAction from '$lib/components/ProjectSettingsMenuAction.svelte';
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
import { getContextStoreBySymbol } from '$lib/utils/context';
import * as hotkeys from '$lib/utils/hotkeys'; import * as hotkeys from '$lib/utils/hotkeys';
import { unsubscribe } from '$lib/utils/unsubscribe'; import { unsubscribe } from '$lib/utils/unsubscribe';
import { BaseBranchService, NoDefaultTarget } from '$lib/vbranches/baseBranch'; import { BaseBranchService, NoDefaultTarget } from '$lib/vbranches/baseBranch';
@ -33,6 +36,7 @@
$: baseBranch = baseBranchService.base; $: baseBranch = baseBranchService.base;
$: baseError = baseBranchService.error; $: baseError = baseBranchService.error;
$: projectError = projectService.error; $: projectError = projectService.error;
const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
$: setContext(VirtualBranchService, vbranchService); $: setContext(VirtualBranchService, vbranchService);
$: setContext(BranchController, branchController); $: setContext(BranchController, branchController);
@ -90,6 +94,9 @@
<div class="view-wrap" role="group" on:dragover|preventDefault> <div class="view-wrap" role="group" on:dragover|preventDefault>
<Navigation /> <Navigation />
<slot /> <slot />
{#if $userSettings.showHistoryView}
<History {projectId} />
{/if}
</div> </div>
{/if} {/if}
{/key} {/key}

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Project, ProjectService } from '$lib/backend/projects'; import { Project, ProjectService } from '$lib/backend/projects';
import BaseBranchSwitch from '$lib/components/BaseBranchSwitch.svelte';
import CloudForm from '$lib/components/CloudForm.svelte'; import CloudForm from '$lib/components/CloudForm.svelte';
import DetailsForm from '$lib/components/DetailsForm.svelte'; import DetailsForm from '$lib/components/DetailsForm.svelte';
import KeysForm from '$lib/components/KeysForm.svelte'; import KeysForm from '$lib/components/KeysForm.svelte';
@ -8,6 +9,7 @@
import SectionCard from '$lib/components/SectionCard.svelte'; import SectionCard from '$lib/components/SectionCard.svelte';
import Spacer from '$lib/components/Spacer.svelte'; import Spacer from '$lib/components/Spacer.svelte';
import ContentWrapper from '$lib/components/settings/ContentWrapper.svelte'; import ContentWrapper from '$lib/components/settings/ContentWrapper.svelte';
import { featureBaseBranchSwitching } from '$lib/config/uiFeatureFlags';
import { showError } from '$lib/notifications/toasts'; import { showError } from '$lib/notifications/toasts';
import { getContext } from '$lib/utils/context'; import { getContext } from '$lib/utils/context';
import * as toasts from '$lib/utils/toasts'; import * as toasts from '$lib/utils/toasts';
@ -15,6 +17,7 @@
import { from } from 'rxjs'; import { from } from 'rxjs';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
const baseBranchSwitching = featureBaseBranchSwitching();
const projectService = getContext(ProjectService); const projectService = getContext(ProjectService);
const project = getContext(Project); const project = getContext(Project);
const platformName = from(platform()); const platformName = from(platform());
@ -39,6 +42,9 @@
</script> </script>
<ContentWrapper title="Project settings"> <ContentWrapper title="Project settings">
{#if $baseBranchSwitching}
<BaseBranchSwitch />
{/if}
<CloudForm /> <CloudForm />
<DetailsForm /> <DetailsForm />
{#if $platformName != 'win32'} {#if $platformName != 'win32'}

View File

@ -0,0 +1,53 @@
<script lang="ts">
import SectionCard from '$lib/components/SectionCard.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import ContentWrapper from '$lib/components/settings/ContentWrapper.svelte';
import {
featureBaseBranchSwitching,
featureAdvancedCommitOperations
} from '$lib/config/uiFeatureFlags';
const baseBranchSwitching = featureBaseBranchSwitching();
const advancedCommitOperations = featureAdvancedCommitOperations();
</script>
<ContentWrapper title="Experimental features">
<p class="text-base-body-13 experimental-settings__text">
This sections contains a list of feature flags for features that are still in development or in
an experimental stage.
</p>
<SectionCard labelFor="baseBranchSwitching" orientation="row">
<svelte:fragment slot="title">Switching the base branch</svelte:fragment>
<svelte:fragment slot="caption">
This allows changing of the base branch (trunk) after the initial project setup from within
the project settings. Not fully tested yet, use with caution.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="baseBranchSwitching"
checked={$baseBranchSwitching}
on:change={() => ($baseBranchSwitching = !$baseBranchSwitching)}
/>
</svelte:fragment>
</SectionCard>
<SectionCard labelFor="advancedCommitOperations" orientation="row">
<svelte:fragment slot="title">Advanced commit operations</svelte:fragment>
<svelte:fragment slot="caption">
Allows for reordeing of commits, changing the message as well as undoing of commits anywhere
in the stack. In addition it allows for adding an empty commit between two other commits.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="advancedCommitOperations"
checked={$advancedCommitOperations}
on:change={() => ($advancedCommitOperations = !$advancedCommitOperations)}
/>
</svelte:fragment>
</SectionCard>
</ContentWrapper>
<style>
.experimental-settings__text {
color: var(--clr-text-2);
margin-bottom: var(--size-12);
}
</style>

View File

@ -116,10 +116,36 @@ button {
/* DIALOG STYLES */ /* DIALOG STYLES */
dialog[open] {
animation: dialog-zoom 0.25s cubic-bezier(0.34, 1.35, 0.7, 1);
}
@keyframes dialog-zoom {
from {
transform: scale(0.95);
}
to {
transform: scale(1);
}
}
dialog::backdrop { dialog::backdrop {
background-color: rgba(214, 214, 214, 0.4); background-color: rgba(214, 214, 214, 0.4);
} }
dialog[open]::backdrop {
animation: dialog-fade 0.2s ease-out;
}
@keyframes dialog-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dark dialog::backdrop { .dark dialog::backdrop {
background-color: rgba(0, 0, 0, 0.35); background-color: rgba(0, 0, 0, 0.35);
} }

View File

@ -82,7 +82,7 @@
/* text Base Body Classes */ /* text Base Body Classes */
.text-base-body-10 { .text-base-body-10 {
font-family: var(--base-font-family); font-family: var(--base-font-family);
font-size: 625rem; font-size: 0.625rem;
font-weight: var(--base-font-weight); font-weight: var(--base-font-weight);
line-height: var(--text-body-line-height); line-height: var(--text-body-line-height);
} }

View File

@ -1,26 +0,0 @@
[package]
name = "gitbutler-analytics"
version = "0.0.0"
edition = "2021"
publish = false
[lib]
doctest = false
test = false
[dependencies]
gitbutler-core.workspace = true
thiserror.workspace = true
tracing = "0.1.40"
tokio.workspace = true
serde.workspace = true
serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
async-trait = "0.1.79"
chrono = { version = "0.4.37", features = ["serde"] }
reqwest = { version = "0.12.2", features = ["json"] }
[lints.clippy]
all = "deny"
perf = "deny"
correctness = "deny"

View File

@ -1,100 +0,0 @@
//! A client to provide analytics.
use std::{fmt, str, sync::Arc};
use gitbutler_core::{projects::ProjectId, users::User};
mod posthog;
pub struct Config<'c> {
pub posthog_token: Option<&'c str>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
HeadChange {
project_id: ProjectId,
reference_name: String,
},
}
impl fmt::Display for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Event::HeadChange {
project_id,
reference_name,
} => write!(
f,
"HeadChange(project_id: {}, reference_name: {})",
project_id, reference_name
),
}
}
}
impl Event {
pub fn project_id(&self) -> ProjectId {
match self {
Event::HeadChange { project_id, .. } => *project_id,
}
}
fn into_posthog_event(self, user: &User) -> posthog::Event {
match self {
Event::HeadChange {
project_id,
reference_name: reference,
} => {
let mut event =
posthog::Event::new("git::head_changed", &format!("user_{}", user.id));
event.insert_prop("project_id", format!("project_{}", project_id));
event.insert_prop("reference", reference);
event
}
}
}
}
/// NOTE: Needs to be `Clone` only because the watcher wants to obtain it from `tauri`.
/// It's just for dependency injection.
#[derive(Clone)]
pub struct Client {
client: Arc<dyn posthog::Client + Sync + Send>,
}
impl Client {
pub fn new(app_name: String, app_version: String, config: &Config) -> Self {
let client: Arc<dyn posthog::Client + Sync + Send> =
if let Some(posthog_token) = config.posthog_token {
let real = posthog::real::Client::new(posthog::real::ClientOptions {
api_key: posthog_token.to_string(),
app_name,
app_version,
});
let real_with_retry = posthog::retry::Client::new(real);
Arc::new(real_with_retry)
} else {
Arc::<posthog::mock::Client>::default()
};
Client { client }
}
/// Send `event` to analytics and associate it with `user` without blocking.
pub fn send_non_anonymous_event_nonblocking(&self, user: &User, event: &Event) {
let client = self.client.clone();
let event = event.clone().into_posthog_event(user);
tokio::spawn(async move {
if let Err(error) = client.capture(&[event]).await {
tracing::warn!(?error, "failed to send analytics");
}
});
}
}
impl Default for Client {
fn default() -> Self {
Self {
client: Arc::new(posthog::mock::Client),
}
}
}

View File

@ -1,67 +0,0 @@
pub mod mock;
pub mod real;
pub mod retry;
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use serde::Serialize;
#[async_trait]
pub trait Client {
async fn capture(&self, events: &[Event]) -> Result<(), Error>;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{code}: {message}")]
BadRequest { code: u16, message: String },
#[error("Connection error: {0}")]
Connection(#[from] reqwest::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
#[derive(Serialize, Debug, PartialEq, Eq, Clone)]
pub struct Event {
event: String,
properties: Properties,
timestamp: Option<NaiveDateTime>,
}
#[derive(Clone, Serialize, Debug, PartialEq, Eq)]
pub struct Properties {
distinct_id: String,
props: HashMap<String, serde_json::Value>,
}
impl Properties {
fn new<S: Into<String>>(distinct_id: S) -> Self {
Self {
distinct_id: distinct_id.into(),
props: HashMap::default(),
}
}
pub fn insert<K: Into<String>, P: Serialize>(&mut self, key: K, prop: P) {
let as_json =
serde_json::to_value(prop).expect("safe serialization of a analytics property");
let _ = self.props.insert(key.into(), as_json);
}
}
impl Event {
pub fn new<S: Into<String>>(event: S, distinct_id: S) -> Self {
Self {
event: event.into(),
properties: Properties::new(distinct_id),
timestamp: None,
}
}
/// Errors if `prop` fails to serialize
pub fn insert_prop<K: Into<String>, P: Serialize>(&mut self, key: K, prop: P) {
self.properties.insert(key, prop);
}
}

View File

@ -1,13 +0,0 @@
use async_trait::async_trait;
use tracing::instrument;
#[derive(Default)]
pub struct Client;
#[async_trait]
impl super::Client for Client {
#[instrument(skip(self), level = "debug")]
async fn capture(&self, _events: &[super::Event]) -> Result<(), super::Error> {
Ok(())
}
}

View File

@ -1,96 +0,0 @@
use std::time::Duration;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use reqwest::{header::CONTENT_TYPE, Client as HttpClient};
use serde::Serialize;
use tracing::instrument;
const API_ENDPOINT: &str = "https://eu.posthog.com/batch/";
const TIMEOUT: &Duration = &Duration::from_millis(800);
pub struct ClientOptions {
pub app_name: String,
pub app_version: String,
pub api_key: String,
}
pub struct Client {
options: ClientOptions,
client: HttpClient,
}
impl Client {
pub fn new<C: Into<ClientOptions>>(options: C) -> Self {
let client = HttpClient::builder().timeout(*TIMEOUT).build().unwrap(); // Unwrap here is as safe as `HttpClient::new`
Client {
options: options.into(),
client,
}
}
}
#[async_trait]
impl super::Client for Client {
#[instrument(skip(self), level = "debug")]
async fn capture(&self, events: &[super::Event]) -> Result<(), super::Error> {
let events = events
.iter()
.map(|event| {
let event = &mut event.clone();
event
.properties
.insert("appName", self.options.app_name.clone());
event
.properties
.insert("appVersion", self.options.app_version.clone());
Event::from(event)
})
.collect::<Vec<_>>();
let batch = Batch {
api_key: &self.options.api_key,
batch: events.as_slice(),
};
let response = self
.client
.post(API_ENDPOINT)
.header(CONTENT_TYPE, "application/json")
.body(serde_json::to_string(&batch)?)
.send()
.await?;
if response.status().is_success() {
Ok(())
} else {
Err(super::Error::BadRequest {
code: response.status().as_u16(),
message: response.text().await.unwrap_or_default(),
})
}
}
}
#[derive(Serialize)]
struct Batch<'a> {
api_key: &'a str,
batch: &'a [Event],
}
#[derive(Serialize)]
struct Event {
event: String,
properties: super::Properties,
timestamp: Option<NaiveDateTime>,
}
impl From<&mut super::Event> for Event {
fn from(event: &mut super::Event) -> Self {
Self {
event: event.event.clone(),
properties: event.properties.clone(),
timestamp: event.timestamp,
}
}
}

View File

@ -1,118 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::Mutex;
use tracing::instrument;
#[derive(Clone)]
pub struct Client<T: super::Client + Sync> {
inner: T,
/// Events that failed to be sent
/// and are waiting to be retried.
batch: Arc<Mutex<Vec<super::Event>>>,
}
impl<T: super::Client + Sync> Client<T> {
pub fn new(inner: T) -> Self {
Client {
inner,
batch: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait]
impl<T: super::Client + Sync> super::Client for Client<T> {
#[instrument(skip(self), level = "debug")]
async fn capture(&self, events: &[super::Event]) -> Result<(), super::Error> {
let mut batch = self.batch.lock().await;
batch.extend_from_slice(events);
if let Err(error) = self.inner.capture(&batch).await {
tracing::warn!("Failed to send analytics: {}", error);
} else {
batch.clear();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use super::{super::Client, *};
#[derive(Clone)]
struct MockClient {
sent: Arc<AtomicUsize>,
is_failing: Arc<AtomicBool>,
}
impl MockClient {
fn new() -> Self {
MockClient {
sent: Arc::new(AtomicUsize::new(0)),
is_failing: Arc::new(AtomicBool::new(false)),
}
}
fn set_failing(&self, is_failing: bool) {
self.is_failing.store(is_failing, Ordering::SeqCst);
}
fn get_sent(&self) -> usize {
self.sent.load(Ordering::SeqCst)
}
}
#[async_trait]
impl super::super::Client for MockClient {
async fn capture(&self, events: &[super::super::Event]) -> Result<(), super::super::Error> {
if self.is_failing.load(Ordering::SeqCst) {
Err(super::super::Error::BadRequest {
code: 400,
message: "Bad request".to_string(),
})
} else {
self.sent.fetch_add(events.len(), Ordering::SeqCst);
Ok(())
}
}
}
#[tokio::test]
async fn retry() {
let inner_client = MockClient::new();
let retry_client = super::Client::new(inner_client.clone());
inner_client.set_failing(true);
retry_client
.capture(&[super::super::Event::new("test", "test")])
.await
.unwrap();
assert_eq!(inner_client.get_sent(), 0);
retry_client
.capture(&[super::super::Event::new("test", "test")])
.await
.unwrap();
assert_eq!(inner_client.get_sent(), 0);
inner_client.set_failing(false);
retry_client
.capture(&[super::super::Event::new("test", "test")])
.await
.unwrap();
assert_eq!(inner_client.get_sent(), 3);
retry_client
.capture(&[super::super::Event::new("test", "test")])
.await
.unwrap();
assert_eq!(inner_client.get_sent(), 4);
}
}

View File

@ -43,15 +43,15 @@ fn score_ignores_whitespace() {
assert_score!(sig, "\t\t hel lo\n\two rld \t\t", 1.0); assert_score!(sig, "\t\t hel lo\n\two rld \t\t", 1.0);
} }
const TEXT1: &str = include_str!("../fixtures/text1.txt"); const TEXT1: &str = include_str!("fixtures/text1.txt");
const TEXT2: &str = include_str!("../fixtures/text2.txt"); const TEXT2: &str = include_str!("fixtures/text2.txt");
const TEXT3: &str = include_str!("../fixtures/text3.txt"); const TEXT3: &str = include_str!("fixtures/text3.txt");
const CODE1: &str = include_str!("../fixtures/code1.txt"); const CODE1: &str = include_str!("fixtures/code1.txt");
const CODE2: &str = include_str!("../fixtures/code2.txt"); const CODE2: &str = include_str!("fixtures/code2.txt");
const CODE3: &str = include_str!("../fixtures/code3.txt"); const CODE3: &str = include_str!("fixtures/code3.txt");
const CODE4: &str = include_str!("../fixtures/code4.txt"); const CODE4: &str = include_str!("fixtures/code4.txt");
const LARGE1: &str = include_str!("../fixtures/large1.txt"); const LARGE1: &str = include_str!("fixtures/large1.txt");
const LARGE2: &str = include_str!("../fixtures/large2.txt"); const LARGE2: &str = include_str!("fixtures/large2.txt");
macro_rules! real_test { macro_rules! real_test {
($a: ident, $b: ident, are_similar) => { ($a: ident, $b: ident, are_similar) => {

View File

@ -0,0 +1,19 @@
[package]
name = "gitbutler-cli"
version = "0.0.0"
edition = "2021"
authors = ["GitButler <gitbutler@gitbutler.com>"]
publish = false
[[bin]]
name = "gitbutler-cli"
path = "src/main.rs"
[dependencies]
gitbutler-core.workspace = true
clap = "4.5.4"
anyhow = "1.0.82"
chrono = "0.4.10"
[target."cfg(unix)".dependencies]
pager = "0.16.1"

View File

@ -0,0 +1,75 @@
use anyhow::Result;
use gitbutler_core::{projects::Project, snapshots::snapshot};
use clap::{arg, Command};
#[cfg(not(windows))]
use pager::Pager;
fn cli() -> Command {
Command::new("gitbutler-cli")
.about("A CLI tool for GitButler")
.arg(arg!(-C <path> "Run as if gitbutler-cli was started in <path> instead of the current working directory."))
.subcommand_required(true)
.arg_required_else_help(true)
.allow_external_subcommands(true)
.subcommand(
Command::new("snapshot")
.about("List and restore snapshots.")
.subcommand(Command::new("restore")
.about("Restores the state of the working direcory as well as virtual branches to a given snapshot.")
.arg(arg!(<SNAPSHOT_ID> "The snapshot to restore"))),
)
}
fn main() -> Result<()> {
#[cfg(not(windows))]
Pager::new().setup();
let matches = cli().get_matches();
let cwd = std::env::current_dir()?.to_string_lossy().to_string();
let repo_dir = matches.get_one::<String>("path").unwrap_or(&cwd);
match matches.subcommand() {
Some(("snapshot", sub_matches)) => match sub_matches.subcommand() {
Some(("restore", sub_matches)) => {
let snapshot_id = sub_matches
.get_one::<String>("SNAPSHOT_ID")
.expect("required");
restore_snapshot(repo_dir, snapshot_id)?;
}
_ => {
list_snapshots(repo_dir)?;
}
},
_ => unreachable!(),
}
Ok(())
}
fn list_snapshots(repo_dir: &str) -> Result<()> {
let project = project_from_path(repo_dir);
let snapshots = snapshot::list(&project, 100)?;
for snapshot in snapshots {
let ts = chrono::DateTime::from_timestamp(snapshot.created_at / 1000, 0);
let details = snapshot.details;
if let (Some(ts), Some(details)) = (ts, details) {
println!("{} {} {}", ts, snapshot.id, details.operation);
}
}
Ok(())
}
fn restore_snapshot(repo_dir: &str, snapshot_id: &str) -> Result<()> {
let project = project_from_path(repo_dir);
snapshot::restore(&project, snapshot_id.to_owned())?;
Ok(())
}
fn project_from_path(repo_dir: &str) -> Project {
Project {
path: std::path::PathBuf::from(repo_dir),
enable_snapshots: Some(true),
..Default::default()
}
}

View File

@ -8,15 +8,17 @@ publish = false
[dev-dependencies] [dev-dependencies]
once_cell = "1.19" once_cell = "1.19"
pretty_assertions = "1.4" pretty_assertions = "1.4"
tempfile = "3.10"
gitbutler-testsupport.workspace = true gitbutler-testsupport.workspace = true
gitbutler-git = { workspace = true, features = ["test-askpass-path" ]}
[dependencies] [dependencies]
toml = "0.8.12" toml = "0.8.12"
anyhow = "1.0.81" anyhow = "1.0.82"
async-trait = "0.1.79" async-trait = "0.1.80"
backtrace = { version = "0.3.71", optional = true } backtrace = { version = "0.3.71", optional = true }
bstr = "1.9.1" bstr = "1.9.1"
chrono = { version = "0.4.37", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] }
diffy = "0.3.0" diffy = "0.3.0"
filetime = "0.2.23" filetime = "0.2.23"
fslock = "0.2.1" fslock = "0.2.1"
@ -33,7 +35,7 @@ r2d2_sqlite = "0.22.0"
rand = "0.8.5" rand = "0.8.5"
refinery = { version = "0.8", features = [ "rusqlite" ] } refinery = { version = "0.8", features = [ "rusqlite" ] }
regex = "1.10" regex = "1.10"
reqwest = { version = "0.12.2", features = ["json"] } reqwest = { version = "0.12.4", features = ["json"] }
resolve-path = "0.1.0" resolve-path = "0.1.0"
rusqlite.workspace = true rusqlite.workspace = true
serde.workspace = true serde.workspace = true
@ -41,8 +43,9 @@ serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
sha2 = "0.10.8" sha2 = "0.10.8"
similar = { version = "2.5.0", features = ["unicode"] } similar = { version = "2.5.0", features = ["unicode"] }
slug = "0.1.5" slug = "0.1.5"
ssh-key = { version = "0.6.5", features = [ "alloc", "ed25519" ] } ssh-key = { version = "0.6.6", features = [ "alloc", "ed25519" ] }
ssh2 = { version = "0.9.4", features = ["vendored-openssl"] } ssh2 = { version = "0.9.4", features = ["vendored-openssl"] }
strum = { version = "0.26", features = ["derive"] }
log = "^0.4" log = "^0.4"
thiserror.workspace = true thiserror.workspace = true
tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros" ] } tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros" ] }
@ -52,7 +55,6 @@ urlencoding = "2.1.3"
uuid.workspace = true uuid.workspace = true
walkdir = "2.5.0" walkdir = "2.5.0"
zip = "0.6.5" zip = "0.6.5"
tempfile = "3.10"
gitbutler-git.workspace = true gitbutler-git.workspace = true
[features] [features]

View File

@ -93,10 +93,7 @@ impl Proxy {
async fn proxy_author(&self, author: Author) -> Author { async fn proxy_author(&self, author: Author) -> Author {
Author { Author {
gravatar_url: self gravatar_url: self.proxy(&author.gravatar_url).await.unwrap_or_else(|error| {
.proxy(&author.gravatar_url)
.await
.unwrap_or_else(|error| {
tracing::error!(gravatar_url = %author.gravatar_url, ?error, "failed to proxy gravatar url"); tracing::error!(gravatar_url = %author.gravatar_url, ?error, "failed to proxy gravatar url");
author.gravatar_url author.gravatar_url
}), }),

View File

@ -1,7 +1,4 @@
use std::{ use std::fmt::{Display, Formatter};
fmt::{Display, Formatter},
time::SystemTime,
};
use anyhow::Result; use anyhow::Result;
@ -50,29 +47,20 @@ impl Document {
}; };
let operations = operations::get_delta_operations(&self.to_string(), new_text); let operations = operations::get_delta_operations(&self.to_string(), new_text);
let delta = if operations.is_empty() {
if operations.is_empty() {
if let Some(reader::Content::UTF8(value)) = value { if let Some(reader::Content::UTF8(value)) = value {
if !value.is_empty() { if !value.is_empty() {
return Ok(None); return Ok(None);
} }
} }
}
delta::Delta { let delta = delta::Delta {
operations, operations,
timestamp_ms: SystemTime::now() timestamp_ms: crate::time::now_ms(),
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis(),
}
} else {
delta::Delta {
operations,
timestamp_ms: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis(),
}
}; };
apply_deltas(&mut self.doc, &vec![delta.clone()])?; apply_deltas(&mut self.doc, &vec![delta.clone()])?;
self.deltas.push(delta.clone()); self.deltas.push(delta.clone());
Ok(Some(delta)) Ok(Some(delta))

View File

@ -1,8 +1,11 @@
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::Result; use anyhow::Result;
use bstr::BString; use bstr::BString;
use gix::dir::walk::EmissionMode; use gix::dir::walk::EmissionMode;
use gix::tempfile::create_dir::Retries;
use gix::tempfile::{AutoRemove, ContainingDirectory};
use walkdir::WalkDir; use walkdir::WalkDir;
// Returns an ordered list of relative paths for files inside a directory recursively. // Returns an ordered list of relative paths for files inside a directory recursively.
@ -48,3 +51,71 @@ pub fn iter_worktree_files(
.filter_map(Result::ok) .filter_map(Result::ok)
.map(|e| e.entry.rela_path)) .map(|e| e.entry.rela_path))
} }
/// Write a single file so that the write either fully succeeds, or fully fails,
/// assuming the containing directory already exists.
pub(crate) fn write<P: AsRef<Path>>(
file_path: P,
contents: impl AsRef<[u8]>,
) -> anyhow::Result<()> {
#[cfg(windows)]
{
Ok(std::fs::write(file_path, contents)?)
}
#[cfg(not(windows))]
{
let mut temp_file = gix::tempfile::new(
file_path.as_ref().parent().unwrap(),
ContainingDirectory::Exists,
AutoRemove::Tempfile,
)?;
temp_file.write_all(contents.as_ref())?;
Ok(persist_tempfile(temp_file, file_path)?)
}
}
/// Write a single file so that the write either fully succeeds, or fully fails,
/// and create all leading directories.
pub(crate) fn create_dirs_then_write<P: AsRef<Path>>(
file_path: P,
contents: impl AsRef<[u8]>,
) -> std::io::Result<()> {
#[cfg(windows)]
{
let dir = file_path.as_ref().parent().unwrap();
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(file_path, contents)
}
#[cfg(not(windows))]
{
let mut temp_file = gix::tempfile::new(
file_path.as_ref().parent().unwrap(),
ContainingDirectory::CreateAllRaceProof(Retries::default()),
AutoRemove::Tempfile,
)?;
temp_file.write_all(contents.as_ref())?;
persist_tempfile(temp_file, file_path)
}
}
fn persist_tempfile(
tempfile: gix::tempfile::Handle<gix::tempfile::handle::Writable>,
to_path: impl AsRef<Path>,
) -> std::io::Result<()> {
match tempfile.persist(to_path) {
Ok(Some(_opened_file)) => {
// EXPERIMENT: Does this fix #3601?
#[cfg(windows)]
_opened_file.sync_all()?;
Ok(())
}
Ok(None) => unreachable!(
"BUG: a signal has caused the tempfile to be removed, but we didn't install a handler"
),
Err(err) => Err(err.error),
}
}

View File

@ -4,7 +4,7 @@ use std::{
collections::HashSet, collections::HashSet,
fs::File, fs::File,
io::{BufReader, Read}, io::{BufReader, Read},
path, time, path,
}; };
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@ -217,7 +217,8 @@ impl Repository {
// Push to the remote // Push to the remote
remote remote
.push(&[&remote_refspec], Some(&mut push_options)).map_err(|error| match error { .push(&[&remote_refspec], Some(&mut push_options))
.map_err(|error| match error {
git::Error::Network(error) => { git::Error::Network(error) => {
tracing::warn!(project_id = %self.project.id, error = %error, "failed to push gb repo"); tracing::warn!(project_id = %self.project.id, error = %error, "failed to push gb repo");
RemoteError::Network RemoteError::Network
@ -279,10 +280,7 @@ impl Repository {
&self, &self,
project_repository: &project_repository::Repository, project_repository: &project_repository::Repository,
) -> Result<sessions::Session> { ) -> Result<sessions::Session> {
let now_ms = time::SystemTime::now() let now_ms = crate::time::now_ms();
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_millis();
let meta = match project_repository.get_head() { let meta = match project_repository.get_head() {
Result::Ok(head) => sessions::Meta { Result::Ok(head) => sessions::Meta {
@ -335,10 +333,7 @@ impl Repository {
let updated_session = sessions::Session { let updated_session = sessions::Session {
meta: sessions::Meta { meta: sessions::Meta {
last_timestamp_ms: time::SystemTime::now() last_timestamp_ms: crate::time::now_ms(),
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_millis(),
..current_session.meta ..current_session.meta
}, },
..current_session ..current_session

View File

@ -113,7 +113,8 @@ impl Helper {
} }
} }
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Self { pub fn from_path(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let keys = keys::Controller::from_path(&path); let keys = keys::Controller::from_path(&path);
let users = users::Controller::from_path(path); let users = users::Controller::from_path(path);
let home_dir = std::env::var_os("HOME").map(PathBuf::from); let home_dir = std::env::var_os("HOME").map(PathBuf::from);

View File

@ -9,7 +9,6 @@ use tracing::instrument;
use super::Repository; use super::Repository;
use crate::git; use crate::git;
use crate::virtual_branches::BranchStatus;
pub type DiffByPathMap = HashMap<PathBuf, FileDiff>; pub type DiffByPathMap = HashMap<PathBuf, FileDiff>;
@ -53,6 +52,7 @@ pub struct GitHunk {
#[serde(rename = "diff", serialize_with = "crate::serde::as_string_lossy")] #[serde(rename = "diff", serialize_with = "crate::serde::as_string_lossy")]
pub diff_lines: BString, pub diff_lines: BString,
pub binary: bool, pub binary: bool,
pub locked_to: Box<[HunkLock]>,
pub change_type: ChangeType, pub change_type: ChangeType,
} }
@ -69,6 +69,7 @@ impl GitHunk {
diff_lines: hex_id.into(), diff_lines: hex_id.into(),
binary: true, binary: true,
change_type, change_type,
locked_to: Box::new([]),
} }
} }
@ -82,6 +83,7 @@ impl GitHunk {
diff_lines: Default::default(), diff_lines: Default::default(),
binary: false, binary: false,
change_type: ChangeType::Modified, change_type: ChangeType::Modified,
locked_to: Box::new([]),
} }
} }
} }
@ -91,6 +93,21 @@ impl GitHunk {
pub fn contains(&self, line: u32) -> bool { pub fn contains(&self, line: u32) -> bool {
self.new_start <= line && self.new_start + self.new_lines >= line self.new_start <= line && self.new_start + self.new_lines >= line
} }
pub fn with_locks(mut self, locks: &[HunkLock]) -> Self {
self.locked_to = locks.to_owned().into();
self
}
}
// A hunk is locked when it depends on changes in commits that are in your
// workspace. A hunk can be locked to more than one branch if it overlaps
// with more than one committed hunk.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Copy)]
#[serde(rename_all = "camelCase")]
pub struct HunkLock {
pub branch_id: uuid::Uuid,
pub commit_id: git::Oid,
} }
#[derive(Debug, PartialEq, Clone, Serialize, Default)] #[derive(Debug, PartialEq, Clone, Serialize, Default)]
@ -298,6 +315,7 @@ fn hunks_by_filepath(repo: Option<&Repository>, diff: &git2::Diff) -> Result<Dif
diff_lines: line.into_owned(), diff_lines: line.into_owned(),
binary: false, binary: false,
change_type, change_type,
locked_to: Box::new([]),
} }
} }
LineOrHexHash::HexHashOfBinaryBlob(id) => { LineOrHexHash::HexHashOfBinaryBlob(id) => {
@ -404,12 +422,13 @@ pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
diff_lines: diff, diff_lines: diff,
binary: hunk.binary, binary: hunk.binary,
change_type: hunk.change_type, change_type: hunk.change_type,
locked_to: Box::new([]),
}) })
} }
} }
// TODO(ST): turning this into an iterator will trigger a cascade of changes that pub fn diff_files_into_hunks(
// mean less unnecessary copies. It also leads to `virtual.rs` - 4k SLOC! files: DiffByPathMap,
pub fn diff_files_into_hunks(files: DiffByPathMap) -> BranchStatus { ) -> impl Iterator<Item = (PathBuf, Vec<GitHunk>)> {
HashMap::from_iter(files.into_iter().map(|(path, file)| (path, file.hunks))) files.into_iter().map(|(path, file)| (path, file.hunks))
} }

View File

@ -20,6 +20,8 @@ pub enum Error {
Hooks(#[from] git2_hooks::HooksError), Hooks(#[from] git2_hooks::HooksError),
#[error("http error: {0}")] #[error("http error: {0}")]
Http(git2::Error), Http(git2::Error),
#[error("blame error: {0}")]
Blame(git2::Error),
#[error("checkout error: {0}")] #[error("checkout error: {0}")]
Checkout(git2::Error), Checkout(git2::Error),
#[error(transparent)] #[error(transparent)]

View File

@ -64,6 +64,8 @@ impl FromStr for Refname {
return Err(Error::NotRemote(value.to_string())); return Err(Error::NotRemote(value.to_string()));
}; };
// TODO(ST): use `gix` (which respects refspecs and settings) to do this transformation
// Alternatively, `git2` also has support for respecting refspecs.
let value = value.strip_prefix("refs/remotes/").unwrap(); let value = value.strip_prefix("refs/remotes/").unwrap();
if let Some((remote, branch)) = value.split_once('/') { if let Some((remote, branch)) = value.split_once('/') {

View File

@ -1,6 +1,6 @@
use std::{io::Write, path::Path, str}; use std::{io::Write, path::Path, str};
use git2::Submodule; use git2::{BlameOptions, Submodule};
use git2_hooks::HookResult; use git2_hooks::HookResult;
use super::{ use super::{
@ -478,6 +478,24 @@ impl Repository {
git2_hooks::hooks_post_commit(&self.0, Some(&["../.husky"]))?; git2_hooks::hooks_post_commit(&self.0, Some(&["../.husky"]))?;
Ok(()) Ok(())
} }
pub fn blame(
&self,
path: &Path,
min_line: u32,
max_line: u32,
oldest_commit: &Oid,
newest_commit: &Oid,
) -> Result<git2::Blame> {
let mut opts = BlameOptions::new();
opts.min_line(min_line as usize)
.max_line(max_line as usize)
.newest_commit(git2::Oid::from(*newest_commit))
.oldest_commit(git2::Oid::from(*oldest_commit));
self.0
.blame_file(path, Some(&mut opts))
.map_err(super::Error::Blame)
}
} }
pub struct CheckoutTreeBuidler<'a> { pub struct CheckoutTreeBuidler<'a> {

View File

@ -1,4 +1,5 @@
use anyhow::Context; use anyhow::Context;
use std::path::PathBuf;
use super::{storage::Storage, PrivateKey}; use super::{storage::Storage, PrivateKey};
@ -12,7 +13,7 @@ impl Controller {
Self { storage } Self { storage }
} }
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Self { pub fn from_path(path: impl Into<PathBuf>) -> Self {
Self::new(Storage::from_path(path)) Self::new(Storage::from_path(path))
} }

View File

@ -1,42 +1,40 @@
use super::PrivateKey; use super::PrivateKey;
use crate::storage; use crate::storage;
use std::path::PathBuf;
// TODO(ST): get rid of this type, it's more trouble than it's worth.
#[derive(Clone)] #[derive(Clone)]
pub struct Storage { pub struct Storage {
storage: storage::Storage, inner: storage::Storage,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("IO error: {0}")] #[error(transparent)]
Storage(#[from] storage::Error), Storage(#[from] std::io::Error),
#[error("SSH key error: {0}")] #[error("SSH key error: {0}")]
SSHKey(#[from] ssh_key::Error), SSHKey(#[from] ssh_key::Error),
} }
impl Storage { impl Storage {
pub fn new(storage: storage::Storage) -> Storage { pub fn new(storage: storage::Storage) -> Storage {
Storage { storage } Storage { inner: storage }
} }
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Storage { pub fn from_path(path: impl Into<PathBuf>) -> Storage {
Storage::new(storage::Storage::new(path)) Storage::new(storage::Storage::new(path))
} }
pub fn get(&self) -> Result<Option<PrivateKey>, Error> { pub fn get(&self) -> Result<Option<PrivateKey>, Error> {
self.storage let key = self.inner.read("keys/ed25519")?;
.read("keys/ed25519") key.map(|s| s.parse().map_err(Into::into)).transpose()
.map_err(Error::Storage)
.and_then(|s| s.map(|s| s.parse().map_err(Error::SSHKey)).transpose())
} }
// TODO(ST): see if Key should rather deal with bytes instead for this kind of serialization.
pub fn create(&self, key: &PrivateKey) -> Result<(), Error> { pub fn create(&self, key: &PrivateKey) -> Result<(), Error> {
self.storage self.inner.write("keys/ed25519", &key.to_string())?;
.write("keys/ed25519", &key.to_string()) self.inner
.map_err(Error::Storage)?; .write("keys/ed25519.pub", &key.public_key().to_string())?;
self.storage
.write("keys/ed25519.pub", &key.public_key().to_string())
.map_err(Error::Storage)?;
Ok(()) Ok(())
} }
} }

View File

@ -30,8 +30,10 @@ pub mod project_repository;
pub mod projects; pub mod projects;
pub mod reader; pub mod reader;
pub mod sessions; pub mod sessions;
pub mod snapshots;
pub mod ssh; pub mod ssh;
pub mod storage; pub mod storage;
pub mod time;
pub mod types; pub mod types;
pub mod users; pub mod users;
pub mod virtual_branches; pub mod virtual_branches;

View File

@ -95,6 +95,8 @@ pub fn conflicting_files(repository: &Repository) -> Result<Vec<String>> {
Ok(reader.lines().map_while(Result::ok).collect()) Ok(reader.lines().map_while(Result::ok).collect())
} }
/// Check if `path` is conflicting in `repository`, or if `None`, check if there is any conflict.
// TODO(ST): Should this not rather check the conflicting state in the index?
pub fn is_conflicting<P: AsRef<Path>>(repository: &Repository, path: Option<P>) -> Result<bool> { pub fn is_conflicting<P: AsRef<Path>>(repository: &Repository, path: Option<P>) -> Result<bool> {
let conflicts_path = repository.git_repository.path().join("conflicts"); let conflicts_path = repository.git_repository.path().join("conflicts");
if !conflicts_path.exists() { if !conflicts_path.exists() {
@ -105,6 +107,7 @@ pub fn is_conflicting<P: AsRef<Path>>(repository: &Repository, path: Option<P>)
let reader = std::io::BufReader::new(file); let reader = std::io::BufReader::new(file);
let mut files = reader.lines().map_ok(PathBuf::from); let mut files = reader.lines().map_ok(PathBuf::from);
if let Some(pathname) = path { if let Some(pathname) = path {
// TODO(ST): This shouldn't work on UTF8 strings.
let pathname = pathname.as_ref(); let pathname = pathname.as_ref();
// check if pathname is one of the lines in conflicts_path file // check if pathname is one of the lines in conflicts_path file

View File

@ -61,7 +61,7 @@ impl Repository {
// XXX(qix-): We will ultimately move away from an internal repository for a variety // XXX(qix-): We will ultimately move away from an internal repository for a variety
// XXX(qix-): of reasons, but for now, this is a simple, short-term solution that we // XXX(qix-): of reasons, but for now, this is a simple, short-term solution that we
// XXX(qix-): can clean up later on. We're aware this isn't ideal. // XXX(qix-): can clean up later on. We're aware this isn't ideal.
if let Ok(config) = git_repository.config().as_mut(){ if let Ok(config) = git_repository.config().as_mut() {
let should_set = match config.get_bool("gitbutler.didSetPrune") { let should_set = match config.get_bool("gitbutler.didSetPrune") {
Ok(None | Some(false)) => true, Ok(None | Some(false)) => true,
Ok(Some(true)) => false, Ok(Some(true)) => false,
@ -76,7 +76,10 @@ impl Repository {
}; };
if should_set { if should_set {
if let Err(error) = config.set_str("gc.pruneExpire", "never").and_then(|()| config.set_bool("gitbutler.didSetPrune", true)) { if let Err(error) = config
.set_str("gc.pruneExpire", "never")
.and_then(|()| config.set_bool("gitbutler.didSetPrune", true))
{
tracing::warn!( tracing::warn!(
"failed to set gc.auto to false for repository at {}; cannot disable gc: {}", "failed to set gc.auto to false for repository at {}; cannot disable gc: {}",
project.path.display(), project.path.display(),
@ -175,17 +178,11 @@ impl Repository {
let branch = self.git_repository.find_branch(&target_branch_refname)?; let branch = self.git_repository.find_branch(&target_branch_refname)?;
let commit_id = branch.peel_to_commit()?.id(); let commit_id = branch.peel_to_commit()?.id();
let now = std::time::SystemTime::now() let now = crate::time::now_ms();
.duration_since(std::time::UNIX_EPOCH) let branch_name = format!("test-push-{now}");
.unwrap_or(std::time::Duration::from_secs(0))
.as_millis()
.to_string();
let branch_name = format!("test-push-{}", now);
let refname = git::RemoteRefname::from_str(&format!( let refname =
"refs/remotes/{}/{}", git::RemoteRefname::from_str(&format!("refs/remotes/{remote_name}/{branch_name}",))?;
remote_name, branch_name,
))?;
match self.push( match self.push(
&commit_id, &commit_id,
@ -623,6 +620,8 @@ pub enum RemoteError {
Network, Network,
#[error("authentication failed")] #[error("authentication failed")]
Auth, Auth,
#[error("Git failed")]
Git(#[from] git::Error),
#[error(transparent)] #[error(transparent)]
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
} }
@ -638,6 +637,9 @@ impl ErrorWithContext for RemoteError {
Code::ProjectGitAuth, Code::ProjectGitAuth,
"Project remote authentication error", "Project remote authentication error",
), ),
RemoteError::Git(_) => {
error::Context::new_static(Code::ProjectGitRemote, "Git command failed")
}
RemoteError::Other(error) => { RemoteError::Other(error) => {
return error.custom_context_or_root_cause().into(); return error.custom_context_or_root_cause().into();
} }

Some files were not shown because too many files have changed in this diff Show More