[
  {
    "path": ".editorconfig",
    "content": "# top-most EditorConfig file\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 4\ntab_width = 4"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: File a bug report\ntitle: \"[Bug]: \"\nlabels: \"bug\"\nbody:\n- type: markdown\n  attributes:\n    value: \"**Please make sure you are on the latest version.**\"\n- type: textarea\n  id: what-happened\n  attributes:\n    label: Describe the bug\n    placeholder: The following error occurs when running command X when I have X enabled. \n  validations:\n    required: true\n- type: textarea\n  id: logs\n  attributes:\n    label: Relevant errors (if available) from notifications or console (`CTRL+SHIFT+I`)\n    description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.\n    render: shell\n- type: textarea\n  id: reproduce\n  attributes:\n    label: Steps to reproduce\n  validations:\n    required: true\n- type: textarea\n  id: expected\n  attributes:\n    label: Expected Behavior\n    description: A clear and concise description of what you expected to happen.\n- type: textarea\n  id: context\n  attributes:\n    label: Addition context\n    description: Add any other context about the problem here.\n- type: dropdown\n  id: os\n  attributes:\n    label: Operating system\n    description: Which OS are you using?\n    options:\n      - Windows\n      - Linux\n      - macOS\n      - Android\n      - iOS\n  validations:\n    required: true\n- type: dropdown\n  id: installation-method\n  attributes:\n    label: Installation Method\n    description: Only necessary on Linux\n    options:\n      - Flatpak\n      - AppImage\n      - Snap\n      - Other\n  validations:\n    required: false\n- type: input\n  id: version\n  attributes: \n    label: Plugin version\n  validations:\n    required: true\n"
  },
  {
    "path": ".github/workflows/releases.yml",
    "content": "name: Build obsidian plugin\n\non:\n    push:\n        tags:\n            - \"*\"\n\nenv:\n    PLUGIN_NAME: obsidian-git\n\npermissions:\n    contents: write\n\njobs:\n    build:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v4\n            - uses: pnpm/action-setup@v2\n              with:\n                  version: latest\n            - name: Build\n              id: build\n              run: |\n                  pnpm install\n                  pnpm run build --if-present\n                  mkdir ${{ env.PLUGIN_NAME }}\n                  cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }}\n                  zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }}\n            - name: Create Release\n              id: create_release\n              uses: softprops/action-gh-release@v1\n              with:\n                  tag_name: ${{ github.ref }}\n                  name: ${{ github.ref_name }}\n                  generate_release_notes: true\n                  draft: false\n                  prerelease: false\n            - name: Upload zip file\n              id: upload-zip\n              uses: actions/upload-release-asset@v1\n              env:\n                  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              with:\n                  upload_url: ${{ steps.create_release.outputs.upload_url }}\n                  asset_path: ./${{ env.PLUGIN_NAME }}.zip\n                  asset_name: ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip\n                  asset_content_type: application/zip\n            - name: Upload main.js\n              id: upload-main\n              uses: actions/upload-release-asset@v1\n              env:\n                  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              with:\n                  upload_url: ${{ steps.create_release.outputs.upload_url }}\n                  asset_path: ./main.js\n                  asset_name: main.js\n                  asset_content_type: text/javascript\n            - name: Upload manifest.json\n              id: upload-manifest\n              uses: actions/upload-release-asset@v1\n              env:\n                  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              with:\n                  upload_url: ${{ steps.create_release.outputs.upload_url }}\n                  asset_path: ./manifest.json\n                  asset_name: manifest.json\n                  asset_content_type: application/json\n            - name: Upload styles.css\n              id: upload-css\n              uses: actions/upload-release-asset@v1\n              env:\n                  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n              with:\n                  upload_url: ${{ steps.create_release.outputs.upload_url }}\n                  asset_path: ./styles.css\n                  asset_name: styles.css\n                  asset_content_type: text/css\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: test\non:\n    push:\n    pull_request:\njobs:\n    svelte-check:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v4\n            - uses: pnpm/action-setup@v2\n              with:\n                  version: latest\n            - name: Install modules\n              run: pnpm install\n            - name: Run Svelte-Check\n              run: pnpm run svelte\n    lint:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v4\n            - uses: pnpm/action-setup@v2\n              with:\n                  version: latest\n            - name: Install modules\n              run: pnpm install\n            - name: Run ESLint\n              run: pnpm run lint\n    format:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v4\n            - uses: pnpm/action-setup@v2\n              with:\n                  version: latest\n            - name: Install modules\n              run: pnpm install\n            - name: Run Prettier\n              run: pnpm run format\n    compile:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v4\n            - uses: pnpm/action-setup@v2\n              with:\n                  version: latest\n            - name: Install modules\n              run: pnpm install\n            - name: Run tsc\n              run: tsc --noEmit\n    build:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v4\n            - uses: pnpm/action-setup@v2\n              with:\n                  version: latest\n            - name: Install modules\n              run: pnpm install\n            - name: Run build\n              run: pnpm run build\n"
  },
  {
    "path": ".gitignore",
    "content": "# Intellij\n*.iml\n.idea\n\n# npm\nnode_modules\nmain.js\nyarn.lock\n\n# build\n*.js.map\n\n.prettierignore\n/data.json\n\n.vscode\n\n.DS_Store\n"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n    \"trailingComma\": \"es5\",\n    \"tabWidth\": 4,\n    \"semi\": true,\n    \"plugins\": [\"prettier-plugin-svelte\"],\n    \"overrides\": [{ \"files\": \"*.svelte\", \"options\": { \"parser\": \"svelte\" } }]\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.\n\n## [2.38.0](https://github.com/Vinzent03/obsidian-git/compare/2.37.1...2.38.0) (2026-03-04)\n\n\n### Features\n\n* Default {{hostname}} placeholder to OS hostname when not explicitly configured ([#1044](https://github.com/Vinzent03/obsidian-git/issues/1044)) ([1cc045d](https://github.com/Vinzent03/obsidian-git/commit/1cc045dbc2a93b33d6c0ef02e6752a889685efdd))\n\n\n### Bug Fixes\n\n* do not alter Obsidian's own process env vars ([b8bd1ec](https://github.com/Vinzent03/obsidian-git/commit/b8bd1ec521561f6888857852cb565f66cc6a2241)), closes [#1041](https://github.com/Vinzent03/obsidian-git/issues/1041)\n* handle having a config value defined multiple times ([8e15ddc](https://github.com/Vinzent03/obsidian-git/commit/8e15ddc3bc2ce27b8c0afbc736ccc0258daffe3b)), closes [#1038](https://github.com/Vinzent03/obsidian-git/issues/1038)\n* wrap buttons in source control view ([932b7a0](https://github.com/Vinzent03/obsidian-git/commit/932b7a0b617b4f09d70fa7f48072e13ecd5f7f05)), closes [#1011](https://github.com/Vinzent03/obsidian-git/issues/1011)\n\n### [2.37.1](https://github.com/Vinzent03/obsidian-git/compare/2.37.0...2.37.1) (2026-02-15)\n\n## [2.37.0](https://github.com/Vinzent03/obsidian-git/compare/2.36.1...2.37.0) (2026-02-13)\n\n\n### Features\n\n* add settings pane icon ([5ea08b8](https://github.com/Vinzent03/obsidian-git/commit/5ea08b874e2bfdc532e8e351921a4a3d97ff41bb))\n* allow empty default manual commit message ([#1022](https://github.com/Vinzent03/obsidian-git/issues/1022)) ([20c942e](https://github.com/Vinzent03/obsidian-git/commit/20c942e4780e9ed272c92824f89b703dd59f53db))\n* close hunk preview with escape key ([fd9bef8](https://github.com/Vinzent03/obsidian-git/commit/fd9bef871ceed52834cae41add670ed5d59420b5)), closes [#1027](https://github.com/Vinzent03/obsidian-git/issues/1027)\n* respect push.autoSetupRemote config ([bde2b3d](https://github.com/Vinzent03/obsidian-git/commit/bde2b3df909e4557e15591d6def20a80e15cec40)), closes [#1020](https://github.com/Vinzent03/obsidian-git/issues/1020)\n* set author info as html attribute in line author information ([fad0dae](https://github.com/Vinzent03/obsidian-git/commit/fad0dae28391cba62744103165ddf0a909fa1014)), closes [#1017](https://github.com/Vinzent03/obsidian-git/issues/1017)\n* show commit summary as tooltip in line author information ([8746e4a](https://github.com/Vinzent03/obsidian-git/commit/8746e4a4f9591a0f83c42d71b1cdb44911364f80))\n\n\n### Bug Fixes\n\n* some ssh askpass fixes ([b89d095](https://github.com/Vinzent03/obsidian-git/commit/b89d095a2b8a2b6ef943772c0d54602be74e6229)), closes [#994](https://github.com/Vinzent03/obsidian-git/issues/994)\n\n### [2.36.1](https://github.com/Vinzent03/obsidian-git/compare/2.36.0...2.36.1) (2026-01-11)\n\n\n### Bug Fixes\n\n* align font size in unified diff with Obsidian ([99b2522](https://github.com/Vinzent03/obsidian-git/commit/99b25224a0a38b2a9885ddada7bad6774e70cd7f)), closes [#1008](https://github.com/Vinzent03/obsidian-git/issues/1008)\n* align signs properly with different line heights ([5bdf09c](https://github.com/Vinzent03/obsidian-git/commit/5bdf09c464d25e6dfb8b685f64c2a3c5af901f79))\n* less gutter updates for same git result ([4da4c64](https://github.com/Vinzent03/obsidian-git/commit/4da4c647feded146f2e5896e20b50b2319415d0d))\n* obscure password prompt in isomorphic-git auth ([#1007](https://github.com/Vinzent03/obsidian-git/issues/1007)) ([174cad8](https://github.com/Vinzent03/obsidian-git/commit/174cad88ab81e232e6b14abaebe4482afefa3846))\n\n## [2.36.0](https://github.com/Vinzent03/obsidian-git/compare/2.35.2...2.36.0) (2026-01-04)\n\n\n### Features\n\n* buttons for hunk actions and display on click ([a3dcd02](https://github.com/Vinzent03/obsidian-git/commit/a3dcd02fba27afc895968af6df6e752c5f06842f))\n* command to preview hunk ([7f26cfe](https://github.com/Vinzent03/obsidian-git/commit/7f26cfe131b6ec47c2c361d510855a19a412bcaf))\n* display hunk changes inline ([f346cac](https://github.com/Vinzent03/obsidian-git/commit/f346cac94199b0a97fda8c8d7908765c075d6109))\n* go to prev/next hunk commands ([48d6658](https://github.com/Vinzent03/obsidian-git/commit/48d66580564fd4105291cdae56180d7e005a080a))\n* granular settings and change status bar ([29bd5ae](https://github.com/Vinzent03/obsidian-git/commit/29bd5ae0ff916b33b43bff028340ee476ad7b81c))\n* hunk actions ([1c32ceb](https://github.com/Vinzent03/obsidian-git/commit/1c32ceb038f112c674ed102dc78bf36a2c7018cf))\n* specify merge strategy in settings ([#934](https://github.com/Vinzent03/obsidian-git/issues/934)) ([d64e4d7](https://github.com/Vinzent03/obsidian-git/commit/d64e4d7dfa57a7f5fb2073f9ebaaa2643acefdf8))\n* stage hunk ([5f0a5a2](https://github.com/Vinzent03/obsidian-git/commit/5f0a5a2344b2d390e0da300f50ed88d0221ec1f6))\n* stage individual hunks from split diff view ([47e97c9](https://github.com/Vinzent03/obsidian-git/commit/47e97c9911655227de401d4da8eccbc160040f95))\n\n\n### Bug Fixes\n\n* detect another error message for offline mode ([387d4bd](https://github.com/Vinzent03/obsidian-git/commit/387d4bd895a5fd04df80cd0705705d93e7fce4bd)), closes [#990](https://github.com/Vinzent03/obsidian-git/issues/990)\n* disable staged hunks as their computation is not feasible ([5ff0fed](https://github.com/Vinzent03/obsidian-git/commit/5ff0fede7f15d4ae3e7e9e0b2c445ec1cced0d59))\n* don't discard ignored files ([5483881](https://github.com/Vinzent03/obsidian-git/commit/54838815eae722a386de17fff71a1434bc6d9f84)), closes [#1006](https://github.com/Vinzent03/obsidian-git/issues/1006)\n* don't remove dom elements in line authoring ([4b29d76](https://github.com/Vinzent03/obsidian-git/commit/4b29d76df035be329c55f5718a0542a85bf2f941))\n* escape file path for css selector ([c6fcbdf](https://github.com/Vinzent03/obsidian-git/commit/c6fcbdf7fa813bfcf3047fd41174d60ea3ffdbf1)), closes [#986](https://github.com/Vinzent03/obsidian-git/issues/986)\n* fallback to debounced diff for slow diffs ([cfac162](https://github.com/Vinzent03/obsidian-git/commit/cfac162d52cddc5b3435ae95277ea1a7cce8ba9c))\n* make diff buttons horizontal ([5d2627c](https://github.com/Vinzent03/obsidian-git/commit/5d2627c3b31ec233402c5cf58f3e86a292ee19a1))\n* only show non 0 numbers in change status bar ([9ee0c06](https://github.com/Vinzent03/obsidian-git/commit/9ee0c06c0d5595f89fdc371942a1f60692d87403))\n* properly disable signs via settings ([bda731b](https://github.com/Vinzent03/obsidian-git/commit/bda731b15f36f69219e54943be1caace6b5de47a))\n* properly manage extensions ([067144c](https://github.com/Vinzent03/obsidian-git/commit/067144cd3c6fddd88eb3c478de7baf6a46efb459))\n* properly show tooltip ([f5a48af](https://github.com/Vinzent03/obsidian-git/commit/f5a48afde5e6ce3a971f70a2b3866313045b3bd8))\n* refresh cached file index version every 10 seconds ([4ba53a4](https://github.com/Vinzent03/obsidian-git/commit/4ba53a428bc61285f1bec37112f2996f234dcc12))\n* reset changing an empty line ([9549bde](https://github.com/Vinzent03/obsidian-git/commit/9549bde6bd58f8186a558c5157a8f5ad3a9e4737))\n* reset new empty line ([8cf494e](https://github.com/Vinzent03/obsidian-git/commit/8cf494e7375b62c4ec3e4cdefc8310c53dca0e19))\n* select hunks and unstage no_nl_at_eof ([67ea27e](https://github.com/Vinzent03/obsidian-git/commit/67ea27e6925c901d6963ca27e7bf7a98a8bd29ed))\n* show signs for new hunks on refresh ([491784d](https://github.com/Vinzent03/obsidian-git/commit/491784d381528c4f168da2399d7327c37e55479f))\n* show tooltip for topdelete ([adae383](https://github.com/Vinzent03/obsidian-git/commit/adae383b0d661389d1e357a3e435d40f7025b939))\n* small fixes ([b90eeb8](https://github.com/Vinzent03/obsidian-git/commit/b90eeb8229620f2d613b6816212709ce1b87a199))\n* update settings description ([5e13e49](https://github.com/Vinzent03/obsidian-git/commit/5e13e492118c36beca22b4f138d7ec481a95f3ba))\n* update styling ([ebd9734](https://github.com/Vinzent03/obsidian-git/commit/ebd973471ddfa7e267ad85c442a89f9ef2dd1eb1))\n* use rem instead of px for dimensions ([60e11b7](https://github.com/Vinzent03/obsidian-git/commit/60e11b77ca2a49281d9c79ce45d7a858dfa7a905))\n\n### [2.35.2](https://github.com/Vinzent03/obsidian-git/compare/2.35.1...2.35.2) (2025-11-05)\n\n\n### Bug Fixes\n\n* catch aborted clone depth modal ([d11579c](https://github.com/Vinzent03/obsidian-git/commit/d11579c96932f0b2c16c7306c5a05cfbae2509b1))\n* only check files to be committed for big files ([91654ef](https://github.com/Vinzent03/obsidian-git/commit/91654ef1e0a29fe4b8e67612b25c314a2cb95eb9)), closes [#966](https://github.com/Vinzent03/obsidian-git/issues/966)\n* prepend custom PATH instead of append ([b8da471](https://github.com/Vinzent03/obsidian-git/commit/b8da471eaf342eb3450cc13cc7a3c57887c7e7d3)), closes [#981](https://github.com/Vinzent03/obsidian-git/issues/981)\n* save edits from split diff view for repo not in vault root ([71fe8dc](https://github.com/Vinzent03/obsidian-git/commit/71fe8dc1be54bfb2aa9dd3d1861b778c212a3a35)), closes [#985](https://github.com/Vinzent03/obsidian-git/issues/985)\n* trim git object result ([0f3d368](https://github.com/Vinzent03/obsidian-git/commit/0f3d368fea440f4a703ea8db21798c2af6d64557))\n\n### [2.35.1](https://github.com/Vinzent03/obsidian-git/compare/2.35.0...2.35.1) (2025-09-19)\n\n\n### Bug Fixes\n\n* correctly abort selection when no branch selected ([23c009a](https://github.com/Vinzent03/obsidian-git/commit/23c009a2e9d23f0be6c882b2b992248e24bcf2da))\n* don't collapse changed files on stage and add bottom padding ([3832059](https://github.com/Vinzent03/obsidian-git/commit/38320597ec712804eecc04dfcd64ce774963c303))\n* get last commit time on branch without commits ([73300a1](https://github.com/Vinzent03/obsidian-git/commit/73300a1bf7c6333ea389a6792f73ea412b7f751d))\n* Prevent jitter of refresh icon in mobile ([#941](https://github.com/Vinzent03/obsidian-git/issues/941)) ([180a314](https://github.com/Vinzent03/obsidian-git/commit/180a314ca65bf666a8e15aaa07dc0d6b1e1ec838))\n* refresh view when staging files from menu ([1bd92c3](https://github.com/Vinzent03/obsidian-git/commit/1bd92c3961ac311312ff20e9fa2e3067a95c0434))\n\n## [2.35.0](https://github.com/Vinzent03/obsidian-git/compare/2.34.0...2.35.0) (2025-08-07)\n\n\n### Features\n\n* add hidden commit staged command ([234bf8f](https://github.com/Vinzent03/obsidian-git/commit/234bf8f97e767b2523c74dc336a2c690b9d231b9)), closes [#932](https://github.com/Vinzent03/obsidian-git/issues/932)\n* auto commit only staged files ([e64ef60](https://github.com/Vinzent03/obsidian-git/commit/e64ef60406c52729f5105887f27e9db10a9e4cb2)), closes [#927](https://github.com/Vinzent03/obsidian-git/issues/927)\n* pause automatics via command ([f6f650e](https://github.com/Vinzent03/obsidian-git/commit/f6f650e4e85337f8e1613255899f191c12989f38))\n\n\n### Bug Fixes\n\n* catch not existing tracking branch ([5833ed3](https://github.com/Vinzent03/obsidian-git/commit/5833ed39c4c14e6c88e58713368c6a558a71425a))\n* correctly set non-existing remote branch as upstream branch ([8283e10](https://github.com/Vinzent03/obsidian-git/commit/8283e10a26492c6359e68219750be5951e8c090d)), closes [#599](https://github.com/Vinzent03/obsidian-git/issues/599)\n* discard files correctly ([1c4e0c3](https://github.com/Vinzent03/obsidian-git/commit/1c4e0c3e37fbc466d33a2f0e8089a65f4de8b103))\n* don't include all files in status on mobile ([8410e32](https://github.com/Vinzent03/obsidian-git/commit/8410e3219ae68b7fd9bea39274e0d81155a560f3))\n* get old path of renamed file ([b31a855](https://github.com/Vinzent03/obsidian-git/commit/b31a855102cd8f81a5a36cae160bac77b96ddfd6)), closes [#944](https://github.com/Vinzent03/obsidian-git/issues/944)\n* properly close toggle tree items ([69fdf79](https://github.com/Vinzent03/obsidian-git/commit/69fdf79c5b45a114043996b5fafd4839728f32e6)), closes [#950](https://github.com/Vinzent03/obsidian-git/issues/950)\n* properly color hovered icon buttons ([197a4c7](https://github.com/Vinzent03/obsidian-git/commit/197a4c7f82121b75e47ab61cae10ca0fb59e1587))\n* use correct remote for push on mobile ([7e54350](https://github.com/Vinzent03/obsidian-git/commit/7e5435000116eaefc16826e6747cb5004675efa1))\n\n## [2.34.0](https://github.com/Vinzent03/obsidian-git/compare/2.33.0...2.34.0) (2025-06-14)\n\n\n### Features\n\n* commit message from script ([c05f847](https://github.com/Vinzent03/obsidian-git/commit/c05f847baac1cfa121755bd6a10cfc5316a8f680)), closes [#849](https://github.com/Vinzent03/obsidian-git/issues/849)\n* make commit command and commit button in source control 'smart' ([f03d75e](https://github.com/Vinzent03/obsidian-git/commit/f03d75e5f9d239d89e7770dfe86c21e9aa7db632)), closes [#907](https://github.com/Vinzent03/obsidian-git/issues/907)\n\n\n### Bug Fixes\n\n* don't use spawnSync, but a non-blocking alternative ([ad03171](https://github.com/Vinzent03/obsidian-git/commit/ad03171300604b968340ca9212790da01b6ba1fd))\n\n## [2.33.0](https://github.com/Vinzent03/obsidian-git/compare/2.32.1...2.33.0) (2025-04-29)\n\n\n### Features\n\n* add setting to hide error notifications ([54f0541](https://github.com/Vinzent03/obsidian-git/commit/54f0541296d910dba317f4f83cf0fbbc1702a596)), closes [#870](https://github.com/Vinzent03/obsidian-git/issues/870)\n* collapse unchanged lines in split diff view ([#893](https://github.com/Vinzent03/obsidian-git/issues/893)) ([6377157](https://github.com/Vinzent03/obsidian-git/commit/637715747deb465ca0730d273002679b8bde5540))\n* ctrl + enter to commit-and-sync ([e15cdc0](https://github.com/Vinzent03/obsidian-git/commit/e15cdc06eaa9a1b33f39535621fd6ee873cd27e0)), closes [#901](https://github.com/Vinzent03/obsidian-git/issues/901)\n\n\n### Bug Fixes\n\n* add obsidian_askpass.sh to .git/info/exclude ([655d171](https://github.com/Vinzent03/obsidian-git/commit/655d1718da42a7eacce8e9a8b066c57304c512bb)), closes [#903](https://github.com/Vinzent03/obsidian-git/issues/903)\n* don't overwrite debug logging of other packages ([#905](https://github.com/Vinzent03/obsidian-git/issues/905)) ([c258fe2](https://github.com/Vinzent03/obsidian-git/commit/c258fe2bcb45be83833f07e5d8d2b788f5571992))\n* duplicated status bar ([f653019](https://github.com/Vinzent03/obsidian-git/commit/f65301946ee7d3160f5b6b00ca2f0aec8e0ef6ed)), closes [#892](https://github.com/Vinzent03/obsidian-git/issues/892)\n* handle \"Add to .gitignore\" edgecases and make the added path absolute ([#890](https://github.com/Vinzent03/obsidian-git/issues/890)) ([485e4cd](https://github.com/Vinzent03/obsidian-git/commit/485e4cda3d374cc5e0d5ea93b0496f6ef24d1aa3))\n* improve settings ui ([#886](https://github.com/Vinzent03/obsidian-git/issues/886)) ([edbbfb6](https://github.com/Vinzent03/obsidian-git/commit/edbbfb610462a3dd85436b4d97e74adfc4bcee7d))\n* improve the \"is file openable in obsidian\" check ([#884](https://github.com/Vinzent03/obsidian-git/issues/884)) ([f7b286c](https://github.com/Vinzent03/obsidian-git/commit/f7b286cf0fb146cdd59a39302796e13a744e9c77))\n* line authoring crashes when dom Element cannot be removed. ([#891](https://github.com/Vinzent03/obsidian-git/issues/891)) ([2d2ebfd](https://github.com/Vinzent03/obsidian-git/commit/2d2ebfd9a91aae636215f03e0620bc14c357e333))\n* make view header buttons sticky and contain scrollbar to body ([#864](https://github.com/Vinzent03/obsidian-git/issues/864)) ([953dbd4](https://github.com/Vinzent03/obsidian-git/commit/953dbd4ac350e7c119439b3ddbf8951377464369))\n* obscure password for ssh passphrase prompt ([5dbd3cd](https://github.com/Vinzent03/obsidian-git/commit/5dbd3cd8dedb4ccd97326205392cbd63702152d5)), closes [#845](https://github.com/Vinzent03/obsidian-git/issues/845)\n\n### [2.32.1](https://github.com/Vinzent03/obsidian-git/compare/2.32.0...2.32.1) (2025-03-20)\n\n\n### Bug Fixes\n\n* don't reschedule auto commit-and-sync too often ([7f506c7](https://github.com/Vinzent03/obsidian-git/commit/7f506c7d54cc83bebcf14e22207c53e57a55add0)), closes [#875](https://github.com/Vinzent03/obsidian-git/issues/875)\n* don't reset auto commit-and-sync timer while still running ([bd5b942](https://github.com/Vinzent03/obsidian-git/commit/bd5b9423eb6f90dce2e078cffa7591a4634bfc87))\n* properly hide md extension ([6494ccf](https://github.com/Vinzent03/obsidian-git/commit/6494ccf0375d3790202e803864d2f6cbd46d1269)), closes [#873](https://github.com/Vinzent03/obsidian-git/issues/873)\n* **settings:** remove ability to enter non-number in number input boxes ([#880](https://github.com/Vinzent03/obsidian-git/issues/880)) ([18e966e](https://github.com/Vinzent03/obsidian-git/commit/18e966e3905814b4c7ba9918466529998a45e26e)), closes [#878](https://github.com/Vinzent03/obsidian-git/issues/878)\n\n## [2.32.0](https://github.com/Vinzent03/obsidian-git/compare/2.31.1...2.32.0) (2025-03-05)\n\n\n### Features\n\n* append .git to https remote url if not present ([2aca7c8](https://github.com/Vinzent03/obsidian-git/commit/2aca7c85b40792f619c511206375f224ece5ab27)), closes [#867](https://github.com/Vinzent03/obsidian-git/issues/867)\n* show a mini file menu for hidden files from source control ([d2991cc](https://github.com/Vinzent03/obsidian-git/commit/d2991ccedcd59c56e685c2c776159f236bad8331)), closes [#766](https://github.com/Vinzent03/obsidian-git/issues/766)\n\n\n### Bug Fixes\n\n* check file size for non Obsidian files as well ([0754fd8](https://github.com/Vinzent03/obsidian-git/commit/0754fd87ba5e947096466c3a63890da12e0159ca)), closes [#859](https://github.com/Vinzent03/obsidian-git/issues/859)\n* refresh already opened views on open command ([fbb62ad](https://github.com/Vinzent03/obsidian-git/commit/fbb62ad0cece4dbf36518616312a29463c39227c)), closes [#838](https://github.com/Vinzent03/obsidian-git/issues/838)\n* schedule new automatic after it finishes ([15416cb](https://github.com/Vinzent03/obsidian-git/commit/15416cb57ce126f8cd47cc8446bab77dd751815a)), closes [#851](https://github.com/Vinzent03/obsidian-git/issues/851)\n\n### [2.31.1](https://github.com/Vinzent03/obsidian-git/compare/2.31.0...2.31.1) (2025-01-04)\n\n\n### Bug Fixes\n\n* Allow commiting of files large than 100mb if handled by LFS ([#822](https://github.com/Vinzent03/obsidian-git/issues/822)) ([e67928d](https://github.com/Vinzent03/obsidian-git/commit/e67928db625a57405f0fa1711e9e031f0e89a683))\n* load more logs in history view on scroll ([a17cb55](https://github.com/Vinzent03/obsidian-git/commit/a17cb55bcffd7e0e6028c72b5b0704fb6fb3fe00)), closes [#807](https://github.com/Vinzent03/obsidian-git/issues/807)\n* more efficient too big file check ([43c70d8](https://github.com/Vinzent03/obsidian-git/commit/43c70d8c85e9471b5d859ad7026ef6e44161118c)), closes [#822](https://github.com/Vinzent03/obsidian-git/issues/822)\n\n## [2.31.0](https://github.com/denolehov/obsidian-git/compare/2.30.1...2.31.0) (2024-12-31)\n\n\n### Features\n\n* run raw git commands ([d05b99c](https://github.com/denolehov/obsidian-git/commit/d05b99ca827b630e1c96fdb2a4dc19469b0b9b81))\n* split diff view ([8c8551f](https://github.com/denolehov/obsidian-git/commit/8c8551f05a2cdbc3f389097e870718ecaf04ff0a))\n\n\n### Bug Fixes\n\n* don't open diff for binary files, just open them instead ([4c102fb](https://github.com/denolehov/obsidian-git/commit/4c102fb881764ba30a048b765926eda577c9f5f7)), closes [#801](https://github.com/denolehov/obsidian-git/issues/801)\n* important fixes for diff view ([e5509f9](https://github.com/denolehov/obsidian-git/commit/e5509f981a51638c910f5cc960737b25d77a36ab))\n* reset commit message to template ([16f0bd9](https://github.com/denolehov/obsidian-git/commit/16f0bd9fcf7a70687b529476bb4d9d5929375dbf)), closes [#828](https://github.com/denolehov/obsidian-git/issues/828)\n\n### [2.30.1](https://github.com/denolehov/obsidian-git/compare/2.30.0...2.30.1) (2024-11-28)\n\n\n### Bug Fixes\n\n* use custom git path ([5346fc8](https://github.com/denolehov/obsidian-git/commit/5346fc8995d11ef46de3277f724bca30aa208a49)), closes [#820](https://github.com/denolehov/obsidian-git/issues/820)\n\n## [2.30.0](https://github.com/denolehov/obsidian-git/compare/2.29.0...2.30.0) (2024-11-25)\n\n\n### Features\n\n* if Git is not found in PATH on windows try default location ([4301e31](https://github.com/denolehov/obsidian-git/commit/4301e31b2b66e111826e900e2d0942e0de871c74)), closes [#489](https://github.com/denolehov/obsidian-git/issues/489)\n\n\n### Bug Fixes\n\n* set SSH_AKSPASS instead of GIT_ASKPASS and better error handling ([044cd6a](https://github.com/denolehov/obsidian-git/commit/044cd6a7ace2e2f18f146b4439344190943342ae))\n\n## [2.29.0](https://github.com/denolehov/obsidian-git/compare/2.28.2...2.29.0) (2024-11-24)\n\n\n### Features\n\n* provide a GIT_ASKPASS handler via Obsidian modal ([1a788b0](https://github.com/denolehov/obsidian-git/commit/1a788b01af7cab7ad6d5a850f68a905ec680a2d2))\n\n\n### Bug Fixes\n\n* Clarification which information was not set ([#802](https://github.com/denolehov/obsidian-git/issues/802)) ([45ba119](https://github.com/denolehov/obsidian-git/commit/45ba119fff28547772c59856f2abf8700e544149))\n* create new upstream branch ([c5a95c1](https://github.com/denolehov/obsidian-git/commit/c5a95c1ac9d9a0788d3ae053e83a11dbc30e590d)), closes [#808](https://github.com/denolehov/obsidian-git/issues/808)\n* get unset config values ([e229c22](https://github.com/denolehov/obsidian-git/commit/e229c227acd21d0a1674218b2a778726c3849908))\n* Make the spelling of .gitignore consistent ([#805](https://github.com/denolehov/obsidian-git/issues/805)) ([8366927](https://github.com/denolehov/obsidian-git/commit/836692735d1cadb0023cd503573a58a14c76299e))\n* properly disable auto pull ([f8464e6](https://github.com/denolehov/obsidian-git/commit/f8464e665850c0eac301a881b40b0c74809d4478)), closes [#816](https://github.com/denolehov/obsidian-git/issues/816)\n\n### [2.28.2](https://github.com/denolehov/obsidian-git/compare/2.28.1...2.28.2) (2024-10-30)\n\n\n### Bug Fixes\n\n* duplicated history view ([ec0668d](https://github.com/denolehov/obsidian-git/commit/ec0668d6a69e9907aa8174976e3030f8c289be90)), closes [#800](https://github.com/denolehov/obsidian-git/issues/800)\n\n### [2.28.1](https://github.com/denolehov/obsidian-git/compare/2.28.0...2.28.1) (2024-10-27)\n\n\n### Bug Fixes\n\n* open general modal ([2a0bbd1](https://github.com/denolehov/obsidian-git/commit/2a0bbd13aaff41bf08386133441b4681983538a2)), closes [#798](https://github.com/denolehov/obsidian-git/issues/798)\n\n## [2.28.0](https://github.com/denolehov/obsidian-git/compare/2.27.0...2.28.0) (2024-10-26)\n\n\n### Features\n\n* pull on commit-and-sync even if no commit happened ([263e675](https://github.com/denolehov/obsidian-git/commit/263e675ead38fba8a7d5a5f3349830477817ca25)), closes [#787](https://github.com/denolehov/obsidian-git/issues/787)\n* reload settings on file change ([d453c6f](https://github.com/denolehov/obsidian-git/commit/d453c6fe85143b6afcd672e3fd65ef7851dd9ae3)), closes [#779](https://github.com/denolehov/obsidian-git/issues/779)\n\n\n### Bug Fixes\n\n* recognize more errors as network issues ([4aceed3](https://github.com/denolehov/obsidian-git/commit/4aceed30c5b349431f7a5ed21c4d3b4bdabb88e7)), closes [#735](https://github.com/denolehov/obsidian-git/issues/735)\n* refresh status after opening source control view ([666b8a8](https://github.com/denolehov/obsidian-git/commit/666b8a8f263ce49a3d02dbfb040aa774cce95db2))\n* rework errors ([ee3eff4](https://github.com/denolehov/obsidian-git/commit/ee3eff46e0d98999e6ab312971392adc008d4f6e))\n* strip files list after 500 entries in source control view ([fe1aedb](https://github.com/denolehov/obsidian-git/commit/fe1aedb0a12d4e0b5d4a81821a8d08c1417dd6b8))\n* typo in settings ([1e6c3dd](https://github.com/denolehov/obsidian-git/commit/1e6c3dddae7f5cd3b8393f36817fba94ed3cd12d))\n\n## [2.27.0](https://github.com/denolehov/obsidian-git/compare/2.26.0...2.27.0) (2024-09-18)\n\n\n### Features\n\n* rename backup to commit-and-sync and better settings page ([cd9ffc2](https://github.com/denolehov/obsidian-git/commit/cd9ffc2ebe964dc59c8ce5d114f444222eb1d068))\n\n\n### Bug Fixes\n\n* discard deleted files ([42bf536](https://github.com/denolehov/obsidian-git/commit/42bf536b7973ef35a251a88fa61127b9a3b9972d))\n* discard not tracked directory ([183929b](https://github.com/denolehov/obsidian-git/commit/183929b0cf3da47670faf930d3b6700df65db5c4))\n* don't refresh views if git client is not ready ([7887c7f](https://github.com/denolehov/obsidian-git/commit/7887c7f4518fd2f1f83810ad3fc13cb53af4fe19))\n* refresh data on view loading ([73d2c29](https://github.com/denolehov/obsidian-git/commit/73d2c299298d3b11dd5a0e976b1187d46d17041e))\n* show better diff view for non existing file ([07d9fce](https://github.com/denolehov/obsidian-git/commit/07d9fce47306a3315128327bec6e50ac351d9bff))\n* trigger vault backup after edit on file rename ([d1ad3d4](https://github.com/denolehov/obsidian-git/commit/d1ad3d49a116db03c926df26044e181f9a53a3a0)), closes [#765](https://github.com/denolehov/obsidian-git/issues/765)\n\n## [2.26.0](https://github.com/denolehov/obsidian-git/compare/2.25.0...2.26.0) (2024-09-01)\n\n\n### Features\n\n* open source control view with ribbon button ([dea4d6f](https://github.com/denolehov/obsidian-git/commit/dea4d6f915492ecc3d43b259fbe8764b9c6210a4))\n\n\n### Bug Fixes\n\n* open diffs in new split with middle click ([65ef5ba](https://github.com/denolehov/obsidian-git/commit/65ef5ba2fa783717baaea24d05c6dcc8e760596d))\n* remove root folding line in git views ([b2df0ed](https://github.com/denolehov/obsidian-git/commit/b2df0ed27b2af948df06dcc45021005b8c54e363))\n\n## [2.25.0](https://github.com/denolehov/obsidian-git/compare/2.24.3...2.25.0) (2024-07-23)\n\n\n### Features\n\n* add context menu to git views ([115c4ba](https://github.com/denolehov/obsidian-git/commit/115c4baccaaf0e905fa8eefee8cb5f35abfff88f)), closes [#615](https://github.com/denolehov/obsidian-git/issues/615)\n\n\n### Bug Fixes\n\n* catch sidebar leaf being null ([86065c9](https://github.com/denolehov/obsidian-git/commit/86065c987bb478cbf65b4baca1745d7162041b5d))\n* don't require .git suffix to open file on github ([9b264bf](https://github.com/denolehov/obsidian-git/commit/9b264bffb49a36d86f8af4c17435de2e6ca6c580)), closes [#753](https://github.com/denolehov/obsidian-git/issues/753)\n* open file on github from submodule ([4981f8b](https://github.com/denolehov/obsidian-git/commit/4981f8bcc2eacda44fcebf8a95f458acd514febc)), closes [#592](https://github.com/denolehov/obsidian-git/issues/592)\n* use active color for buttons in file component ([c28d44b](https://github.com/denolehov/obsidian-git/commit/c28d44b1c6f358cdb0113ebcc4b1634a161dfdf2))\n\n### [2.24.3](https://github.com/denolehov/obsidian-git/compare/2.24.2...2.24.3) (2024-06-22)\n\n\n### Bug Fixes\n\n* Adjust git.cwd to use a relative path to git root ([#733](https://github.com/denolehov/obsidian-git/issues/733)) ([ed31553](https://github.com/denolehov/obsidian-git/commit/ed31553a8cf25548ab722c4edf40d8d0a20df4e8))\n* limit amount of files to list in commit msg ([a0416ed](https://github.com/denolehov/obsidian-git/commit/a0416edf5f3ac5d9adc9fc37cf9a9c932583ced4))\n* support vault in subdirectory of git repo ([#722](https://github.com/denolehov/obsidian-git/issues/722)) ([171693f](https://github.com/denolehov/obsidian-git/commit/171693f7fda542f4ba0452a54979d92c8ecfcdb6))\n\n### [2.24.2](https://github.com/denolehov/obsidian-git/compare/2.24.1...2.24.2) (2024-05-09)\n\n\n### Bug Fixes\n\n* ask for upstream branch in backup ([d1143f7](https://github.com/denolehov/obsidian-git/commit/d1143f7d643581ddf674b492921bf0aab9044643))\n* hide line authoring on small width window([#684](https://github.com/denolehov/obsidian-git/issues/684)) ([6a89424](https://github.com/denolehov/obsidian-git/commit/6a89424231ab45ca7741a6d9b96693f63ca40e6e))\n\n### [2.24.1](https://github.com/denolehov/obsidian-git/compare/2.24.0...2.24.1) (2024-03-12)\n\n\n### Bug Fixes\n\n* disable line authoring on mobile ([ac28656](https://github.com/denolehov/obsidian-git/commit/ac2865676135a22a81f8d1a440825e7583aa73ec))\n\n## [2.24.0](https://github.com/denolehov/obsidian-git/compare/2.23.2...2.24.0) (2024-03-04)\n\n\n### Features\n\n* show date and author in history view ([a6e33d3](https://github.com/denolehov/obsidian-git/commit/a6e33d30d1556c485cc2ac6467972aa471b94758)), closes [#691](https://github.com/denolehov/obsidian-git/issues/691)\n\n\n### Bug Fixes\n\n* update submodules without outer remote repo ([675cef5](https://github.com/denolehov/obsidian-git/commit/675cef52d4e0ebbd2d11ff2322aa21649574bf9d)), closes [#701](https://github.com/denolehov/obsidian-git/issues/701)\n\n### [2.23.2](https://github.com/denolehov/obsidian-git/compare/2.23.1...2.23.2) (2024-01-31)\n\n\n### Bug Fixes\n\n* many issues with list changed files ([8b3fc8b](https://github.com/denolehov/obsidian-git/commit/8b3fc8bd69b76193086d5b3a814546a9e3cf51ea)), closes [#655](https://github.com/denolehov/obsidian-git/issues/655)\n\n### [2.23.1](https://github.com/denolehov/obsidian-git/compare/2.23.0...2.23.1) (2024-01-29)\n\n\n### Bug Fixes\n\n* commit in source control view ([90985b1](https://github.com/denolehov/obsidian-git/commit/90985b1b7733eb39247b3aaa71ddc2482012f272)), closes [#686](https://github.com/denolehov/obsidian-git/issues/686)\n\n## [2.23.0](https://github.com/denolehov/obsidian-git/compare/2.22.2...2.23.0) (2024-01-28)\n\n\n### Features\n\n* add commit amend command ([8f10261](https://github.com/denolehov/obsidian-git/commit/8f10261b08a498b0fc8f989209c1e0b048a27c35)), closes [#648](https://github.com/denolehov/obsidian-git/issues/648)\n* add setting to disable 'No changes...' popups ([#676](https://github.com/denolehov/obsidian-git/issues/676)) ([bfd6de9](https://github.com/denolehov/obsidian-git/commit/bfd6de9092aaa18d7624b374d10873a179f12351))\n\n\n### Bug Fixes\n\n* fold only one item ([cd1d932](https://github.com/denolehov/obsidian-git/commit/cd1d93226a4e2f5ebfaa89ada97851c60f35a4fd)), closes [#680](https://github.com/denolehov/obsidian-git/issues/680)\n\n### [2.22.2](https://github.com/denolehov/obsidian-git/compare/2.22.1...2.22.2) (2024-01-26)\n\n### [2.22.1](https://github.com/denolehov/obsidian-git/compare/2.22.0...2.22.1) (2024-01-08)\n\n\n### Bug Fixes\n\n* allow different ssh remote user than git ([5b6400c](https://github.com/denolehov/obsidian-git/commit/5b6400cc85c827bc13e11ffa1bc0cbb3bd6cfd26)), closes [#664](https://github.com/denolehov/obsidian-git/issues/664)\n* create new remote ([1a4cca8](https://github.com/denolehov/obsidian-git/commit/1a4cca8baf20de91ce3ee825740a85f1d33c1744)), closes [#599](https://github.com/denolehov/obsidian-git/issues/599)\n* grammar improvement in settings ([#635](https://github.com/denolehov/obsidian-git/issues/635)) ([1d81577](https://github.com/denolehov/obsidian-git/commit/1d81577877ccb548b06fb91036a246aa442a41ae))\n* tooltip direction ([#600](https://github.com/denolehov/obsidian-git/issues/600)) ([a913303](https://github.com/denolehov/obsidian-git/commit/a91330381e83cfc2ece14186325b129d7fc9b6bf))\n* update settings grammar ([#656](https://github.com/denolehov/obsidian-git/issues/656)) ([d9e8be1](https://github.com/denolehov/obsidian-git/commit/d9e8be14b5dbb64a9b78b2d3fe56c474bd57596f))\n\n## [2.22.0](https://github.com/denolehov/obsidian-git/compare/2.21.0...2.22.0) (2023-08-30)\n\n\n### Features\n\n* highlight opened diff view file ([5708c63](https://github.com/denolehov/obsidian-git/commit/5708c63ad7cad72c3939a4d554a5b98bc04783ed)), closes [#545](https://github.com/denolehov/obsidian-git/issues/545)\n\n\n### Bug Fixes\n\n* ui alignment ([a9adfff](https://github.com/denolehov/obsidian-git/commit/a9adfff996d570f8e893a2e2786059d0fa2e1cb9))\n\n## [2.21.0](https://github.com/denolehov/obsidian-git/compare/2.20.7...2.21.0) (2023-08-22)\n\n\n### Features\n\n* add fetch command ([222245b](https://github.com/denolehov/obsidian-git/commit/222245b7c750bd4d2740aa25913d74d538e5035d))\n* mark push button when push is ready ([32936eb](https://github.com/denolehov/obsidian-git/commit/32936eb76dff45c2333660791faa0bc8f86e1154)), closes [#557](https://github.com/denolehov/obsidian-git/issues/557)\n\n\n### Bug Fixes\n\n* clarify backup after file change setting ([30d12ca](https://github.com/denolehov/obsidian-git/commit/30d12ca872c523cc3d6ee2987c2299270f50a578)), closes [#575](https://github.com/denolehov/obsidian-git/issues/575)\n* show file name in diff view on mobile ([20e0aba](https://github.com/denolehov/obsidian-git/commit/20e0aba46fa3454f8dabc60dd9d3c579efc322ee)), closes [#564](https://github.com/denolehov/obsidian-git/issues/564)\n\n### [2.20.7](https://github.com/denolehov/obsidian-git/compare/2.20.6...2.20.7) (2023-07-31)\n\n\n### Bug Fixes\n\n* properly collapse icon in tree views ([919d7f8](https://github.com/denolehov/obsidian-git/commit/919d7f8f65174e76a4f13a992b3f37931eaf7262))\n* refresh status bar after push ([ed31df8](https://github.com/denolehov/obsidian-git/commit/ed31df88effc5e686cb2e81e0379df93627bdd9b)), closes [#566](https://github.com/denolehov/obsidian-git/issues/566)\n\n### [2.20.6](https://github.com/denolehov/obsidian-git/compare/2.20.5...2.20.6) (2023-07-16)\n\n\n### Bug Fixes\n\n* allow empty commit in history view ([2571473](https://github.com/denolehov/obsidian-git/commit/257147311cf65a2b5dedf957f4c71ad9624ce7be))\n\n### [2.20.5](https://github.com/denolehov/obsidian-git/compare/2.20.4...2.20.5) (2023-06-29)\n\n\n### Bug Fixes\n\n* disallow clone in vault root on desktop ([c073d19](https://github.com/denolehov/obsidian-git/commit/c073d19283c01af4fdc79002d1913b1d5672a5fd)), closes [#540](https://github.com/denolehov/obsidian-git/issues/540)\n* textarea for commit message in settings ([ea4a7a1](https://github.com/denolehov/obsidian-git/commit/ea4a7a105a8a954e705b372dac127fa3a83fddc1))\n\n### [2.20.4](https://github.com/denolehov/obsidian-git/compare/2.20.3...2.20.4) (2023-06-21)\n\n\n### Bug Fixes\n\n* make  `{{files}}` variable visible in settings ([#536](https://github.com/denolehov/obsidian-git/issues/536)) ([07abcce](https://github.com/denolehov/obsidian-git/commit/07abcce878a66e686e7f0221c68df746f69b590b))\n* missing git file status 113 ([#537](https://github.com/denolehov/obsidian-git/issues/537)) ([ba2b40c](https://github.com/denolehov/obsidian-git/commit/ba2b40cbc5687f92ab9ca6a65110d5d6ec39c2ca))\n\n### [2.20.3](https://github.com/denolehov/obsidian-git/compare/2.20.2...2.20.3) (2023-06-04)\n\n\n### Bug Fixes\n\n* show correct empty diff ([c8bbe7c](https://github.com/denolehov/obsidian-git/commit/c8bbe7c5d2b7beb49ab5fa55922f289c2bcdbed1)), closes [#327](https://github.com/denolehov/obsidian-git/issues/327)\n\n### [2.20.2](https://github.com/denolehov/obsidian-git/compare/2.20.1...2.20.2) (2023-06-02)\n\n\n### Bug Fixes\n\n* hide line authoring settings on mobile ([c135c0b](https://github.com/denolehov/obsidian-git/commit/c135c0b49cd0cc8033ea40f64e6f922702375aa0))\n* properly resolve merge conflict ([80c0b65](https://github.com/denolehov/obsidian-git/commit/80c0b65f8d0d8dc8dbddff61118bd79733ffce94)), closes [#502](https://github.com/denolehov/obsidian-git/issues/502)\n\n### [2.20.1](https://github.com/denolehov/obsidian-git/compare/2.20.0...2.20.1) (2023-05-31)\n\n\n### Bug Fixes\n\n* use queue for actions in source control view ([eb20dd4](https://github.com/denolehov/obsidian-git/commit/eb20dd4c93cb6013e9aef1e47f817586261507d5)), closes [#517](https://github.com/denolehov/obsidian-git/issues/517)\n\n## [2.20.0](https://github.com/denolehov/obsidian-git/compare/2.19.1...2.20.0) (2023-05-17)\n\n\n### Features\n\n* Line Authoring ([aa8dd1b](https://github.com/denolehov/obsidian-git/commit/aa8dd1b3cf0fc440c4d7177831795f3fc5b0076c)), closes [#321](https://github.com/denolehov/obsidian-git/issues/321)\n\n\n### Bug Fixes\n\n* use proper tree structure on Obsidian 1.3.1 ([c124943](https://github.com/denolehov/obsidian-git/commit/c124943d5f1ba388f700524e517a43cf9682abc8)), closes [#512](https://github.com/denolehov/obsidian-git/issues/512)\n\n### [2.19.1](https://github.com/denolehov/obsidian-git/compare/2.19.0...2.19.1) (2023-04-04)\n\n\n### Bug Fixes\n\n* handle missing tracking branch ([#483](https://github.com/denolehov/obsidian-git/issues/483)) ([703fc18](https://github.com/denolehov/obsidian-git/commit/703fc18e7dccec89c810e6529a869ec5c271c21e))\n\n## [2.19.0](https://github.com/denolehov/obsidian-git/compare/2.18.0...2.19.0) (2023-03-22)\n\n\n### Features\n* new History view\n* show last commit time in status bar ([b6d93a1](https://github.com/denolehov/obsidian-git/commit/b6d93a1b8574b1d9dc56c3be9bf8403d95fcef26)), closes [#334](https://github.com/denolehov/obsidian-git/issues/334)\n\n\n### Bug Fixes\n\n* catch error in diffView ([cbff377](https://github.com/denolehov/obsidian-git/commit/cbff37701fd8aa8b9e1257d09fb8ca9fb655b35b))\n* catch huge auto intervals ([35bca00](https://github.com/denolehov/obsidian-git/commit/35bca003c98637f65295fe7d5a8bc4ae1fd68b07)), closes [#153](https://github.com/denolehov/obsidian-git/issues/153)\n\n## [2.18.0](https://github.com/denolehov/obsidian-git/compare/2.17.4...2.18.0) (2023-03-20)\n\n\n### Features\n\n* add setting to hide file menu actions ([a59d38a](https://github.com/denolehov/obsidian-git/commit/a59d38a9f00042a1c27dc64426cd93e595f8eb6b)), closes [#456](https://github.com/denolehov/obsidian-git/issues/456)\n* show last commit time in status bar ([4525fef](https://github.com/denolehov/obsidian-git/commit/4525fef302c23b54c233186bdbf898615fc1b314)), closes [#334](https://github.com/denolehov/obsidian-git/issues/334)\n\n\n### Bug Fixes\n\n* catch huge auto intervals ([b96efc5](https://github.com/denolehov/obsidian-git/commit/b96efc5e06654f144d5837e784da297d79496c51)), closes [#153](https://github.com/denolehov/obsidian-git/issues/153)\n* minor source control view improvements ([fd7792c](https://github.com/denolehov/obsidian-git/commit/fd7792c80403d884d09c31bb09a76799bbd0dff0))\n* typo in settings ([4014057](https://github.com/denolehov/obsidian-git/commit/4014057879d24cb176a2ee1baac868fab05bc856)), closes [#468](https://github.com/denolehov/obsidian-git/issues/468)\n\n### [2.17.4](https://github.com/denolehov/obsidian-git/compare/2.17.3...2.17.4) (2023-03-07)\n\n\n### Bug Fixes\n\n* add additional author check ([58ce847](https://github.com/denolehov/obsidian-git/commit/58ce84749936c78a2789f3eae1e2de3877350b96))\n\n### [2.17.3](https://github.com/denolehov/obsidian-git/compare/2.17.2...2.17.3) (2023-03-07)\n\n\n### Bug Fixes\n\n* better error message for missing author ([2e9e3b1](https://github.com/denolehov/obsidian-git/commit/2e9e3b135de411f764ba6eef5b0aaf4d21216b55))\n* don't checkout when nothing changed after merge ([f807d8a](https://github.com/denolehov/obsidian-git/commit/f807d8a19712bc8f697e37ba1a571b47be77c064))\n* show diff with custom base path ([fdde0bf](https://github.com/denolehov/obsidian-git/commit/fdde0bf83b4fedf430fe829724992207f1393d48))\n* use correct git path on clone on mobile ([686c323](https://github.com/denolehov/obsidian-git/commit/686c3230daff6a7fa1d51cf9270295ad975e2599))\n\n### [2.17.2](https://github.com/denolehov/obsidian-git/compare/2.17.1...2.17.2) (2023-03-06)\n\n\n### Bug Fixes\n\n* use correct git dir on mobile ([fd456e5](https://github.com/denolehov/obsidian-git/commit/fd456e5f505ba1bedc0ab85fdc62ee9aa91c18e5))\n\n### [2.17.1](https://github.com/denolehov/obsidian-git/compare/2.17.0...2.17.1) (2023-03-05)\n\n\n### Bug Fixes\n\n* show missing repo message ([70a6464](https://github.com/denolehov/obsidian-git/commit/70a64640f3b21fe61bbdaf0b6215d2878df732be))\n\n## [2.17.0](https://github.com/denolehov/obsidian-git/compare/2.16.0...2.17.0) (2023-02-25)\n\n\n### Features\n\n* include old file name in log ([fa34fb5](https://github.com/denolehov/obsidian-git/commit/fa34fb5c87c9d9d6b294ef3fe28f5c7538df21ac))\n* specify depth on clone ([cf81f0c](https://github.com/denolehov/obsidian-git/commit/cf81f0c1ea72931b2274265e32ab4db2d11d0c82)), closes [#307](https://github.com/denolehov/obsidian-git/issues/307)\n\n\n### Bug Fixes\n\n* correct git dir for clone on mobile ([0b06487](https://github.com/denolehov/obsidian-git/commit/0b0648716f790e2676509b77dee444a72ef06814))\n* handle github link errors ([#445](https://github.com/denolehov/obsidian-git/issues/445)) ([fd294cc](https://github.com/denolehov/obsidian-git/commit/fd294ccf50237c24000559d0d99cff4758e43b1a))\n\n## [2.16.0](https://github.com/denolehov/obsidian-git/compare/2.15.0...2.16.0) (2023-01-16)\n\n\n### Features\n\n* additional environment variables ([f9b1bca](https://github.com/denolehov/obsidian-git/commit/f9b1bca38c6db23f05abfb211933f7ce4f69db7f)), closes [#414](https://github.com/denolehov/obsidian-git/issues/414)\n* custom GIT_DIR ([978453e](https://github.com/denolehov/obsidian-git/commit/978453ebb1cf0df1c70bd169665709cd512264dd))\n\n## [2.15.0](https://github.com/denolehov/obsidian-git/compare/2.14.0...2.15.0) (2023-01-06)\n\n\n### Features\n\n* improve discard modal ([872fc18](https://github.com/denolehov/obsidian-git/commit/872fc182743df108a42ae244699bb2d2b03d7c69))\n\n## [2.14.0](https://github.com/denolehov/obsidian-git/compare/2.13.0...2.14.0) (2022-12-14)\n\n\n### Features\n\n* add instructions to conflict  file ([50291d3](https://github.com/denolehov/obsidian-git/commit/50291d3b182ba4789dac25164ca66f511ba1ab67)), closes [#402](https://github.com/denolehov/obsidian-git/issues/402)\n\n\n### Bug Fixes\n\n* close empty leaf of deleted conflict file ([cd6027d](https://github.com/denolehov/obsidian-git/commit/cd6027dcb8f3f9c353c9e9f9592b057c06fceb70)), closes [#401](https://github.com/denolehov/obsidian-git/issues/401)\n\n## [2.13.0](https://github.com/denolehov/obsidian-git/compare/2.12.1...2.13.0) (2022-12-07)\n\n\n### Features\n\n* add file name to diff view tab name ([8520c2b](https://github.com/denolehov/obsidian-git/commit/8520c2beed20f9fe20e6af830c34f59b1678b36a))\n\n\n### Bug Fixes\n\n* move commit msg setting to correct heading ([88eabc9](https://github.com/denolehov/obsidian-git/commit/88eabc930ca98ea205d366e874a245af964efabd))\n* use correct path for diff view via command ([1150351](https://github.com/denolehov/obsidian-git/commit/1150351e6bdf066e59cd7579e9efc087d4d5a595)), closes [#397](https://github.com/denolehov/obsidian-git/issues/397)\n\n### [2.12.1](https://github.com/denolehov/obsidian-git/compare/2.12.0...2.12.1) (2022-11-27)\n\n\n### Bug Fixes\n\n* use correct git implementation ([2def322](https://github.com/denolehov/obsidian-git/commit/2def32278a6dadab4777aae38a57821f5d044406)), closes [#387](https://github.com/denolehov/obsidian-git/issues/387) [#386](https://github.com/denolehov/obsidian-git/issues/386)\n\n## [2.12.0](https://github.com/denolehov/obsidian-git/compare/2.11.0...2.12.0) (2022-11-27)\n\n\n### Features\n\n* set last auto backup to last commit ([d8cfbf2](https://github.com/denolehov/obsidian-git/commit/d8cfbf2efe31770f6fd7ac6399bb7563f3caa831)), closes [#73](https://github.com/denolehov/obsidian-git/issues/73)\n\n## [2.11.0](https://github.com/denolehov/obsidian-git/compare/2.10.2...2.11.0) (2022-11-26)\n\n\n### Features\n\n* add backup button to source control view ([477b166](https://github.com/denolehov/obsidian-git/commit/477b16644dddd13dce1bde1600aed996b2b8f377)), closes [#374](https://github.com/denolehov/obsidian-git/issues/374)\n\n\n### Bug Fixes\n\n* hide 'finished pull'  notice when hiding notifications ([8ba0e75](https://github.com/denolehov/obsidian-git/commit/8ba0e7526001eaefed71b2978e9f1ab76ab18136)), closes [#292](https://github.com/denolehov/obsidian-git/issues/292)\n\n### [2.10.2](https://github.com/denolehov/obsidian-git/compare/2.10.1...2.10.2) (2022-11-17)\n\n\n### Bug Fixes\n\n* focus diff view via command ([e56641c](https://github.com/denolehov/obsidian-git/commit/e56641c25f9672fcffeb0801f09ca7eadf99ede0)), closes [#377](https://github.com/denolehov/obsidian-git/issues/377)\n\n### [2.10.1](https://github.com/denolehov/obsidian-git/compare/2.10.0...2.10.1) (2022-11-13)\n\n\n### Bug Fixes\n\n* add remote on mobile ([c529a37](https://github.com/denolehov/obsidian-git/commit/c529a377195fea76028b424d5973aae80498670e)), closes [#375](https://github.com/denolehov/obsidian-git/issues/375)\n\n## [2.10.0](https://github.com/denolehov/obsidian-git/compare/2.9.4...2.10.0) (2022-11-08)\n\n\n### Features\n\n* log git commands ([a63bb8a](https://github.com/denolehov/obsidian-git/commit/a63bb8a0063b69cc020a0fd0017b42d7ee31ed1e))\n\n\n### Bug Fixes\n\n* reorder settings item ([8d5b596](https://github.com/denolehov/obsidian-git/commit/8d5b59658500329b9f52a68127d619c3f5016906))\n\n### [2.9.4](https://github.com/denolehov/obsidian-git/compare/2.9.3...2.9.4) (2022-11-04)\n\n\n### Bug Fixes\n\n* unset config on empty value ([d0f927e](https://github.com/denolehov/obsidian-git/commit/d0f927ecec9aeeae4ee86873511a208bf943e29c))\n\n### [2.9.3](https://github.com/denolehov/obsidian-git/compare/2.9.2...2.9.3) (2022-11-03)\n\n### [2.9.2](https://github.com/denolehov/obsidian-git/compare/2.9.1...2.9.2) (2022-11-02)\n\n\n### Bug Fixes\n\n* detect network unreachable ([76b894c](https://github.com/denolehov/obsidian-git/commit/76b894c21085ff99d2f0bbaf1c4f46351e3f19f1)), closes [#211](https://github.com/denolehov/obsidian-git/issues/211)\n* hide notification on mobile ([7d62527](https://github.com/denolehov/obsidian-git/commit/7d6252795ca62f4176fee90d674041659a0a1d9f)), closes [#292](https://github.com/denolehov/obsidian-git/issues/292)\n\n### [2.9.1](https://github.com/denolehov/obsidian-git/compare/2.9.0...2.9.1) (2022-11-02)\n\n\n### Bug Fixes\n\n* set path env var ([8a2ae4d](https://github.com/denolehov/obsidian-git/commit/8a2ae4dfe2ebb0023a351c251845d29a311a9560))\n\n## [2.9.0](https://github.com/denolehov/obsidian-git/compare/2.8.0...2.9.0) (2022-11-01)\n\n\n### Features\n\n* custom PATH env paths ([2c42609](https://github.com/denolehov/obsidian-git/commit/2c4260942a738421bf517f1b0d063b536345f8bf))\n\n\n### Bug Fixes\n\n* store username in localstorage ([f3668ac](https://github.com/denolehov/obsidian-git/commit/f3668ac23f13d263e50b7d6b716d91150a11b6c7))\n\n## [2.8.0](https://github.com/denolehov/obsidian-git/compare/2.7.0...2.8.0) (2022-10-18)\n\n\n### Features\n\n* new discard icon ([730e9a6](https://github.com/denolehov/obsidian-git/commit/730e9a6405b4018dc987b29c0a156feb01b583f2))\n\n\n### Bug Fixes\n\n* align buttons ([a09bc4a](https://github.com/denolehov/obsidian-git/commit/a09bc4ac2b5165b11a740230db55a4ef05e3c219))\n* center buttons in discard modal ([79a1e86](https://github.com/denolehov/obsidian-git/commit/79a1e86ce5ba7e039393c49414b0e408e940aaa5))\n* create .gitignore if not exists ([ac8e3ee](https://github.com/denolehov/obsidian-git/commit/ac8e3ee380340fbeedf0dac8e80a4c28aeadffa8))\n* full directory path on hover ([0f2c9d5](https://github.com/denolehov/obsidian-git/commit/0f2c9d56b1733450283af487c740d01908201284))\n\n## [2.7.0](https://github.com/denolehov/obsidian-git/compare/2.6.0...2.7.0) (2022-10-18)\n\n\n### Features\n\n* discard all changes ([3461a30](https://github.com/denolehov/obsidian-git/commit/3461a300ee563a316faf5d198473f2ccc323b1e8))\n* discard directories ([149805f](https://github.com/denolehov/obsidian-git/commit/149805f24e310e2b225be904c75094b90d38dd33))\n* stage/unstage button on category ([3373e6d](https://github.com/denolehov/obsidian-git/commit/3373e6d0ee4f2a4d84e7b3513fd7712046b2e889))\n\n\n### Bug Fixes\n\n* correct height for textarea ([b44c900](https://github.com/denolehov/obsidian-git/commit/b44c9008db9b12b1e4f23ef5fc87151618953231))\n* jittering of refresh button ([dbf36b2](https://github.com/denolehov/obsidian-git/commit/dbf36b2a63617b4d937f75326cd007ea08cfb622))\n* sum folder paths in n depth ([e690164](https://github.com/denolehov/obsidian-git/commit/e690164c0ef6bc1749008357299f92b9a244d960))\n* unstage all on mobile ([4507fdb](https://github.com/denolehov/obsidian-git/commit/4507fdb9c6f2f5c890198a18980586d892786d0e))\n* unstage dir ([3d421b7](https://github.com/denolehov/obsidian-git/commit/3d421b70e0611b5dbbd91c23668e8b98df57116a))\n* unstage folder on desktop ([56afe51](https://github.com/denolehov/obsidian-git/commit/56afe510ade79fab52a8a1aa2a9c15739d16a904))\n\n## [2.6.0](https://github.com/denolehov/obsidian-git/compare/2.5.1...2.6.0) (2022-10-13)\n\n\n### Features\n\n* combine multiple empty directory into one in git view ([4e45e6a](https://github.com/denolehov/obsidian-git/commit/4e45e6accc468402033c5b56d6fb56ec5b461c1e))\n* redesign source control view ([06f3c22](https://github.com/denolehov/obsidian-git/commit/06f3c229cfd199c716401ad1f4e524e2e23bc4f7))\n* stage/unstage directory ([61b3eb3](https://github.com/denolehov/obsidian-git/commit/61b3eb3ac38553ef4cda0512be19b1f4480d613c))\n\n### [2.5.1](https://github.com/denolehov/obsidian-git/compare/2.5.0...2.5.1) (2022-09-29)\n\n\n### Bug Fixes\n\n* push with file named like branch ([2664bfe](https://github.com/denolehov/obsidian-git/commit/2664bfe633e9ea0c76123ed7b0d4ac56aaf05b10)), closes [#171](https://github.com/denolehov/obsidian-git/issues/171)\n\n## [2.5.0](https://github.com/denolehov/obsidian-git/compare/2.4.1...2.5.0) (2022-09-28)\n\n\n### Features\n\n* improve source control view style ([d5647a8](https://github.com/denolehov/obsidian-git/commit/d5647a8e2e49c8a77f28854bb4c276a17f390d55))\n\n\n### Bug Fixes\n\n* reveal source control view ([c88a1b4](https://github.com/denolehov/obsidian-git/commit/c88a1b43633e9964e3a9f60e94c0dc7f8307edc1))\n\n### [2.4.1](https://github.com/denolehov/obsidian-git/compare/2.4.0...2.4.1) (2022-09-22)\n\n\n### Bug Fixes\n\n* keep git view on unload ([8b846da](https://github.com/denolehov/obsidian-git/commit/8b846da0010a852b5422d64034c1e4b309fa7f35)), closes [#321](https://github.com/denolehov/obsidian-git/issues/321)\n\n## [2.4.0](https://github.com/denolehov/obsidian-git/compare/2.3.0...2.4.0) (2022-09-22)\n\n\n### Features\n\n* prefill edit remote modal ([223193c](https://github.com/denolehov/obsidian-git/commit/223193c51b362788a0682dc598c7d0eefa9ccdf0))\n\n\n### Bug Fixes\n\n* middle click to open file/diff in new tab ([ddb1164](https://github.com/denolehov/obsidian-git/commit/ddb1164b10f5e0d373daaa4cd8ec4d60119cc544))\n\n## [2.3.0](https://github.com/denolehov/obsidian-git/compare/2.2.1...2.3.0) (2022-09-21)\n\n\n### Features\n\n* branch management ([caaacd1](https://github.com/denolehov/obsidian-git/commit/caaacd11c8634e86b01dc19dfb57b546adedf7e6)), closes [#132](https://github.com/denolehov/obsidian-git/issues/132) [#220](https://github.com/denolehov/obsidian-git/issues/220)\n\n\n### Bug Fixes\n\n* backup with reset sync method ([41a00ff](https://github.com/denolehov/obsidian-git/commit/41a00ff8b17212a23c515d0f19be69a0b8d2f1c1)), closes [#319](https://github.com/denolehov/obsidian-git/issues/319)\n\n### [2.2.1](https://github.com/denolehov/obsidian-git/compare/2.2.0...2.2.1) (2022-09-20)\n\n\n### Bug Fixes\n\n* localstorage migration ([1d9391a](https://github.com/denolehov/obsidian-git/commit/1d9391a970624f03fcc60fb68f3bd8ee450af24b))\n\n## [2.2.0](https://github.com/denolehov/obsidian-git/compare/2.1.2...2.2.0) (2022-09-20)\n\n\n### Features\n\n* diff view on mobile ([86b4d5a](https://github.com/denolehov/obsidian-git/commit/86b4d5ad4be23b420fe0efd0f3dfd989047be23a)), closes [#302](https://github.com/denolehov/obsidian-git/issues/302)\n\n\n### Bug Fixes\n\n* respect obsidian default hotkey for open file ([b8631f4](https://github.com/denolehov/obsidian-git/commit/b8631f4ff0b9a0bdeacc829248195085f4512f1d)), closes [#306](https://github.com/denolehov/obsidian-git/issues/306)\n* save localstorage per vault ([a3c4e4f](https://github.com/denolehov/obsidian-git/commit/a3c4e4f8916b78160de074e72444c5ccd91c32b2))\n\n### [2.1.2](https://github.com/denolehov/obsidian-git/compare/2.1.1...2.1.2) (2022-09-19)\n\n\n### Bug Fixes\n\n* respect obsidian default hotkey for open diff ([271ec02](https://github.com/denolehov/obsidian-git/commit/271ec022d4caa91ebf0f4c1d82755bb879525ef6)), closes [#306](https://github.com/denolehov/obsidian-git/issues/306)\n* scroll line number in diff view ([1a01e30](https://github.com/denolehov/obsidian-git/commit/1a01e30357894980d8c5ffc77d2828af57174af7)), closes [#318](https://github.com/denolehov/obsidian-git/issues/318)\n\n### [2.1.1](https://github.com/denolehov/obsidian-git/compare/2.1.0...2.1.1) (2022-09-15)\n\n\n### Bug Fixes\n\n* open diff in new leaf ([6914830](https://github.com/denolehov/obsidian-git/commit/6914830ce03651b7f9604ba7be81581636a34f5b)), closes [#306](https://github.com/denolehov/obsidian-git/issues/306)\n* retry auth with different credentials ([f8da5f4](https://github.com/denolehov/obsidian-git/commit/f8da5f455a15f27e93e8b317af3bf9dba7dc3d57)), closes [#296](https://github.com/denolehov/obsidian-git/issues/296)\n\n## [2.1.0](https://github.com/denolehov/obsidian-git/compare/2.0.3...2.1.0) (2022-09-08)\n\n\n### Features\n\n* disable plugin per device ([82b2c1a](https://github.com/denolehov/obsidian-git/commit/82b2c1ad82196927eda16b965094f025a1ed2960)), closes [#301](https://github.com/denolehov/obsidian-git/issues/301)\n* specify source control refresh timer ([a1ecb1b](https://github.com/denolehov/obsidian-git/commit/a1ecb1b39954422de150169a71d0b9da8ee84167)), closes [#199](https://github.com/denolehov/obsidian-git/issues/199)\n\n### [2.0.3](https://github.com/denolehov/obsidian-git/compare/2.0.2...2.0.3) (2022-09-06)\n\n\n### Bug Fixes\n\n* don't show mobile notice on new installation ([218f002](https://github.com/denolehov/obsidian-git/commit/218f002f433ec3a69f92ebfdf1876c50cd99e85c))\n\n### [2.0.2](https://github.com/denolehov/obsidian-git/compare/2.0.1...2.0.2) (2022-09-06)\n\n\n### Bug Fixes\n\n* don't show mobile notice on mobile ([c93ddfa](https://github.com/denolehov/obsidian-git/commit/c93ddfaee5d7621248f2ed1183b8819a7d216706))\n\n### [2.0.1](https://github.com/denolehov/obsidian-git/compare/2.0.0...2.0.1) (2022-09-06)\n\n## [2.0.0](https://github.com/denolehov/obsidian-git/compare/1.31.0...2.0.0) (2022-09-06)\n\n\n### ⚠ BREAKING CHANGES\n\n* mobile support\n\n### Features\n\n* mobile support ([9ffda76](https://github.com/denolehov/obsidian-git/commit/9ffda762dbc0cba380942acdeabcb66adce8253d)), closes [#57](https://github.com/denolehov/obsidian-git/issues/57)\n\n\n### Bug Fixes\n\n* password field description ([9dc5f7c](https://github.com/denolehov/obsidian-git/commit/9dc5f7c7bab3a3b1b24d42ae2fadb10e48cbc292)), closes [#293](https://github.com/denolehov/obsidian-git/issues/293)\n\n## [1.31.0](https://github.com/denolehov/obsidian-git/compare/1.30.0...1.31.0) (2022-08-28)\n\n\n### Features\n\n* command to backup and close Obsidian ([c144d80](https://github.com/denolehov/obsidian-git/commit/c144d80e719fae5d3aba6f0ff5535172993a2c69)), closes [#13](https://github.com/denolehov/obsidian-git/issues/13)\n\n\n### Bug Fixes\n\n* **mobile** don't show push notice on empty push ([9986667](https://github.com/denolehov/obsidian-git/commit/998666778ed07f380b6a4057afc33ca637c472c7))\n* set upstream branch for existing remote branch ([c454b9d](https://github.com/denolehov/obsidian-git/commit/c454b9d7ae3323e5c9f4e758e139e97eb85ec40e)), closes [#224](https://github.com/denolehov//github.com/denolehov/obsidian-git/issues/224/issues/issuecomment-1229136511)\n\n## [1.30.0](https://github.com/denolehov/obsidian-git/compare/1.29.2...1.30.0) (2022-08-24)\n\n\n### Features\n\n* store git binary path in localstorage ([bd8bafc](https://github.com/denolehov/obsidian-git/commit/bd8bafc904e0f14501a9985c0ae490bea98297be)), closes [#283](https://github.com/denolehov/obsidian-git/issues/283)\n\n\n### Bug Fixes\n\n* **mobile:** clone and delete local config dir ([9b0bc8a](https://github.com/denolehov/obsidian-git/commit/9b0bc8afb2f21440382f40a442dfc7b1bd369cca))\n* **mobile:** readdir with empty base path ([1c38b91](https://github.com/denolehov/obsidian-git/commit/1c38b913d15ed6a2410eeb9b0da7ae9675ef3ab3))\n* respect custom base path in open in github ([c4e8acf](https://github.com/denolehov/obsidian-git/commit/c4e8acf62586b477434bd0c7e55c3ea0c0c99e8e)), closes [#284](https://github.com/denolehov/obsidian-git/issues/284)\n* too many changes to display ([c4bf4eb](https://github.com/denolehov/obsidian-git/commit/c4bf4eb8bd7d3e9110b354910eed9e29bafbafa6))\n\n### [1.29.2](https://github.com/denolehov/obsidian-git/compare/1.29.1...1.29.2) (2022-08-22)\n\n\n### Bug Fixes\n\n* catch ssh network failure ([62e4a6a](https://github.com/denolehov/obsidian-git/commit/62e4a6a255e2ceedd38e4a016fa92f407e052485)), closes [#211](https://github.com/denolehov/obsidian-git/issues/211)\n* diff of new file ([92d24bf](https://github.com/denolehov/obsidian-git/commit/92d24bf8f25749f48ea8646088adec55a1ae2c25)), closes [#277](https://github.com/denolehov/obsidian-git/issues/277)\n* **mobile:** set correct base path after clone ([3a69b79](https://github.com/denolehov/obsidian-git/commit/3a69b79583d3e76b22b36ef5c414fc4b98d1fdb7)), closes [#282](https://github.com/denolehov/obsidian-git/issues/282)\n* require upstream branch for pull ([3fac8ad](https://github.com/denolehov/obsidian-git/commit/3fac8ad86f8885ca322865b6e27f4a43b804a6ce)), closes [#261](https://github.com/denolehov/obsidian-git/issues/261)\n\n### [1.29.1](https://github.com/denolehov/obsidian-git/compare/1.29.0...1.29.1) (2022-08-19)\n\n\n### Bug Fixes\n\n* export mock IsomorphicGit.ts ([aa5fa37](https://github.com/denolehov/obsidian-git/commit/aa5fa37579903243f6623fa99592203c76cd5478)), closes [#281](https://github.com/denolehov/obsidian-git/issues/281)\n\n## [1.29.0](https://github.com/denolehov/obsidian-git/compare/1.28.0...1.29.0) (2022-08-19)\n\n\n### Features\n\n* add delete repo command ([26cdfb8](https://github.com/denolehov/obsidian-git/commit/26cdfb8629f2909e019fecebecd6ff745ad0b932))\n* add to .gitignore command ([c824903](https://github.com/denolehov/obsidian-git/commit/c824903ea8572619b147b405dee76e51b4970f9c))\n* edit .gitignore ([1cad1b7](https://github.com/denolehov/obsidian-git/commit/1cad1b72649c4ad7da931a32bb891176e2f96b3d))\n* commit only staged files ([f6f4a97](https://github.com/denolehov/obsidian-git/commit/f6f4a97c36acda5950bb156f1732ab0ece89a63e))\n* fix clone overwrite ([d853a4e](https://github.com/denolehov/obsidian-git/commit/d853a4ea00f636bcf98a3e5c31ad360923f30219))\n* hide settings when git is not ready ([4c40556](https://github.com/denolehov/obsidian-git/commit/4c40556653132767d1dd424fa37c75ccf7cafe86))\n* set author to config ([f40920d](https://github.com/denolehov/obsidian-git/commit/f40920d9970dcdf6146b9b108b76cad88d166fdc))\n* stage and unstage to context menu ([081ad1d](https://github.com/denolehov/obsidian-git/commit/081ad1dda58f6ae8a3458bf8568de5165824410d))\n\n\n### Bug Fixes\n\n* abort edit remotes on no url ([e617278](https://github.com/denolehov/obsidian-git/commit/e617278e68019583b39ac961de27fe84d46f572a))\n* require valid repo for list changed files ([fe300c7](https://github.com/denolehov/obsidian-git/commit/fe300c767d4dac81ce9968e29106eaaf6aeb3ea2))\n* restart notice after clone ([140bed5](https://github.com/denolehov/obsidian-git/commit/140bed5cde1772b8c59a72db9ffa89a6eac9151e))\n* set base path after clone ([0327090](https://github.com/denolehov/obsidian-git/commit/032709096b6afd8411868135596d5b9ef6c19fbd))\n* stage individual file ([76e317b](https://github.com/denolehov/obsidian-git/commit/76e317b5320b3a7e9ab303b402518f3791333a8d))\n\n## [1.28.0](https://github.com/denolehov/obsidian-git/compare/1.27.1...1.28.0) (2022-07-25)\n\n\n### Features\n\n* stage and unstage current file ([f014e52](https://github.com/denolehov/obsidian-git/commit/f014e52e11cbb345a313914a9e71e0807a0d4197)), closes [#265](https://github.com/denolehov/obsidian-git/issues/265)\n\n\n### Bug Fixes\n\n* register event listener after initial load ([d32d0f4](https://github.com/denolehov/obsidian-git/commit/d32d0f4bc26db390da8008e8af878eef97ba98f4))\n\n### [1.27.1](https://github.com/denolehov/obsidian-git/compare/1.27.0...1.27.1) (2022-07-20)\n\n\n### Bug Fixes\n\n* check for too big files in source control view ([2275d4f](https://github.com/denolehov/obsidian-git/commit/2275d4f716c00a305bf4371e9cb1b934669b2272))\n\n## [1.27.0](https://github.com/denolehov/obsidian-git/compare/1.26.4...1.27.0) (2022-07-20)\n\n\n### Features\n\n* check for too big files ([f0f3942](https://github.com/denolehov/obsidian-git/commit/f0f394246fb001dcd4b5b42f17d764f7a95a4486)), closes [#248](https://github.com/denolehov/obsidian-git/issues/248) [#189](https://github.com/denolehov/obsidian-git/issues/189)\n\n### [1.26.4](https://github.com/denolehov/obsidian-git/compare/1.26.3...1.26.4) (2022-07-20)\n\n\n### Bug Fixes\n\n* Version History Diff for empty base path ([3b9b699](https://github.com/denolehov/obsidian-git/commit/3b9b69938d97c1a257755a4896857fe8ee8db557)), closes [#263](https://github.com/denolehov/obsidian-git/issues/263)\n\n### [1.26.3](https://github.com/denolehov/obsidian-git/compare/1.26.2...1.26.3) (2022-07-17)\n\n\n### Bug Fixes\n\n* commit only staged files, again ([ba35555](https://github.com/denolehov/obsidian-git/commit/ba35555eebbac5f4a69aaa7da6847928e0ddd017)), closes [#253](https://github.com/denolehov/obsidian-git/issues/253)\n\n### [1.26.2](https://github.com/denolehov/obsidian-git/compare/1.26.1...1.26.2) (2022-07-16)\n\n\n### Bug Fixes\n\n* clarification about disabling notifications ([#249](https://github.com/denolehov/obsidian-git/issues/249)) ([f90b284](https://github.com/denolehov/obsidian-git/commit/f90b284f1f4140eab6aec1b77353cb52e661a8e3))\n* commit only staged files ([f71fdf5](https://github.com/denolehov/obsidian-git/commit/f71fdf58271c1490887f057c6ecc5e6d3689dbd4)), closes [#253](https://github.com/denolehov/obsidian-git/issues/253)\n* open diff view in correct pane ([96d2913](https://github.com/denolehov/obsidian-git/commit/96d2913c67bf8348953440954d5e41986c6b121b)), closes [#252](https://github.com/denolehov/obsidian-git/issues/252)\n* open files from source control view ([0d5ec26](https://github.com/denolehov/obsidian-git/commit/0d5ec262187281517c4a63a78c417c8d9940750f)), closes [#258](https://github.com/denolehov/obsidian-git/issues/258)\n\n### [1.26.1](https://github.com/denolehov/obsidian-git/compare/1.26.0...1.26.1) (2022-06-09)\n\n\n### Bug Fixes\n\n* open file with custom base path ([8a11666](https://github.com/denolehov/obsidian-git/commit/8a11666e6d430ed3fba952a584b9f8af6cc462fe))\n* use correct path with custom base path ([0d86e68](https://github.com/denolehov/obsidian-git/commit/0d86e6872fa16c809f7bf71f05e344acaf31008d))\n\n## [1.26.0](https://github.com/denolehov/obsidian-git/compare/1.25.3...1.26.0) (2022-06-09)\n\n\n### Features\n\n* different intervals for commit and push ([59367aa](https://github.com/denolehov/obsidian-git/commit/59367aa3d0fde912cf393f5e48989758f44b82e0)), closes [#106](https://github.com/denolehov/obsidian-git/issues/106)\n* show changes files count in status bar ([d091694](https://github.com/denolehov/obsidian-git/commit/d0916947546aad84f506f39f9f754a6b3c33f42c)), closes [#243](https://github.com/denolehov/obsidian-git/issues/243)\n\n\n### Bug Fixes\n\n* handle merge conflict better ([101aff9](https://github.com/denolehov/obsidian-git/commit/101aff991ecaecd57f004dd7bfd1811866b755e5))\n\n### [1.25.3](https://github.com/denolehov/obsidian-git/compare/1.25.2...1.25.3) (2022-05-14)\n\n\n### Bug Fixes\n\n* show renamed files ([b76c783](https://github.com/denolehov/obsidian-git/commit/b76c78332978dbbf7045f94295ed3228de7132a9)), closes [#226](https://github.com/denolehov/obsidian-git/issues/226)\n\n### [1.25.2](https://github.com/denolehov/obsidian-git/compare/1.25.1...1.25.2) (2022-05-07)\n\n\n### Bug Fixes\n\n* improve base path description ([8ee3a63](https://github.com/denolehov/obsidian-git/commit/8ee3a63fff91d4c9fd61cb9da73cc738026b8af1))\n\n### [1.25.1](https://github.com/denolehov/obsidian-git/compare/1.25.0...1.25.1) (2022-04-22)\n\n\n### Bug Fixes\n\n* recursive submodules ([#217](https://github.com/denolehov/obsidian-git/issues/217)) ([98f566f](https://github.com/denolehov/obsidian-git/commit/98f566ffa29bb99dde44615f52fd352c099bd7f4))\n\n## [1.25.0](https://github.com/denolehov/obsidian-git/compare/1.24.1...1.25.0) (2022-04-07)\n\n\n### Features\n\n* custom git repository root ([#209](https://github.com/denolehov/obsidian-git/issues/209)) ([4157e42](https://github.com/denolehov/obsidian-git/commit/4157e42fa6cbd0f69d8ed03169c5bc836229d6d4))\n* offline mode ([6989ba4](https://github.com/denolehov/obsidian-git/commit/6989ba4fd7ce44bfac5c6f7479cf41ef8fcb5de3)), closes [#211](https://github.com/denolehov/obsidian-git/issues/211)\n\n\n### Bug Fixes\n\n* refresh source control view less frequently ([b90b1a5](https://github.com/denolehov/obsidian-git/commit/b90b1a5fa596142f42698727dd76cadd97e9bdc6))\n\n### [1.24.1](https://github.com/denolehov/obsidian-git/compare/1.24.0...1.24.1) (2022-03-23)\n\n\n### Bug Fixes\n\n* :adhesive_bandage: More specific CSS selectors for the diff-view ([c0c9a38](https://github.com/denolehov/obsidian-git/commit/c0c9a381f2c4c0527674e4e215e2418c71d68b73))\n* refresh source control view on first open ([6e75300](https://github.com/denolehov/obsidian-git/commit/6e75300424eb8d78f1a4c79caf830ce5d5fd1727))\n\n## [1.24.0](https://github.com/denolehov/obsidian-git/compare/1.23.0...1.24.0) (2022-03-18)\n\n\n### Features\n\n* add show, diff, log as api ([b3a72a4](https://github.com/denolehov/obsidian-git/commit/b3a72a46dfb917b28ca9af7848994668d1846b64))\n\n## [1.23.0](https://github.com/denolehov/obsidian-git/compare/1.22.0...1.23.0) (2022-03-18)\n\n\n### Features\n\n* reworked diff view handling ([be4856b](https://github.com/denolehov/obsidian-git/commit/be4856b0f3a6ecc7f4416f98d9eb05a992b61443)), closes [#202](https://github.com/denolehov/obsidian-git/issues/202) [#203](https://github.com/denolehov/obsidian-git/issues/203)\n\n\n### Bug Fixes\n\n* expand selection width on stagedFileComponent too ([daf8ac7](https://github.com/denolehov/obsidian-git/commit/daf8ac7e4279d6334fd36b4706c85c77d2e8dbbe))\n* highlight staged file on hover ([ef0d3e6](https://github.com/denolehov/obsidian-git/commit/ef0d3e6640712669e8d303d7cf1bff7dbdedbc7d))\n* refresh source control view on exception ([c1eee7b](https://github.com/denolehov/obsidian-git/commit/c1eee7b0d378677dff4da75fc3945b88c3ede7d3)), closes [#183](https://github.com/denolehov/obsidian-git/issues/183)\n\n## [1.22.0](https://github.com/denolehov/obsidian-git/compare/1.21.2...1.22.0) (2022-03-02)\n\n\n### Features\n\n* separate commit message for auto backup ([b59db5d](https://github.com/denolehov/obsidian-git/commit/b59db5dc816479fd6dad5d798d0979c49c8b8ccf)), closes [#197](https://github.com/denolehov/obsidian-git/issues/197)\n\n\n### Bug Fixes\n\n* automatically refresh source control ([9c2b063](https://github.com/denolehov/obsidian-git/commit/9c2b063584366226da876c9e5e6509868b6a01cd)), closes [#199](https://github.com/denolehov/obsidian-git/issues/199)\n* correct pull changes count ([6aead15](https://github.com/denolehov/obsidian-git/commit/6aead155571eda2499ef1ab6377236152124df96)), closes [#198](https://github.com/denolehov/obsidian-git/issues/198)\n\n### [1.21.2](https://github.com/denolehov/obsidian-git/compare/1.21.1...1.21.2) (2022-03-01)\n\n\n### Bug Fixes\n\n* catch git error on commit ([fe78ae3](https://github.com/denolehov/obsidian-git/commit/fe78ae364e5296a378a3d0844a3daa53b3d024c7))\n* stage files without glob pattern ([99b1f6c](https://github.com/denolehov/obsidian-git/commit/99b1f6c3d61e4b2fca274531fa98359af0a8c64e)), closes [#196](https://github.com/denolehov/obsidian-git/issues/196)\n\n### [1.21.1](https://github.com/denolehov/obsidian-git/compare/1.21.0...1.21.1) (2022-02-19)\n\n\n### Bug Fixes\n\n* better automatic backup/pull description ([10c3072](https://github.com/denolehov/obsidian-git/commit/10c307228da5c79cf62acfa2d6c90d2f519855a8)), closes [#181](https://github.com/denolehov/obsidian-git/issues/181)\n* catch more git errors ([153fd82](https://github.com/denolehov/obsidian-git/commit/153fd82d7467d6c58905fa77c4376b2e79594810))\n* stage filenames with leading '-' ([c06296e](https://github.com/denolehov/obsidian-git/commit/c06296e364962474299687e941fcdab8e03c9061)), closes [#184](https://github.com/denolehov/obsidian-git/issues/184)\n\n### [1.21.0](https://github.com/denolehov/obsidian-git/compare/1.20.1...1.20.2) (2022-02-02)\n\n\n### Bug Fixes\n\n* stage files in vault below git root ([9d3c662](https://github.com/denolehov/obsidian-git/commit/9d3c6620f32b392935a689a9ff645aa664f49478)), closes [#172](https://github.com/denolehov/obsidian-git/issues/172)\n\n### Features\n\n* new sync method ([f1d6b33](https://github.com/denolehov/obsidian-git/commit/f1d6b334972b76271a15883c31784812f24d6878))\n\n### [1.20.1](https://github.com/denolehov/obsidian-git/compare/1.20.0...1.20.1) (2022-01-29)\n\n\n### Bug Fixes\n\n* show correct debug console hotkey ([087582e](https://github.com/denolehov/obsidian-git/commit/087582e429d96345c1f1ee17e0d6a1eeb71d9489)), closes [#175](https://github.com/denolehov/obsidian-git/issues/175)\n\n## [1.20.0](https://github.com/denolehov/obsidian-git/compare/1.19.0...1.20.0) (2022-01-08)\n\n\n### Features\n\n* :sparkles: Add \"Open File in GitHub\" Command, fix [#149](https://github.com/denolehov/obsidian-git/issues/149) ([2c216d0](https://github.com/denolehov/obsidian-git/commit/2c216d033fe0a82a68cf4951d72b5af4e9d10c87))\n* :sparkles: Add Command to open file history on GitHub ([e7dd288](https://github.com/denolehov/obsidian-git/commit/e7dd288ba85a87e783d18c2b51e9027ec20f94fa))\n* :sparkles: Add Diff View ([78cd43f](https://github.com/denolehov/obsidian-git/commit/78cd43fadece2b2d6bed80582bba18d842632e1a)), closes [#158](https://github.com/denolehov/obsidian-git/issues/158)\n* :sparkles: Add Folder view to Sidebar ([919dc44](https://github.com/denolehov/obsidian-git/commit/919dc4435f08e8b3217ee66237a0671687bdb5a1)), closes [#134](https://github.com/denolehov/obsidian-git/issues/134)\n* :sparkles: Allow multiline commit messages, fix [#157](https://github.com/denolehov/obsidian-git/issues/157) ([80ea17e](https://github.com/denolehov/obsidian-git/commit/80ea17e34f07f43bfe2aef1b5c520160a0e71e10))\n* Add toggle in Settings to choose default layout ([38c7240](https://github.com/denolehov/obsidian-git/commit/38c7240918d1e463044431d976b9025eb1fdc318))\n\n\n### Bug Fixes\n\n* :bug: Fix RegEx for openInGitHub ([ca59a2d](https://github.com/denolehov/obsidian-git/commit/ca59a2db581643cfd77f3663850fe1243efe4260))\n* :children_crossing: Show diff on double click ([407dcc0](https://github.com/denolehov/obsidian-git/commit/407dcc05d6e9679c7487c1d2dfa78f580c16b5da))\n* catch diff for deleted file ([710cd2c](https://github.com/denolehov/obsidian-git/commit/710cd2cc6e69c1561277c762518ba0ba903e91f3))\n* different tree data structure ([0fd2f95](https://github.com/denolehov/obsidian-git/commit/0fd2f954b66f0336475f8babb1904130711cbc50))\n* many minor fixes ([7d29bef](https://github.com/denolehov/obsidian-git/commit/7d29bef4ed7793b399a704f73a3ab458e043e595))\n* refresh source control view on change ([45e54e2](https://github.com/denolehov/obsidian-git/commit/45e54e21d097492d35084e4b7c52e1f7df5c59b1))\n* remove tree structure from settings ([5af00ae](https://github.com/denolehov/obsidian-git/commit/5af00ae593d573016694da3bc9bbb218c8baa978))\n\n## [1.19.0](https://github.com/denolehov/obsidian-git/compare/1.18.1...1.19.0) (2021-12-22)\n\n\n### Features\n\n* add rebase option for pull ([b04e444](https://github.com/denolehov/obsidian-git/commit/b04e444e99ca31d1abb1e4bfdd81cbdaca88caec)), closes [#155](https://github.com/denolehov/obsidian-git/issues/155)\n\n### [1.18.1](https://github.com/denolehov/obsidian-git/compare/1.18.0...1.18.1) (2021-12-09)\n\n\n### Bug Fixes\n\n* use more specific css class ([471b257](https://github.com/denolehov/obsidian-git/commit/471b257671b861f69747882fcd67be22f7dca287))\n\n## [1.18.0](https://github.com/denolehov/obsidian-git/compare/1.17.0...1.18.0) (2021-12-09)\n\n\n### Features\n\n* add commands for push and commit ([82dd037](https://github.com/denolehov/obsidian-git/commit/82dd037189a4dbe1b8ef7cad13d0c11b0817af2d)), closes [#122](https://github.com/denolehov/obsidian-git/issues/122)\n* use icons for status bar ([96dcbc4](https://github.com/denolehov/obsidian-git/commit/96dcbc443369803a6f11d69ca80f34176025864a)), closes [#147](https://github.com/denolehov/obsidian-git/issues/147)\n\n\n### Bug Fixes\n\n* show error notices for a longer time ([d455489](https://github.com/denolehov/obsidian-git/commit/d45548993bcb95924a28f723d616e6c2f8c7c293)), closes [#148](https://github.com/denolehov/obsidian-git/issues/148)\n\n## [1.17.0](https://github.com/denolehov/obsidian-git/compare/1.16.2...1.17.0) (2021-12-08)\n\n\n### Features\n\n* add hostname commit message placeholder ([32d8382](https://github.com/denolehov/obsidian-git/commit/32d8382c804b1e86effb409246dad06cad78506d)), closes [#146](https://github.com/denolehov/obsidian-git/issues/146)\n\n\n### Bug Fixes\n\n* clear autobackup/pull correctly ([1c5eeab](https://github.com/denolehov/obsidian-git/commit/1c5eeab098609ab5925a2ddda3aeef76db2660b3))\n* don't start autobackup with 0 interval time ([a36c741](https://github.com/denolehov/obsidian-git/commit/a36c741cb1a5e557615768af3656dc76d6391ed0))\n\n### [1.16.2](https://github.com/denolehov/obsidian-git/compare/1.16.1...1.16.2) (2021-11-29)\n\n\n### Bug Fixes\n\n* don't use new auto backup after change by default ([cc95a96](https://github.com/denolehov/obsidian-git/commit/cc95a96613386c30c379457d7d33198808403c63))\n\n### [1.16.1](https://github.com/denolehov/obsidian-git/compare/1.16.0...1.16.1) (2021-11-28)\n\n\n### Bug Fixes\n\n* proper utf-8 encoding ([1bc7d28](https://github.com/denolehov/obsidian-git/commit/1bc7d2844ec3b3a954abe3c11766fd1e2d1c1b2a)), closes [#121](https://github.com/denolehov/obsidian-git/issues/121)\n\n## [1.16.0](https://github.com/denolehov/obsidian-git/compare/1.15.1...1.16.0) (2021-11-28)\n\n\n### Features\n\n* add auto backup after last change ([192a947](https://github.com/denolehov/obsidian-git/commit/192a9474af7fbd607d451c8b95afaa46c84b7a9d)), closes [#140](https://github.com/denolehov/obsidian-git/issues/140)\n\n### [1.15.1](https://github.com/denolehov/obsidian-git/compare/1.15.0...1.15.1) (2021-11-25)\n\n\n### Bug Fixes\n\n* use custom git binary path for git check ([7188753](https://github.com/denolehov/obsidian-git/commit/718875300f6f9d22e8773a5336bd70b095f63845))\n\n## [1.15.0](https://github.com/denolehov/obsidian-git/compare/1.14.3...1.15.0) (2021-11-11)\n\n\n### Features\n\n* add custom commit message to auto backup ([b3d8077](https://github.com/denolehov/obsidian-git/commit/b3d8077bab29edf2602cd57a392969abf89e4241)), closes [#135](https://github.com/denolehov/obsidian-git/issues/135)\n\n### [1.14.3](https://github.com/denolehov/obsidian-git/compare/1.14.2...1.14.3) (2021-11-03)\n\n### Bug Fixes\n\n* open file from Git view when no other file is opened\n\n### [1.14.2](https://github.com/denolehov/obsidian-git/compare/1.14.1...1.14.2) (2021-11-01)\n\n\n### Bug Fixes\n\n* replace '?' by 'U' for untracked files ([64cf162](https://github.com/denolehov/obsidian-git/commit/64cf1623e50513f0f46141f6860650d0a865238c))\n* wrap tooltip for long paths ([1fc4c1f](https://github.com/denolehov/obsidian-git/commit/1fc4c1fd7afbdbb08d7e3a061dd5d602e6f195a3))\n\n### [1.14.1](https://github.com/denolehov/obsidian-git/compare/1.14.0...1.14.1) (2021-11-01)\n\n\n### Bug Fixes\n\n* list files in commit body ([f52a18b](https://github.com/denolehov/obsidian-git/commit/f52a18b3a3b6b05d643541baf2f74c32bb3e88d4)), closes [#131](https://github.com/denolehov/obsidian-git/issues/131)\n\n## [1.14.0](https://github.com/denolehov/obsidian-git/compare/1.13.1...1.14.0) (2021-10-31)\n\n\n### Features\n\n* New Git view in the sidebar to stage and commit individual files. Thanks to @phibr0 for making the UI\n\n\n### [1.13.1](https://github.com/denolehov/obsidian-git/compare/1.13.0...1.13.1) (2021-09-30)\n\n\n### Bug Fixes\n\n* changed files path was wrong with whitespaces ([043f02f](https://github.com/denolehov/obsidian-git/commit/043f02f89daea8051612b5f7816564ba7f7657e8)), closes [#119](https://github.com/denolehov/obsidian-git/issues/119)\n\n## [1.13.0](https://github.com/denolehov/obsidian-git/compare/1.12.0...1.13.0) (2021-09-21)\n\n\n### Features\n\n* support cloning remote repos ([ab5ece7](https://github.com/denolehov/obsidian-git/commit/ab5ece75ceba3af5845770dc029732d5657720a3))\n\n## [1.12.0](https://github.com/denolehov/obsidian-git/compare/1.11.0...1.12.0) (2021-09-18)\n\n\n### Features\n\n* support custom git binary path ([7793035](https://github.com/denolehov/obsidian-git/commit/77930351622a86ef3babdb4d60acbc8ff334cc84)), closes [#113](https://github.com/denolehov/obsidian-git/issues/113)\n\n## [1.11.0](https://github.com/denolehov/obsidian-git/compare/1.10.2...1.11.0) (2021-09-15)\n\n\n### Features\n\n* add remote editing ([f70363b](https://github.com/denolehov/obsidian-git/commit/f70363b522c2e144260411d01e26108f7dedb735))\n* support initalizing a new repo ([0fd2062](https://github.com/denolehov/obsidian-git/commit/0fd20627c3c289a05e0aba179b36badfe11d2414))\n* support selecting upstream branch ([013878e](https://github.com/denolehov/obsidian-git/commit/013878e378bdbc6bab23c94615fba0c2bb72e1dc))\n\n### [1.10.2](https://github.com/denolehov/obsidian-git/compare/1.10.1...1.10.2) (2021-09-05)\n\n\n### Bug Fixes\n\n* plugin status bar now displays time from last update (push or pull) ([b835fc3](https://github.com/denolehov/obsidian-git/commit/b835fc3548884dca4084ec37a296ebebf9c9dab7))\n\n### [1.10.1](https://github.com/denolehov/obsidian-git/compare/1.10.0...1.10.1) (2021-08-19)\n\n\n### Bug Fixes\n\n* checkRequirements cant find user.name/email ([1994a44](https://github.com/denolehov/obsidian-git/commit/1994a44c5ecec121965505e2627d26460425e4dd))\n* rename commands to be more consistend ([5e07e80](https://github.com/denolehov/obsidian-git/commit/5e07e80a13640b6ba587185880ba01befbd563ac))\n\n## [1.10.0](https://github.com/denolehov/obsidian-git/compare/1.9.3...1.10.0) (2021-08-11)\n\n\n### Features\n\n* add submodules support ([2a4ce6d](https://github.com/denolehov/obsidian-git/commit/2a4ce6d47696cd6667b639c8479b37f61346e9be)), closes [#93](https://github.com/denolehov/obsidian-git/issues/93)\n\n\n### Bug Fixes\n\n* Changed the branchLocal command to branch with no-color ([dbd93cf](https://github.com/denolehov/obsidian-git/commit/dbd93cfe5f127874a514837577b42b34a07bcf3e))\n\n### [1.9.3](https://github.com/denolehov/obsidian-git/compare/1.9.2...1.9.3) (2021-07-13)\n\n\n### Bug Fixes\n\n* storing lastAutos in a file caused many problems ([2812d94](https://github.com/denolehov/obsidian-git/commit/2812d948f0c6b0534c507425249c93397f71e973)), closes [#90](https://github.com/denolehov/obsidian-git/issues/90) [#78](https://github.com/denolehov/obsidian-git/issues/78)\n\n### [1.9.2](https://github.com/denolehov/obsidian-git/compare/1.9.1...1.9.2) (2021-05-12)\n\n\n### Bug Fixes\n\n* plugin started wrong when normally enabled ([dc9c4b1](https://github.com/denolehov/obsidian-git/commit/dc9c4b13387067793e315a9aca24c05c75fb6d38))\n* storing of last auto backup/pull caused merge conflicts ([cf6f279](https://github.com/denolehov/obsidian-git/commit/cf6f27900d05eb3ffe74222950cd00270879fd6c)), closes [#74](https://github.com/denolehov/obsidian-git/issues/74)\n\n### [1.9.1](https://github.com/denolehov/obsidian-git/compare/1.9.0...1.9.1) (2021-05-07)\n\n\n### Bug Fixes\n\n* init slowed Obsidian startup time down ([e3f559c](https://github.com/denolehov/obsidian-git/commit/e3f559c14b54ef97eb8d07397d8b92250eeb3d62)), closes [#72](https://github.com/denolehov/obsidian-git/issues/72)\n\n## [1.9.0](https://github.com/denolehov/obsidian-git/compare/1.8.1...1.9.0) (2021-05-02)\n\n\n### Features\n\n* add env var OBSIDIAN_GIT for scripting ([2b76097](https://github.com/denolehov/obsidian-git/commit/2b7609774cfd8689297c23ea672264cea6255409))\n* add option to disable status bar ([0ab55d3](https://github.com/denolehov/obsidian-git/commit/0ab55d3f0805a031e9e3ec5b5cfa21d8c5026330)), closes [#70](https://github.com/denolehov/obsidian-git/issues/70)\n* auto pull/backup outlives session ([7ec00e7](https://github.com/denolehov/obsidian-git/commit/7ec00e7cfd113aeb827f282d765ca061d85235a6)), closes [#68](https://github.com/denolehov/obsidian-git/issues/68)\n\n### [1.8.1](https://github.com/denolehov/obsidian-git/compare/1.8.0...1.8.1) (2021-04-12)\n\n\n### Bug Fixes\n\n* add promise queue ([f95d71a](https://github.com/denolehov/obsidian-git/commit/f95d71a5475107dbf1bbacfb3bdb4e74fd190d15)), closes [#61](https://github.com/denolehov/obsidian-git/issues/61)\n\n## [1.8.0](https://github.com/denolehov/obsidian-git/compare/1.7.0...1.8.0) (2021-03-31)\n\n\n### Features\n\n* open not supported files in changed files modal in default app ([93930e0](https://github.com/denolehov/obsidian-git/commit/93930e079384d0ae2ed165e94241dc1d0acee82a))\n\n## [1.7.0](https://github.com/denolehov/obsidian-git/compare/1.6.1...1.7.0) (2021-03-24)\n\n\n### Features\n\n* add git initialization and conflict files status to statusbar ([ba0ef11](https://github.com/denolehov/obsidian-git/commit/ba0ef11a5abcc8ff11d9e33ca8157a283d06920b))\n* auto pull on specified interval ([2aa7fb8](https://github.com/denolehov/obsidian-git/commit/2aa7fb866e41c1f7170b723a35d9acd2942921b0)), closes [#59](https://github.com/denolehov/obsidian-git/issues/59)\n* conflict files support ([358dc6e](https://github.com/denolehov/obsidian-git/commit/358dc6e492e6ef8156687535d14a9070ebadfb30)), closes [#38](https://github.com/denolehov/obsidian-git/issues/38)\n* list changed files ([5e28b94](https://github.com/denolehov/obsidian-git/commit/5e28b9449f3f7f978fe825fb102b61fb27d191e4))\n\n\n### Bug Fixes\n\n* conflict files pane was opened on pull error ([8d43e7b](https://github.com/denolehov/obsidian-git/commit/8d43e7b32e7b5082c3518537ce32c0627b35dfb2))\n\n### [1.6.1](https://github.com/denolehov/obsidian-git/compare/1.6.0...1.6.1) (2021-03-17)\n\n\n### Bug Fixes\n\n* disable check for root git repository ([49a68e0](https://github.com/denolehov/obsidian-git/commit/49a68e0396b46c09a49f03898b804f97d1a709b3)), closes [#55](https://github.com/denolehov/obsidian-git/issues/55) [#11](https://github.com/denolehov/obsidian-git/issues/11)\n\n## [1.6.0](https://github.com/denolehov/obsidian-git/compare/1.5.0...1.6.0) (2021-03-15)\n\n\n### Features\n\n* commit changes with specified message ([e992199](https://github.com/denolehov/obsidian-git/commit/e9921994e135ac01f5eda8f23d7c4db312cedd05)), closes [#26](https://github.com/denolehov/obsidian-git/issues/26)\n* list filenames affected by commit in the commit body ([0ce9ac3](https://github.com/denolehov/obsidian-git/commit/0ce9ac310c402a3a7a679fc30e591a045d3a4fb2)), closes [#3](https://github.com/denolehov/obsidian-git/issues/3)\n* pull before push ([30d8798](https://github.com/denolehov/obsidian-git/commit/30d8798d433f080404bd22c8a33a1ea49b37648f)), closes [#43](https://github.com/denolehov/obsidian-git/issues/43)\n\n\n### Bug Fixes\n\n* does not push when no changes detected ([d016dee](https://github.com/denolehov/obsidian-git/commit/d016dee92db4af02446b112de580b5197a3303f3)), fixes [#33](https://github.com/denolehov/obsidian-git/issues/33)\n* git repository check ([98fa9f7](https://github.com/denolehov/obsidian-git/commit/98fa9f758f9b08546c0c9319a14fd25b85af4503))\n* initialization procedure ([1d71418](https://github.com/denolehov/obsidian-git/commit/1d714181d8967fa6089cd380b879ce652332a3fa)), fixes [#27](https://github.com/denolehov/obsidian-git/issues/27)\n* lastUpdate gets changed when no changes are detected ([71d2a59](https://github.com/denolehov/obsidian-git/commit/71d2a59f1d5ea7f7fd08e77b1802a47d0aae3f46))\n* needed tracking branch to commit ([619c5d1](https://github.com/denolehov/obsidian-git/commit/619c5d182e95c5f1ca946c56d8c002e6b3f09daf))\n\n## [1.5.0](https://github.com/denolehov/obsidian-git/compare/v1.2.0...v1.5.0) (2020-12-08)\n\n\n### Features\n\n* add {{files}} template placeholder ([64adf0f](https://github.com/denolehov/obsidian-git/commit/64adf0f464cfdad544fec225e52798ccbb565d4d))\n* add option to toggle pushing to remote\n\n\n### Bug Fixes\n\n* change \"auto push\" setting to \"disable push\" to resolve issues with obsidian settings not loading correctly ([e00014c](https://github.com/denolehov/obsidian-git/commit/e00014cb269efa6391ebeb1d1e0026d209635bfe))\n* correctly update `.lastUpdate` timestamp during push/pull ([4b61297](https://github.com/denolehov/obsidian-git/commit/4b61297be84fa7940e2909ddfdd2ef1d8608e20d))\n* fix plugin getting stuck at \"checking repo status..\" message ([4875519](https://github.com/denolehov/obsidian-git/commit/4875519f9986946f0628a343c8ffd94686b86fa4))\n* fix status bar messages race conditions ([f3f0a63](https://github.com/denolehov/obsidian-git/commit/f3f0a63132e0cd38c27d0e14c08a8b7c59134a83))\n\n## [1.4.0](https://github.com/denolehov/obsidian-git/compare/v1.3.0...v1.4.0) (2020-11-01)\n\n\n### Features\n\n* display messages in status bar (including error ones) ([e1e0fcc](https://github.com/denolehov/obsidian-git/commit/e1e0fcc26d5736637239316d5881a696f78eca30))\n\n## [1.3.0](https://github.com/denolehov/obsidian-git/compare/v1.2.0...v1.3.0) (2020-10-31)\n\n\n### Features\n\n* add `{{numFiles}}` placeholder ([fbc6ce8](https://github.com/denolehov/obsidian-git/commit/fbc6ce85d4f6f2b183c7a41f9cbd8f2814027e92))\n* add more granular customization of `{{date}}` commit message placeholder ([7063f5a](https://github.com/denolehov/obsidian-git/commit/7063f5a902c3141671ddbf3c82c2076e07cc872b))\n\n## [1.2.0](https://github.com/denolehov/obsidian-git/compare/v1.1.0...v1.2.0) (2020-10-31)\n\n\n### Features\n\n* `master` branch is no longer hardcoded ([dc8f3bd](https://github.com/denolehov/obsidian-git/commit/dc8f3bda9751a358fdd64771eec0c6b25bb07f6d))\n* allow specifying `{{date}}` placeholder in commit message ([43c5f6e](https://github.com/denolehov/obsidian-git/commit/43c5f6e509d1284411ff26332b7820710fd51c2f))\n* rename \"Autosave\" to \"Vault backup interval\" ([26cd1e3](https://github.com/denolehov/obsidian-git/commit/26cd1e371ad5b7076ac1da7575983ba4f6791713))\n\n\n### Bug Fixes\n\n* fix `undefined` backup settings and rearrange settings a bit ([68f8b84](https://github.com/denolehov/obsidian-git/commit/68f8b8438c9aba3c314ee2baa857bfd1efd587d2))\n* register interval functions so Obsidian properly unloads them ([717a538](https://github.com/denolehov/obsidian-git/commit/717a53811ef55907ca804ead83d7db6a4747199f))\n* save settings on plugin unload ([67cd7a3](https://github.com/denolehov/obsidian-git/commit/67cd7a3f9303505b86b6399694bf1d8e4c8bff4e))\n\n## [1.1.0](https://github.com/denolehov/obsidian-git/compare/v1.0.0...v1.1.0) (2020-10-29)\n\n\n### Features\n\n* Add \"Disable notifications\" setting + some minor fixes ([ec240a7](https://github.com/denolehov/obsidian-git/commit/ec240a7122656e551b93a79ad5af9b7be138b2ec))\n* Add an option to automatically fetch updates from remote repository when Obsidian starts ([aa59d29](https://github.com/denolehov/obsidian-git/commit/aa59d29fb23ac5b42d8c6a644fdc413a04931966))\n* Add status bar that shows status updates ([80dbf0f](https://github.com/denolehov/obsidian-git/commit/80dbf0f647fe27237bd86174feebe7987a90be63))\n\n## [1.0.0](https://github.com/denolehov/obsidian-git/compare/v0.0.6...v1.0.0) (2020-10-27)\n\n\n### Bug Fixes\n\n* update some Notice messages ([a97c44e](https://github.com/denolehov/obsidian-git/commit/a97c44e2f5a1581e5bb8ea432deca108df8c7fde))\n\n### [0.0.6](https://github.com/denolehov/obsidian-git/compare/v0.0.5...v0.0.6) (2020-10-27)\n\n\n### Features\n\n* Add autosave feature ([6f0d6bc](https://github.com/denolehov/obsidian-git/commit/6f0d6bc0b8b84fe6e14fcf1c85e6a6213c9da578))\n\n### [0.0.5](https://github.com/denolehov/obsidian-git/compare/v0.0.4...v0.0.5) (2020-10-27)\n\n\n### Features\n\n* Add an ability to specify custom commit message (specified in plugin settings) ([ca67112](https://github.com/denolehov/obsidian-git/commit/ca671124c5b2dc5127b76f48ab94e63d1e2b3626))\n\n### [0.0.4](https://github.com/denolehov/obsidian-git/compare/v0.0.3...v0.0.4) (2020-10-27)\n\n\n### Features\n\n* Improve UX a bit by showing notification of what's happening when user presses hotkey ([c562e74](https://github.com/denolehov/obsidian-git/commit/c562e746d7538923a378104d0204dad1f3f2aa61))\n\n### [0.0.3](https://github.com/denolehov/obsidian-git/compare/v0.0.2...v0.0.3) (2020-10-27)\n\n\n### Features\n\n* add an ability to push changes to a remote repository ([f229516](https://github.com/denolehov/obsidian-git/commit/f2295165fbd77dd9ed6e4cdd2f6d085b3ee78bfe))\n\n### [0.0.2](https://github.com/denolehov/obsidian-git/compare/v0.0.1...v0.0.2) (2020-10-27)\n\n\n### Features\n\n* Add an ability to pull changes from remote repository. ([88da6e5](https://github.com/denolehov/obsidian-git/commit/88da6e5bc01ef5066ab994e69640e0e101ed6b8f))\n\n### 0.0.1 (2020-10-27)\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020 Vinzent03, Denis Olehov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Obsidian Git Plugin\n\nA powerful community plugin for [Obsidian.md](Obsidian.md) that brings Git integration right into your vault. Automatically commit, pull, push, and see your changes — all within Obsidian.\n\n## 📚 Documentation\n\nAll setup instructions (including mobile), common issues, tips, and advanced configuration can be found in the 📖 [full documentation](https://publish.obsidian.md/git-doc).\n\n> Mobile users: The plugin is **highly unstable ⚠️ !** Please check the dedicated [Mobile](#-mobile-support-%EF%B8%8F--experimental) section below.\n\n## Key Features\n\n- 🔁 **Automatic commit-and-sync** (commit, pull, and push) on a schedule.\n- 📥 **Auto-pull on Obsidian startup**\n- 📂 **Submodule support** for managing multiple repositories (desktop only and opt-in)\n- 🔧 **Source Control View** to stage/unstage, commit and diff files - Open it with the `Open source control view` command.\n- 📜 **History View** for browsing commit logs and changed files - Open it with the `Open history view` command.\n- 🔍 **Diff View** for viewing changes in a file - Open it with the `Open diff view` command.\n- 📝 **Signs in the editor** to indicate added, modified, and deleted lines/hunks (desktop only).\n- GitHub integration to open files and history in your browser\n\n> For detailed file history, consider pairing this plugin with the [Version History Diff](obsidian://show-plugin?id=obsidian-version-history-diff) plugin.\n\n## UI Previews\n\n### 🔧 Source Control View\n\nManage your file changes directly inside Obsidian like stage/unstage individual files and commit them.\n\n![Source Control View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/source-view.png)\n\n### 📜 History View\n\nShow the commit history of your repository. The commit message, author, date, and changed files can be shown. Author and date are disabled by default as shown in the screenshot, but can be enabled in the settings.\n\n![History View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/history-view.png)\n\n### 🔍 Diff View \n\nCompare versions with a clear and concise diff viewer.\nOpen it from the source control view or via the `Open diff view` command.\n\n![Diff View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/diff-view.png)\n\n### 📝 Signs in the Editor\n\nView line-by-line changes directly in the editor with added, modified, and deleted line/hunk indicators. You can stage and reset changes right from the signs. There also commands to navigate between hunks and stage/reset hunks under the cursor. Needs to be enabled in the plugin settings.\n\n![Signs](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/signs.png)\n\n## Available Commands\n> Not exhaustive - these are just some of the most common commands. For a full list, see the Command Palette in Obsidian.\n\n- 🔄 Changes\n  - `List changed files`: Lists all changes in a modal\n  - `Open diff view`: Open diff view for the current file\n  - `Stage current file`\n  - `Unstage current file`\n  - `Discard all changes`: Discard all changes in the repository\n- ✅ Commit\n  - `Commit`: If files are staged only commits those, otherwise commits only files that have been staged\n  - `Commit with specific message`: Same as above, but with a custom message\n  - `Commit all changes`: Commits all changes without pushing\n  - `Commit all changes with specific message`: Same as above, but with a custom message\n- 🔀 Commit-and-sync\n  - `Commit-and-sync`: With default settings, this will commit all changes, pull, and push\n  - `Commit-and-sync with specific message`: Same as above, but with a custom message\n  - `Commit-and-sync and close`: Same as `Commit-and-sync`, but if running on desktop, will close the Obsidian window. Will not exit Obsidian app on mobile.\n- 🌐 Remote\n  - `Push`, `Pull`\n  - `Edit remotes`: Add new remotes or edit existing remotes\n  - `Remove remote`\n  - `Clone an existing remote repo`: Opens dialog that will prompt for URL and authentication to clone a remote repo\n  - `Open file on GitHub`: Open the file view of the current file on GitHub in a browser window. Note: only works on desktop\n  - `Open file history on GitHub`: Open the file history of the current file on GitHub in a browser window. Note: only works on desktop\n- 🏠 Manage local repository\n  - `Initialize a new repo`\n  - `Create new branch`\n  - `Delete branch`\n  - `CAUTION: Delete repository`\n- 🧪 Miscellaneous\n  - `Open source control view`: Opens side pane displaying [Source control view](#sidebar-view)\n  - `Open history view`: Opens side pane displaying [History view](#history-view)\n  - `Edit .gitignore`\n  - `Add file to .gitignore`: Add current file to `.gitignore`\n\n## 💻 Desktop Notes\n\n### 🔐 Authentication\n\nSome Git services may require further setup for HTTPS/SSH authentication. Refer to the [Authentication Guide](https://publish.obsidian.md/git-doc/Authentication)\n\n### Obsidian on Linux\n\n- ⚠️  Snap is not supported due to its sandboxing restrictions.\n- ⚠️  Flatpak is not recommended, because it doesn't have access to all system files. They are actively fixing many issues, but there are still issues. Especially with more advanced setups.\n- ✅ Please use AppImage or a full access installation of your system's package manager instead ([Linux installation guide](https://publish.obsidian.md/git-doc/Installation#Linux))\n\n## 📱 Mobile Support (⚠️  Experimental)\n\nThe Git implementation on mobile is **very unstable**! I would not recommend using this plugin on mobile, but try other syncing services.\n\nOne such alternative is [GitSync](https://github.com/ViscousPot/GitSync), which is available on both Android and iOS. It is not associated with this plugin, but it may be a better option for mobile users. A tutorial for setting it up can be found [here](https://viscouspotenti.al/posts/gitsync-all-devices-tutorial).\n\n> 🧪 The Git plugin works on mobile thanks to [isomorphic-git](https://isomorphic-git.org/), a JavaScript-based re-implementation of Git - but it comes with serious limitations and issues. It is not possible for an Obsidian plugin to use a native Git installation on Android or iOS.\n\n### ❌ Mobile Feature Limitations\n\n- No **SSH authentication** ([isomorphic-git issue](https://github.com/isomorphic-git/isomorphic-git/issues/231))\n- Limited repo size, because of memory restrictions\n- No rebase merge strategy\n- No submodules support\n\n### ⚠️ Performance Caveats\n\n> [!caution]\n> Depending on your device and available free RAM, Obsidian may\n>\n> - crash on clone/pull\n> - create buffer overflow errors\n> - run indefinitely.\n>\n> It's caused by the underlying git implementation on mobile, which is not efficient. I don't know how to fix this. If that's the case for you, I have to admit this plugin won't work for you. So commenting on any issue or creating a new one won't help. I am sorry.\n\n### Tips for Mobile Use:\n\nIf you have a large repo/vault I recommend to stage individual files and only commit staged files.\n\n## 🙋 Contact & Credits\n\n- The Line Authoring feature was developed by [GollyTicker](https://github.com/GollyTicker), so any questions may be best answered by her.\n- This plugin was initial developed by [denolehov](https://github.com/denolehov). Since March 2021, it's me [Vinzent03](https://github.com/Vinzent03) who is developing this plugin. That's why the GitHub repository got moved to my account in July 2024.\n- If you have any kind of feedback or questions, feel free to reach out via GitHub issues.\n\n## ☕ Support\n\nIf you find this plugin useful and would like to support its development, you can support me on Ko-fi.\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F195IQ5)\n"
  },
  {
    "path": "docs/.gitignore",
    "content": ".obsidian"
  },
  {
    "path": "docs/Authentication.md",
    "content": "---\naliases:\n  - \"04 Authentication\"\n---\n# macOS\n\n## HTTPS\n\nRun the following to use the macOS keychain to store your credentials.\n\n```bash\ngit config --global credential.helper osxkeychain\n```\n\nYou have to do one authentication action (clone/pull/push) after setting the helper in the terminal. After that you should be able to clone/pull/push in Obsidian without any issues.\n\n## SSH\n\nRemember you still have to setup ssh correctly, like adding your SSH key to the `ssh-agent`. GitHub provides a great documentation on how to [generate a new SSH key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent?platform=mac#generating-a-new-ssh-key) and then on how to [add the SSH key to your ssh-agent](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent?platform=mac#adding-your-ssh-key-to-the-ssh-agent).\n\n# Windows\n\n## HTTPS\n\nEnsure you are using Git 2.29 or higher and you are using Git Credential Manager as a credential helper. \nYou can verify this by executing the following snippet in a terminal, preferably in the directory where your vault/repository is located. It should output `manager`.\n\n```bash\ngit config credential.helper\n```\n\nIf this doesn't output `manager`, please run `git config set credential.helper manager`\nJust execute any authentication command like push/pull/clone and a pop window should come up, allowing your to sign in.\n\nAlternatively, you can also leave that setting empty and always provide the username and password manually via the prompted modal in Obsidian. All available credential helpers are listed [here](https://git-scm.com/doc/credential-helpers).,\n\n## SSH\nRemember you still have to setup ssh correctly, like adding your SSH key to the `ssh-agent`. GitHub provides a great documentation on how to [generate a new SSH key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent?platform=windows#generating-a-new-ssh-key) and then on how to [add the SSH key to your ssh-agent](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent?platform=windows#adding-your-ssh-key-to-the-ssh-agent).\n\n# Linux\n\n## HTTPS\n\n### Storing\n\nTo securely store the username and password permanently without having to reenter it all the time you can use Git's [Credential Helper](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage). `libsecret` stores the password in a secure place. On GNOME it's backed up by [GNOME Keyring](https://wiki.gnome.org/Projects/GnomeKeyring/) and on KDE by [KDE Wallet](https://wiki.archlinux.org/title/KDE_Wallet).\nTo set `libsecret` as your credential helper execute the following in the terminal from the directory of your vault/repository. You can also add the `--global` flag to set that setting for all other repositories on your device, too.\n\n```bash\ngit config credential.helper libsecret\n```\n\nYou have to do one authentication action (clone/pull/push) after setting the helper in the terminal. After that you should be able to clone/pull/push in Obsidian without any issues.\n\nIn case you get the message `git: 'credential-libsecret' is not a git command`, libsecret is not installed on your system. You may have to install it by yourself.\nHere is an example for Ubuntu.\n\n```bash\nsudo apt install libsecret-1-0 libsecret-1-dev make gcc\n\nsudo make --directory=/usr/share/doc/git/contrib/credential/libsecret\n\n# NOTE: This changes your global config, in case you don't want that you can omit the `--global` and execute it in your existing git repository.\ngit config --global credential.helper \\\n   /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret\n\n```\n\n### SSH_PASS Tools\nWhen Git is not connected to any terminal, so  you can't enter your username/password in the terminal, it relies on the `GIT_ASKPASS`/`SSH_ASKPASS` environment variable to provide an interface to the user to enter those values.\n\n#### Native SSH_ASKPASS\nIn case you don't want to store it permanently you can install `ksshaskpass` (it's preinstalled on KDE systems) and set it as binary to ask for the password.\n\nTo use `ksshaskpass` in Obsidian as the tool for `SSH_ASKPASS` add the following line to the \"Additional Environment Variables\" in the plugin's settings in the \"Advanced\" section.\n\n```\nSSH_ASKPASS=ksshaskpass\n```\n\nYou should get a new window to enter your username/password when using a Git action needing authentication now.\n\n#### SSH_PASS integrated in Obsidian\nThe plugin now automatically provides an integrated script for the `SSH_ASKPASS` environment variable, if no other program is set, that opens a modal in Obsidian whenever Git asks for username or password.\n\n## SSH\nWith one of the above [[#SSH_PASS Tools]]  installed to enter your passphrase, you can use ssh with a passphrase. Remember you still have to setup ssh correctly, like adding your SSH key to the `ssh-agent`. GitHub provides a great documentation on how to [generate a new SSH key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent?platform=linux#generating-a-new-ssh-key) and then on how to [add the SSH key to your ssh-agent](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent?platform=linuxu#adding-your-ssh-key-to-the-ssh-agent).\n"
  },
  {
    "path": "docs/Common issues.md",
    "content": "## xcrun: error: invalid developer path\n\nThis is an error occurring only on macOS. It's easy to fix though. Just run the following snippet in the terminal. `xcode-select --install` See #64 as an example.\n\n## Error: spansSync git ENOENT/ Cannot run Git command\n\nThis occurs, when the plugin can't find the Git executable. It takes it from the PATH. Head over to [[Installation]] to see if everything is properly installed for your platform.\nIf you think everything is correctly set up and the error still occurs try the following:\n\nIn case you know where Git is installed, you can set the path under \"Custom Git binary path\" in the settings. If you don't know where Git is installed, you can try to find it by running the following in the terminal:\n\n### Windows\n\nRun `where git` in the terminal. It should return the path to the Git executable. If it fails, Git is not properly installed.\n\n### Linux/MacOS\n\nRun `which git` in the terminal. It should return the path to the Git executable. If it fails, Git is not properly installed.\n\n## Infinite pulling/pushing with no error\n\nThat's most time caused by authentication problems. Head over to [[Authentication]]\n\n## Bad owner or permissions on /home/\\<user>/.ssh/config\n\nRun `chmod 600 ~/.ssh/config` in the terminal.\n\n\n## Files in `.gitignore` aren't ignored\n\nSince the plugin uses the native git installation, I can assure you that if the `.gitignore` file is properly written and git is correctly used, everything should work.\n\nIt's important to note that once a file is committed (or staged) changing the `.gitignore` doesn't help. You have to delete the file from your repo manually to ignore the file properly:\n1. Run `git rm --cached <file>` in your terminal. The file will stay on your file system. It's just deleted in your repo.\n2. The file should be listed as deleted in `git status`\n3. Commit the deletion\n4. Now any changes to the file are properly ignored.\n\n## Cannot run gpg\n\n```\nError: error: cannot run gpg: No such file or directory\nerror: gpg failed to sign the data\nfatal: failed to write commit object\n```\n\nSee [[Integration with other tools#GPG Signing]] on how to solve this.\n\n## This repository is configured for Git LFS but 'git-lfs' was not found on your path.\n\nSee [[Integration with other tools#Git Large File Storage]] on how to solve this."
  },
  {
    "path": "docs/Features.md",
    "content": "## Source Control View\n\nOpen it using the \"Open source control view\" command. It lists all current changes like when you run `git status`. It provides the following features\n\n- Stage/Unstage individual files\n- Discard any changes to a specific file\n- Open the diff view for changed files\n- Stage/Unstage all files\n- Push/Pull\n- Commit or [[Start here#commit-and-sync|commit-and-sync]]\n- Switch between list and tree view using the button at the top\n\n## History View\n\nOpen it using the \"Open history view\" command. It behaves like `git log` resulting in a list of the last commits. Each commit entry can be expanded to see the changed files in that commit. By clicking on a file, you can even see the diff.\n\n## Line Authoring\n\nFor each line, view the last time, it was modified: [[Line Authoring|Line Authoring]]. Technically known as `git-blame`.\n\n## Automatic commit-and-sync\n\nSee [[Start here#commit-and-sync|commit-and-sync]] for an explanation of the term. The goal of automatic commit-and-sync is that you can focus on taking notes and not care about saving your work, as this plugin will take care of it.\nThere are multiple ways to trigger an automatic commit-and-sync. The default is a basic interval to run commit-and-sync every X minutes. Use the \"Auto commit-and-sync interval\" setting for that. The interval works across Obsidian sessions to ensure opening Obsidian only for short times doesn't prevent running commit-and-sync. For example, if you set a 15 minutes interval, you don't have to keep Obsidian open for 15 minutes. If you close Obsidian before the interval end, the commit-and-sync will automatically run the next time you start Obsidian.\n\nAnother method is to enable \"Auto commit-and-sync after stopping file edits\". This waits X minutes after your latest change for the commit-and-sync. This is useful if you don't want to get interrupted by a commit while typing. \n\nThe last mode is the \"Auto commit-and-sync after latest commit\" setting. This sets the last commit-and-sync timestamp to the latest commit. By default, the plugin only compares with it's own latest run of commit-and-sync. So if you manually commit and want the commit-and-sync timer to reset, enable this setting.\n\n## Commit message\n\nThe plugin uses [momentjs](https://momentjs.com/) for formatting the date, so read through their documentation on how to construct your date placeholder.\n\n## Submodules Support\n\nSince version 1.10.0 submodules are supported. While adding/cloning new submodules is still not supported (might come later), updating existing submodules on the known \"Commit-and-sync\" and \"Pull\" commands is supported. This works even recursively. \"Commit-and-sync\" will cause adding, commit and push (if turned on) all changes in all submodules. This feature needs to be turned on in the settings.\n\nAdditional **requirements**:\n\n- Checked out branch (not just a commit as it is when running `git submodule update --init`)\n- Tracking branch is set up, so that `git push` works\n- Tracking branch needs to be fetched, so that a `git diff` with the branch works\n"
  },
  {
    "path": "docs/Getting Started.md",
    "content": "# Desktop\nYou can either start by cloning an existing remote repository as described [[#For existing remote repository|here]] or start with initializing a new repository locally and optionally push that to a remote repository as described [[#Create new local repository|here]].\n\n## Create new local repository\n\n1. Follow the [[Installation]] instructions for your operating system\n2. Call the `Initialize a new repo` command\n3. Create your first commit by creating some files and calling the `Commit all changes with specific message` command\n4. If you want to Setup to push it to a remote repository like to GitHub:\n\t1. Setup [[Authentication]]\n\t2. Ensure that the remote repository is empty. Otherwise delete the repository and instead proceed to clone the remote repository as described in the [[#For existing remote repository|next section]].\n\t3. Call the `Push` command. It should ask you for a name and URL of the remote repository. Just enter `origin` for the remote name and copy the URL to push to somewhere from your remote git service.\n\n## For existing remote repository\n\nTo clone, you have to use a remote URL. This can be one of two protocols: either `https` or `ssh`. This depends on your chosen [[Authentication]] method.\n`https`: `https://github.com/<username>/<repo>.git`\n`ssh`: `git@github.com:<username>/<repo>.git`\n\n1. Follow the [[Installation]] instructions for your operating system\n2. Setup [[Authentication]]\n3. Git can only clone a remote repo in a new folder. Thus you have two options\n    - Use the \"Clone an exising remote repository\" command to clone your repo into a subfolder of your vault. You then have again two choices\n        - Move all your files from the new folder (including `.git` !) into your vault root.\n        - Open your new subfolder as a new vault. You may have to install the plugin again.\n    - Run `git clone <your-remote-url>` in the command line wherever you want your vault to be located.\n4. Read on how to best configure your [[Tips-and-Tricks#Gitignore|.gitignore]]\n\n\n> [!info] iCloud and Git\n> When syncing your vault with iCloud and using Git on your desktop device the whole `.git` directory gets synced to your mobile device as well. This may slow down the Obsidian startup time. \n> - One solution is to put the git repository above your Obsidian vault. So that your vault is a sub directory of your git repository.\n> - Another solution is to move the `.git` directory to another location and create a `.git` file in your vault with only the following line: `gitdir: <path-to-your-actual-git-direcotry>`\n\n# Mobile\nThe Git implementation on mobile is **very unstable**! I would not recommend using this plugin on mobile, but try other syncing services.\n\nOne such alternative is [GitSync](https://github.com/ViscousPot/GitSync), which is available on both Android and iOS. It is not associated with this plugin, but it may be a better option for mobile users. A tutorial for setting it up can be found [here](https://viscouspotenti.al/posts/gitsync-all-devices-tutorial).\nAnother alternative for iOS is [Working Copy](https://workingcopy.app/).\n\n## Restrictions\n\nI am using [isomorphic-git](https://isomorphic-git.org/), which is a re-implementation of Git in JavaScript, because you cannot use native Git on Android or iOS.\n\n-   SSH authentication is not supported ([isomorphic-git issue](https://github.com/isomorphic-git/isomorphic-git/issues/231))\n-   Repo size is limited, because of memory restrictions\n-   Rebase merge strategy is not supported\n-   Submodules are not supported\n\n## Performance on mobile\n\n> [!danger] Warning\n> Depending on your device and available free RAM, Obsidian may\n> - crash on clone/pull\n> - create buffer overflow errors\n> - run indefinitely.\n>   \n> It's caused by the underlying git implementation on mobile, which is not efficient. I don't know how to fix this. If that's the case for you, I have to admit this plugin won't work for you. So commenting on any issue or creating a new one won't help. I am sorry.\n\n## Start with existing remote repository\n\n### Clone via plugin\n\nFollow these instructions for setting up an Obsidian Vault on a mobile device that is already backed up in a remote git repository.\n\nThe instructions assume you are using [GitHub](https://github.com), but can be extrapolated to other providers.\n\n1. Make sure any outstanding changes on all devices are pushed and reconciled with the remote repo.\n2. Install Obsidian for Android or iOS.\n3. Create a new vault (or point Obsidian to an empty directory). Do NOT select `Store in iCloud` if you are on iOS.\n4. If your repo is hosted on GitHub, [authentication must be done with a personal access token](https://github.blog/2020-12-15-token-authentication-requirements-for-git-operations/). Detailed instruction for that process can be found [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token).\n    - Minimal permissions required are\n        - \"Read access to metadata\"\n        - \"Read and Write access to contents and commit status\"\n5. In Obsidian settings, enable community plugins. Browse plugins to install Git.\n6. Enable Git (on the same screen)\n7. Go to Options for the Git plugin (bottom of main settings page, under Community Plugins section)\n8. Under the \"Authentication/Commit Author\" section, fill in the username on your git server and your password/personal access token.\n9. Don't touch any settings under \"Advanced\"\n10. Exit plugin settings, open command palette, choose \"Git: Clone existing remote repo\".\n11. Fill in repo URL in the text field and press the repo URL button below it. The repo URL is NOT the URL in the browser. You have to append `.git`. - `https://github.com/<username>/<repo>.git`\n    - E.g. `https://github.com/denolehov/obsidian-git.git`\n12. Follow instructions to determine the folder to place repo in and whether an `.obsidian` directory already exits.\n13. Clone should start. Popup notifications (if not disabled) will display the progress. Do not exit until a popup appears requesting that you \"Restart Obsidian\".\n\n### Clone via Working Copy on iOS\n\nDepending on the size of your repository and your device, Obsidian may crash during clone via the plugin. Alternatively, the initial clone can be done via [Working Copy](https://workingcopy.app/). None that this a paid app. The usual commit-and-sync can then be done via the plugin. The following guide assumes you don't commit your `.obsidian` directory.\n\n1. Make sure any outstanding changes on all devices are pushed and reconciled with the remote repo.\n2. Install Obsidian for Android or iOS.\n3. Create a new vault (or point Obsidian to an empty directory). Do NOT select `Store in iCloud` if you are on iOS.\n4. If your repo is hosted on GitHub, [authentication must be done with a personal access token](https://github.blog/2020-12-15-token-authentication-requirements-for-git-operations/). Detailed instruction for that process can be found [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token).\n    - Minimal permissions required are\n        - \"Read access to metadata\"\n        - \"Read and Write access to contents and commit status\"\n5. Swipe up and away Obsidian to fully close it. Open Working Copy app.\n6. Clone the repo using Working Copy. Instead of logging in to GitHub through the Working Copy interface, enter the clone URL directly. Then enter your username, and for the password your Personal Access Token.\n7. Open Files app.\n8. Copy the repo from Working Copy. Delete the vault from Obsidian and paste the repo there (repo has the same name as the vault).\n9. Open Obsidian.\n10. All your cloned files should be visible.\n11. Install and enable the Git plugin.\n12. Add your name/email to the \"Authentication/Commit Author\" section in the plugin settings.\n13. Use the command palette to call the \"Pull\" command.\n\n## Start with new repo\n\nSimilar steps as [existing repo](#existing-repo), except use the `Initialize a new repo` command, followed by `Edit remotes` to add the remote repo to track. This remote repo will need to exist and be empty. Also make sure to read on how to best configure your [[Tips-and-Tricks#Gitignore|.gitignore]].\n"
  },
  {
    "path": "docs/Installation.md",
    "content": "---\naliases:\n  - 02 Installation\n---\n\n> [!important]\n> Although the plugin itself is desktop platform independent, an incorrect installation of Obsidian or Git may break the plugin.\n\n## Plugin installation\n\n### From within Obsidian\nGo to \"Settings\" -> \"Community plugins\" -> \"Browse\", search for \"Git\", install and enable it.\n\n### Manual\n1. Download `obsidian-git-<latest-version>.zip` from the [latest release](https://github.com/Vinzent03/obsidian-git/releases/latest)\n2. Unpack the zip in `<vault>/.obsidian/plugins/obsidian-git`\n3. Restart Obsidian\n4. Go to settings and disable restricted mode\n5. Enable `Git`\n\n# Windows\n\nInstalling [GitHub Desktop](https://github.com/apps/desktop) is **not** enough! You need to install regular Git as well.\n## Git installation\n\n> [!info] \n> Ensure you are using Git 2.29 or higher. \n\nInstall Git from the official [website](https://git-scm.com/download/win) with all default settings.\nMake sure you have `3rd-party software` access enabled.\n\n![[third-party-windows-git.png]]\n\nEnable Git Credential Manager. You can verify this for existing installations by executing the following. It should ouput `manager`.\n\n```bash\ngit config credential.helper\n```\n\n![[credential-manager-windows-git.png]]\n\n\n# Linux\n\n## Obsidian installation\n\nKnown **supported** Obsidian installation methods:\n- AppImage\n\nKnown **not fully supported** package managers\n- Snap (Snap puts Obsidian in a kind of sandbox, so that Obsidian can't access Git)\n- [Flatpak](https://flathub.org/apps/details/md.obsidian.Obsidian) can access Git, but not all system files, so it's not recommended.\n\nIf you installed Obsidian a while ago via **Flatpak**, and it doesn't work, please run the following snippet.\n\n```\n$ flatpak update md.obsidian.Obsidian\n$ flatpak override --reset md.obsidian.Obsidian\n$ flatpak run md.obsidian.Obsidian\n```\n[Source of this snippet](https://github.com/flathub/md.obsidian.Obsidian/issues/5#issuecomment-736974662)\n\n# MacOS\n\n## Git Installation\n\nIn order to install `git` on your Mac Computer please follow a suitable route explained in the [Official Git documentation](https://git-scm.com/install/mac)\n\n## Keychain\n\nRun the following to use the macOS keychain to store your credentials.\n\n```zsh\ngit config --global credential.helper osxkeychain\n```\n\n>[!info]\n> You have to complete a **single authenticated action** (either clone, pull or push) after setting the helper in the terminal. Once done, you should be able to sync Obsidian without any issues."
  },
  {
    "path": "docs/Integration with other tools.md",
    "content": "Most issues with the integration of other installable tools are that their installation path is not added to the `PATH` environment variable. The `PATH` environment variable contains the directories where to search for executable programs. You probably don't have issues with executing your tools from the terminal, because you edited the `PATH` in your  `.bashrc`,`.zshrc`, but those files only apply to your shell and not to desktop applications like Obsidian. So some installation directories are missing in the `PATH` and the plugin can't find them.\n\n# Git Large File Storage\nGit Large File Storage is supported, but may need a bit configuration for the plugin to find the `git-lfs` executable.\n\n## MacOS\n\n1. Make sure to install [git-lfs](https://git-lfs.com/) using `brew install git-lfs`.\n\t- This will install `git-lfs` to `/opt/homebrew/bin/`, which is probably not in your `PATH` environment variable when using Obsidian.\n2. To make `/opt/homebrew/bin/` available in Obsidian, add `/opt/homebrew/bin/` to the \"Additional PATH environment variables paths\" setting under \"Advanced\".\n3. Restart Obsidian.\n\n## Linux\n1. Make sure to install [git-lfs](https://git-lfs.com/).\n\t- The place where `git-lfs` is installed to varies by package manager and distribution. Usually there is no need to manually add it to your `PATH`, but if the plugin can't find `git-lfs`follow the next steps.\n2. Run `which git-lfs` in your terminal to get the installation path. It should output something of the form `<some-path>/git-lfs.\n2. Add the `<some-path>` part of the previous step to the \"Additional PATH environment variables paths\" setting under \"Advanced\".\n3. Restart Obsidian.\n\n## Windows\nThere is no need to change anything for the plugin, because git-lfs is installed with Git for Windows and should be available if Git is available as well.\n\n# GPG Signing\n\nGitHub provides a great [documentation about GPG](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key), which should work with Obsidian as well.\nOne issue you might encounter though is the following:\n```\nError: error: cannot run gpg: No such file or directory\nerror: gpg failed to sign the data\nfatal: failed to write commit object\n```\n\nThis means there is no `gpg`  binary in your PATH, which you may have only properly configured for your shell. But since Obsidian is started in a different way, these PATH modifications don't affect Obsidian. To get the binary path of your `gpg` installation, run `which gpg` on Linux and Mac-OS and `where gpg` on Windows. A common location may be `/usr/local/bin/gpg`.\n\n- You can either add that to the \"Additional PATH environment variables\" plugin setting to provide the gpg binary to your  plugin installation only.\n- Or set it in your Git config via `git config --global gpg.program <your previous output>` to set the gpg binary globally for all git repositories.\n\nPlease create an issue if you encounter any issues and the documentation needs to be improved."
  },
  {
    "path": "docs/Line Authoring.md",
    "content": "# Quick User Guide\n\nA quick showcase of all functionality. This feature is based on [git-blame](https://git-scm.com/docs/git-blame).\n\nℹ️ The line author view only works in Live-Preview and Source mode - not in Reading mode.\n\nℹ️ Currently, only Obsidian on desktop is supported.\n\nℹ️ The recently released Obsidian v1.0 is fully supported. The images and GIFs in this document are however not yet updated.\n\n## Activate\n\n![](assets/line-author-activate.png)\n\nIt can also be activated via Command Palette `Git: Toggle line author information`.\n\n## Default line author information\n\n![](assets/line-author-default.png)\n\nShows the initials of the author as well as the authoring date in `YYYY-MM-DD` format.\n\nThe `*` indicates, that the author and committer (or their timestamps) are different - i.e., due to a rebase.\n\n## Commit hash and full name\n\n![](assets/line-author-commit-hash-full-name.png)\n\nvia config\n\n![](assets/line-author-commit-hash-full-name-config.png)\n\n## Natural language dates\n\n![](assets/line-author-natural-language-dates.png)\n\n## Custom date formats\n\n![](assets/line-author-custom-dates.png)\n\nvia config\n\n![](assets/line-author-custom-dates-config.png)\n\n## Commit time in local/author/UTC time-zone\n\n**UTC+0000/Z**\n\nThe simplest option to start with is showing the time in `UTC+00:00/Z` time-zone.\nThis is independent of both your local and the author's time-zone.\nIt is shown with a suffix `Z` to avoid confusion with local time.\n\n![](assets/line-author-tz-utc0000.png)\n\nThis is the time displayed in the guter is the same for all users.\n\n**My local (default)**\n\nBy default, the times are shown in your local time-zone - i.e., `What was the clock-time at my wall showing, when the commit was made?` This depends on your local time-zone. For instance, this is the view for a user in the `UTC+01:00` time-zone.\n\n![](assets/line-author-tz-viewer-plus0100.png)\n\nNote, how the displayed time is `1h` ahead of the above `UTC+0000` time.\n\n**Author's local**\n\nAlternatively, it can show it in the author's time-zone with explicit `UTC` offset - i.e., `What was clock-time at the author's wall and their explicit UTC offset, when the commit was made?`\n\nThis is independent of your local time-zone and the same time is displayed for all users.\n\n![](assets/line-author-tz-author-local.png)\n\n**Configuration**\n\n![](assets/line-author-tz-config.png)\n\n## Age-based gutter colors\n\nThe line gutter color is based on the age of the commit. It adapts to the dark/light mode automatically.\n\n![](assets/line-author-dark-light.gif)\n\nRed-ish means newer and blue-ish means older. All commits at and above a certain maximum coloring\nage (configurable; default `1 year`) get the same strongest blue-ish color.\n\nThe colors are configurable and the defaults are chosen to be accessible.\n\n![](assets/line-author-color-config.png)\n\n## Adjust text color CSS based on theme\n\nBy default, the gutter text color uses `var(--text-muted)` which\nis whatever is defined by your theme. You can however, change it to a different CSS\ncolor or variable.\n\n![](assets/line-author-text-color.png)\n\nExample:\n| `var(--text-muted)` | `var(--text-normal)` |\n|----------------------------------------------|-----------------------------------------------|\n| ![](assets/line-author-text-color-muted.png) | ![](assets/line-author-text-color-normal.png) |\n\n## Copy commit hash\n\n![](assets/line-author-copy-commit-hash.png)\n\n## Quick configure gutter\n\n![](assets/line-author-quick-configure-gutter.gif)\n\n## New/uncommitted lines and files show `+++`\n\n![](assets/line-author-untracked.png)\n\n## Follow lines across cut-copy-paste-ing within same commit / all commits\n\nBy default, each line shows the last commit, where it was changed.\nThis means, that cut-copy-paste-ing lines will show the new commit,\neven though it was not originally written in that commit.\n\n![](assets/line-author-follow-no-follow.png)\n\nHowever, if for instance following is set to `all commits`, then this is the result:\n\n![](assets/line-author-follow-all-commits.png)\n\nConfiguration:\n\n![](assets/line-author-follow-config.png)\n\n## Soft and unintrusive ansynchronous view updates\n\nSince computing the line author information takes time (due to a `git blame` shell invocation)\nthe result appears delayed. To minimize distraction and improve user experience,\nthe view is updated in a soft and unintrusive manner.\n\nWhen opening a file, a placeholder is shown meanwhile:\n\n![](assets/line-author-soft-unintrusive-ux.gif)\n\nWhile editing, a placeholder is shown as well until the file is saved and the line author information is computed.\n\n![](assets/line-author-soft-unintrusive-ux-editing.gif)\n\n## Multi-line block support\n\nThe markdown rendering of multiple lines as a combined block is also supported.\nIn this case the newest of all lines is shown in the gutter.\n\n![](assets/line-author-multi-line-newest.gif)\n\n## Ignore whitespace and newlines\n\nThis can be activated in the settings.\n\n| **Original**                                         | **Changed with preserved whitespace**                   | **Changed with ignored whitespace**                   |\n| ---------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------- |\n| ![](assets/line-author-ignore-whitespace-before.png) | ![](assets/line-author-ignore-whitespace-preserved.png) | ![](assets/line-author-ignore-whitespace-ignored.png) |\n\nNote, how ignoring the whitespace does not mark the indented\nlines as changes, as only additional whitespace was added.\n\n## Submodules support\n\nLine author information is fully supported in submodules.\n"
  },
  {
    "path": "docs/Start here.md",
    "content": "---\naliases:\n    - \"01 Start here\"\n---\n\n# Git plugin Documentation\n\n## Topics\n\n-   [[Installation|Installation]]\n-   [[Getting Started|Getting Started]]\n-   [[Authentication|Authentication]]\n-   [[Integration with other tools]]\n-   [[Features|Features]]\n-   [[Tips-and-Tricks|Tips-and-Tricks]]\n-   [[Common issues|Common Issues]]\n-   [[Line Authoring|Line Authoring]]\n\n> [!warning] Obsidian installation on Linux\n> Please don't use Flatpak or Snap to install Obsidian on Linux. Learn more [[Installation#Linux|here]]\n\n![[Getting Started#Performance on mobile]]\n\n## What is Git?\n\nGit is a version control system. It allows you to keep track of changes to your notes and revert back to previous versions. It also allows you to collaborate with other people on the same files. You can read more about Git [here](https://git-scm.com/book/en/v2/Getting-Started-About-Version-Control).\n\n> [!info] Git/GitHub is not a syncing service!\n> Git is not meant to share your changes live to the cloud or another person. Meaning it should not be used to work with someone live on the same note. However, it's perfect for async collaboration.\n\nYou build your history by batching multiple changes into commits. These can then be reverted or checked out. You can view the difference between version of a note via the [Version History Diff](obsidian://show-plugin?id=obsidian-version-history-diff) plugin.\nGit itself only manages a local repository. It becomes really handy in conjunction with an online remote repository. You can push and pull your commits to/from a remote repository to share or backup your vault. The most popular provider is [GitHub](https://github.com).\n\nGit is primarily used by developers and thus the command line is sometimes needed. Obsidian-Git is a plugin for Obsidian that allows you to use Git from within Obsidian without always having to use the command line or leaving Obsidian.\n\n## Terminology and concepts\n\n### Backup - no longer in use\n\nFor simplification, the term \"Backup\" refers to staging everything -> committing -> pulling -> pushing.\n\n### Sync\n\nSyncing is the process of pulling and pushing changes to and from a remote repository. This is done to keep your local repository up to date with the remote repository on e.g. GitHub.\n\n### Commit-and-sync\n\nCommit-and-sync is the process of staging everything -> committing -> pulling -> pushing. Ideally this is a single action that you do regularly to keep your local and remote repository in sync. It's recommended you set it up from the plugin's settings to be run automatically every X minutes. You can also disable the pulling or pushing part from the \"Commit-and-sync\" section in the plugin's settings. This reduces the \"commit-and-sync\" action to either a \"commit and pull\", \"commit and push\" or just commit action.\n"
  },
  {
    "path": "docs/Tips-and-Tricks.md",
    "content": "# Tips and Tricks\n\n## Gitignore\n\nTo exclude cache files from the repository, create `.gitignore` file in the root of your vault and add the lines in the snippet below.\nThere's also the `Edit .gitignore` command that will open the file in a modal.\n\n```\n# to exclude Obsidian's settings (including plugin and hotkey configurations)\n.obsidian/\n\n# to only exclude plugin configuration. Might be useful to prevent some plugin from exposing sensitive data\n.obsidian/plugins\n\n# OR only to exclude workspace cache\n.obsidian/workspace.json\n\n# to exclude workspace cache specific to mobile devices\n.obsidian/workspace-mobile.json\n\n# Add below lines to exclude OS settings and caches\n.trash/\n.DS_Store\n```\n\n\n## Usage with Obsidian Sync\n\nA common use case for using git and Obsidian Sync is to use Obsidian Sync to actually sync between all your devices and Git as a form of backup and version history.\n\n### Use Git plugin only on one device\n\nIn case you are syncing your enabled plugins and their settings, the Git plugin is enabled and running even though the `.git` directory doesn't exist or you don't want to run automatics on that device. To fix this, you can enable the \"Disable on this device\" option under \"Advanced\" in the plugin settings. That setting is not synced to other devices.\n\n### Use Git plugin, but not to pull your files\n\nAnother use case might be that you don't want to update your files on pull, because Obsidian Sync already updated your files. You can still commit/push/commit-and-sync. To accomplish this use \"Other sync service\" as \"Merge strategy\" under \"Pull\". This only updates the HEAD to the latest commit on pull, but doesn't change your files at all.\n"
  },
  {
    "path": "docs/dev/LineAuthorFeature.md",
    "content": "# Line Authoring Feature - Developer Documentation\n\n-   This feature was developed by [GollyTicker](https://github.com/GollyTicker).\n-   [Feature documentation for users](https://publish.obsidian.md/git-doc/Line+Authoring)\n\n## Architecture\n\nTo understand how this feature integrates with the [Codemirror 6 editor](https://codemirror.net/) used in the Obsidian editors, it is adviseable to read the following sections of the [Codemirror Guide](https://codemirror.net/docs/guide/):\n\n-   Architecture Overview > (everything)\n-   Data Model\n    -   Configuration\n    -   Facets\n    -   Transactions\n-   View > (intro)\n-   Extending Codemirror\n    -   State Fields\n\nFurthermore, the following concepts are necessary:\n\n-   [EditorState](https://codemirror.net/docs/ref/#state.EditorState)\n-   [State Field](https://codemirror.net/docs/ref/#state.StateField)\n-   [Transaction](https://codemirror.net/docs/ref/#state.Transaction)\n-   [Creating a transaction](https://codemirror.net/docs/ref/#state.EditorState.update)\n-   [Annotation within a transaction](https://codemirror.net/docs/ref/#state.Annotation)\n-   [ChangeSet](https://codemirror.net/docs/ref/#state.ChangeSet) (for the unsaved changes gutter update)\n-   [Exmaple: Document Changes](https://codemirror.net/examples/change/)\n-   [Example: Configuratoin and Extension](https://codemirror.net/examples/config/)\n\nGiven changes/updates of the file or file-view within Obsidian, we want to re-compute the line authoring (via [git-blame](https://git-scm.com/docs/git-blame)) and show it in the line gutters left to the editors.\n\nWhen doing this, we need to integrate with the declarative modeling of Codemirror - and have its views automatically updated, when we change its associated data.\n\nWe achieve the goal via the following steps:\n\n1. Every new editor pane in Obsidian subscribes itself\n   by its filepath ([LineAuthoringSubcriber](/src/lineAuthor/control.ts))\n   and listens in an internal publish-subscriber-model\n   ([eventsPerFilepath.ts](/src/lineAuthor/eventsPerFilepath.ts))\n   for updates on that filepath.\n2. Any changed file in the Obsidian Vault or anytime when a new\n   file is opened, [lineAuthorProvider](/src/lineAuthor/lineAuthoProvider.ts)\n   initiates the asynchronous computation of the\n   [LineAuthoring](/src/lineAuthor/model.ts)\n   via [simpleGit.ts](/src/simpleGit.ts) -\n   which parses the output of `git-blame`.\n3. Once the `LineAuthoring` is computed, the publish-subscriber-model is notified\n   of the new value for the corresponding filepath.\n4. The notified `LineAuthoringSubcriber` creates a new transaction\n   (via [newComputationResultAsTransaction](/src/lineAuthor/model.ts))\n   containing the `LineAuthoring`.\n5. The `LineAuthoringSubscriber` [dispatches the transaction\n   on the current EditorView](https://codemirror.net/docs/ref/#view.EditorView.dispatch).\n6. The [StateField's update](https://codemirror.net/docs/ref/#state.StateField^define^config.update)\n   method is called by Codemirror due to the dispatched transaction.\n   The [lineAuthorState](/src/lineAuthor/model.ts) updates itself with the\n   newest `LineAuthoring`, if it one was provided in the transaction.\n7. The [lineAuthorGutter](/src/lineAuthor/view/view.ts) is automatically re-rendered,\n   due to the dispatch and the changes of the state-fields. The re-rendering\n   now accesses the newest state-field values - resulting in a new DOM.\n\n## Development\n\nYou can use this test-vault https://github.com/GollyTicker/obsidian-git-test-vault-online.\n\nOnce the watchmode npm is started, one can simply open the `test-vault` in Obsidian to\ntest the plugin. The Git plugin files are symbolic links to the\nautomatically re-compiled files at repository root level.\n\nOne can additionally use the\n[docker-setup from this branch for a reproduceable developer setup](https://github.com/GollyTicker/obsidian-git/tree/docker-setup).\n\n## Edge cases and error cases\n\nThese cases should be tested, when changes to this feature have been made.\n\n-   running outside of a git repository\n-   opening an untracked file\n-   opening and closing obsidian windows of panes/notes\n-   notes with a starting \"--\" in their filename\n-   special characters in filenames\n-   unicode filenames\n-   empty file\n-   file with populated last line\n-   multi-line block with differeing line commits\n-   examples for moving/copy-following\n-   submodules\n-   vault root != repository root\n-   error in git blame result\n-   open multiple files simultanously\n-   open same file multiple times - and edit\n-   open same files in multiple windows - and edit\n-   open empty tracked file and make edits. quick update should respond sensibly\n-   open file in a large, complex real-world vault with unknown characteristics\n    (the private vault of the developer GollyTicker suffices) and repeatedly press Enter in a tracked file.\n    -   We expect no errors, but after adding the unsaved changed gutter update feature,\n        an early bu was present, where errors would occur during rendering and the view would become messed up.\n-   UI should render correctly regardless of whether line numbers are shown as well or not.\n    -   [[see obsidan forum discussion](https://forum.obsidian.md/t/added-editor-gutter-overlaps-and-obscures-editor-content/45217)\n-   indentation changes and changes after last line (without trailing newline) with 'Ignored whitespace' enabled/disabled\n-   [Unsaved Changes Gutter Update Scenario](#unsaved-changes-gutter-update-scenario)\n-   commit file in a different time-zone than the current Obsidian user\n    -   check that time-zone \"local\" formatting is correct\n    -   time-zone \"UTC\" should always show the same result regardless of the local time-zone\n-   line authoring id correctly uses submodule HEAD revision rather than super-project.\n\n    -   There was a bug with the old super-project identifier. It did not fully work with submodules as the following scenario lead to a different displayed line authoring, than the true one.\n\n    1. remember the lineAuthoringId A for a file in a submodule in the vault.\n\n        - it uses the HEAD of the git super-project rather than of the submodule the file is contained in.\n\n    2. add a few lines in the file. The plugin will correctly detect the changed file-contents\n       hash, which will trigger re-computation and re-render.\n    3. commit the changes in the submodule - without making a corresponding commit in the super-project.\n    4. Close the file and re-open it in Obsidian.\n\n        - In the submodule, the HEAD has changed - but not in the super-project.\n        - Since the file path and file contents are same after committing, they haven't changed.\n        - The current cache key doesn't detect this change and hence the view isn't updated.\n        - Reloading Obsidian entirely will evict the cache - and the line authoring will be shown correctly again.\n\n### Unsaved Changes Gutter Update Scenario\n\nThis scenario contains two main cases to test:\n\n#### 1. Untracked file\n\n1. Open an untracked file. It should show +++ everywhere.\n2. Make insertions, deletions and in-line changes. It should always show +++.\n\n#### 2. Tracked file\n\n1. Open a tracked file with different line author dates and colors\n2. Make insertions, deletions and in-line changes.\n\n-   It should first show % until the changes are saved and the line authoring is computed.\n-   The % should preserving the color of the changed line and insertions/deletions should shift the\n    line authoring for subsequent lines accordingly\n\n3. Make multi-line insertions, deletions and in-line changes (e.g. via cut-copy-pasting of blocks of text).\n\n-   Hint: Use Ctrl+Z as well.\n-   The behavior should be same as above.\n\n4. Make changes at the intersection of unsaved and saved changes. The result should be consistent with above.\n\n## Potential Future Improvements\n\n-   show commit info when click/hover on gutter\n-   show / highlight diff when hover/click on gutter\n-   small tooltip widget when hovering/right-clicking on line author gutter with author/hash, etc.\n-   show deleted lines\n-   interpret new 'newline' at end of line as non-change to make gutter change marking more intuitive.\n    -   [one option is to add a setting which switches between compatibility-mode and comfort-mode](https://github.com/denolehov/obsidian-git/pull/288)\n-   distinguish untracked and changed line (e.g. \"~\" and \"+\")\n-   use addMomentFormat in settings.ts when configuring the line author date format.\n-   main.ts: refreshUpdatedHead(): Detect, if the head has changed from outside of Git (e.g. script) and run this callback then.\n-   Avoid \"Uncaught illegal access error\" when closing a separate Obsidian window.\n    It doesn't seem to have any impact on UX yet though...\n-   Unique initials option: [work in progress branch](https://github.com/GollyTicker/obsidian-git/tree/line-author-unique-initials)\n"
  },
  {
    "path": "esbuild.config.mjs",
    "content": "import esbuild from \"esbuild\";\nimport esbuildSvelte from \"esbuild-svelte\";\nimport process from \"process\";\nimport { sveltePreprocess } from \"svelte-preprocess\";\n\nconst banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\nif you want to view the source visit the plugins github repository (https://github.com/denolehov/obsidian-git)\n*/\n`;\n\nconst prod = process.argv[2] === \"production\";\n\nconst context = await esbuild.context({\n    banner: {\n        js: banner,\n    },\n    entryPoints: [\"src/main.ts\"],\n    bundle: true,\n    external: [\n        \"obsidian\",\n        \"electron\",\n        \"child_process\",\n        \"fs\",\n        \"os\",\n        \"path\",\n        \"moment\",\n        \"node:events\",\n        \"@codemirror/autocomplete\",\n        \"@codemirror/collab\",\n        \"@codemirror/commands\",\n        \"@codemirror/language\",\n        \"@codemirror/lint\",\n        \"@codemirror/search\",\n        \"@codemirror/state\",\n        \"@codemirror/view\",\n        \"@lezer/common\",\n        \"@lezer/highlight\",\n        \"@lezer/lr\",\n    ],\n    format: \"cjs\",\n    target: \"es2018\",\n    logLevel: \"info\",\n    sourcemap: prod ? false : \"inline\",\n    treeShaking: true,\n    platform: \"browser\",\n    minify: prod,\n    conditions: [prod ? \"production\" : \"development\"], // https://www.npmjs.com/package/esm-env\n    plugins: [\n        esbuildSvelte({\n            compilerOptions: {\n                css: \"injected\",\n                dev: !prod,\n            },\n            filterWarnings: (warning) => {\n                if (warning.code.startsWith(\"a11y-\")) return false;\n                return true;\n            },\n            preprocess: sveltePreprocess(),\n        }),\n    ],\n    inject: [\"polyfill_buffer.js\"],\n    outfile: \"main.js\",\n});\n\nif (prod) {\n    await context.rebuild();\n    process.exit(0);\n} else {\n    await context.watch();\n}\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import svelteParser from \"svelte-eslint-parser\";\nimport tsParser from \"@typescript-eslint/parser\";\nimport eslint from \"@eslint/js\";\nimport tseslint from \"typescript-eslint\";\nimport eslintPluginSvelte from \"eslint-plugin-svelte\";\nimport { defineConfig } from \"eslint/config\";\n\nexport default defineConfig(\n    {\n        ignores: [\"**/node_modules/\", \"**/main.js\"],\n    },\n    eslint.configs.recommended,\n    ...tseslint.configs.recommendedTypeChecked,\n    ...eslintPluginSvelte.configs[\"flat/prettier\"],\n    {\n        languageOptions: {\n            parserOptions: {\n                projectService: true,\n                tsconfigRootDir: import.meta.dirname,\n            },\n        },\n        rules: {\n            \"@typescript-eslint/no-unused-vars\": [\n                \"error\",\n                {\n                    args: \"all\",\n                    argsIgnorePattern: \"^_\",\n                    caughtErrors: \"all\",\n                    caughtErrorsIgnorePattern: \"^_\",\n                    destructuredArrayIgnorePattern: \"^_\",\n                    varsIgnorePattern: \"^_\",\n                    ignoreRestSiblings: true,\n                },\n            ],\n        },\n    },\n    {\n        files: [\"**/*.svelte\"],\n        languageOptions: {\n            parser: svelteParser,\n            parserOptions: {\n                extraFileExtensions: [\".svelte\"],\n                parser: tsParser,\n            },\n        },\n        rules: {\n            \"no-undef\": \"off\",\n        },\n    }\n);\n"
  },
  {
    "path": "manifest.json",
    "content": "{\n    \"author\": \"Vinzent\",\n    \"authorUrl\": \"https://github.com/Vinzent03\",\n    \"id\": \"obsidian-git\",\n    \"name\": \"Git\",\n    \"description\": \"Integrate Git version control with automatic backup and other advanced features.\",\n    \"isDesktopOnly\": false,\n    \"fundingUrl\": \"https://ko-fi.com/vinzent\",\n    \"version\": \"2.38.0\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"obsidian-git\",\n    \"version\": \"2.38.0\",\n    \"description\": \"Integrate Git version control with automatic backup and other advanced features in Obsidian.md\",\n    \"main\": \"main.js\",\n    \"scripts\": {\n        \"dev\": \"node esbuild.config.mjs dev\",\n        \"build\": \"node esbuild.config.mjs production\",\n        \"release\": \"standard-version\",\n        \"lint\": \"eslint src\",\n        \"format\": \"prettier --check src\",\n        \"tsc\": \"tsc --noEmit\",\n        \"svelte\": \"svelte-check\",\n        \"all\": \"pnpm run tsc && pnpm run svelte && pnpm run format && pnpm run lint\"\n    },\n    \"keywords\": [],\n    \"author\": \"Vinzent03\",\n    \"license\": \"MIT\",\n    \"standard-version\": {\n        \"t\": \"\"\n    },\n    \"engines\": {\n        \"node\": \">=18\",\n        \"pnpm\": \">=9\"\n    },\n    \"devDependencies\": {\n        \"@eslint/js\": \"^9.39.2\",\n        \"@types/debug\": \"^4.1.12\",\n        \"@types/deep-equal\": \"^1.0.4\",\n        \"@types/diff\": \"^5.2.3\",\n        \"@types/diff3\": \"^0.0.2\",\n        \"@types/node\": \"^22.19.10\",\n        \"@typescript-eslint/parser\": \"8.47.0\",\n        \"esbuild\": \"^0.24.2\",\n        \"esbuild-svelte\": \"^0.8.2\",\n        \"eslint\": \"^9.39.2\",\n        \"eslint-plugin-svelte\": \"^2.46.1\",\n        \"obsidian\": \"^1.11.4\",\n        \"prettier\": \"3.3.2\",\n        \"prettier-plugin-svelte\": \"^3.4.1\",\n        \"scss\": \"^0.2.4\",\n        \"standard-version\": \"^9.5.0\",\n        \"svelte-check\": \"^4.3.6\",\n        \"svelte-eslint-parser\": \"^0.43.0\",\n        \"svelte-preprocess\": \"^6.0.3\",\n        \"tslib\": \"^2.8.1\",\n        \"typescript\": \"5.8.3\",\n        \"typescript-eslint\": \"^8.54.0\"\n    },\n    \"dependencies\": {\n        \"@codemirror/commands\": \"^6.10.2\",\n        \"@codemirror/merge\": \"^6.12.0\",\n        \"@codemirror/search\": \"^6.6.0\",\n        \"@codemirror/state\": \"^6.5.4\",\n        \"@codemirror/view\": \"^6.39.13\",\n        \"buffer\": \"^6.0.3\",\n        \"codemirror\": \"^6.0.2\",\n        \"css-color-converter\": \"^2.0.0\",\n        \"debug\": \"^4.4.3\",\n        \"deep-equal\": \"^2.2.3\",\n        \"diff\": \"^8.0.3\",\n        \"diff2html\": \"^3.4.56\",\n        \"diff3\": \"^0.0.4\",\n        \"isomorphic-git\": \"^1.36.3\",\n        \"js-sha256\": \"^0.9.0\",\n        \"simple-git\": \"github:Vinzent03/git-js#release\",\n        \"supports-color\": \"^9.4.0\",\n        \"svelte\": \"^5.50.0\"\n    },\n    \"moduleFileExtensions\": [\n        \"js\",\n        \"ts\",\n        \"svelte\"\n    ]\n}\n"
  },
  {
    "path": "polyfill_buffer.js",
    "content": "import { Platform } from 'obsidian';\nlet buffer;\nif (Platform.isMobileApp) {\n    buffer = require('buffer/index.js').Buffer\n} else {\n    buffer = global.Buffer\n}\n\nexport const Buffer = buffer;\n"
  },
  {
    "path": "src/automaticsManager.ts",
    "content": "import { debounce } from \"obsidian\";\nimport type ObsidianGit from \"./main\";\n\nexport default class AutomaticsManager {\n    private timeoutIDCommitAndSync?: number;\n    private timeoutIDPush?: number;\n    private timeoutIDPull?: number;\n\n    constructor(private readonly plugin: ObsidianGit) {}\n\n    private saveLastAuto(date: Date, mode: \"backup\" | \"pull\" | \"push\") {\n        if (mode === \"backup\") {\n            this.plugin.localStorage.setLastAutoBackup(date.toString());\n        } else if (mode === \"pull\") {\n            this.plugin.localStorage.setLastAutoPull(date.toString());\n        } else if (mode === \"push\") {\n            this.plugin.localStorage.setLastAutoPush(date.toString());\n        }\n    }\n\n    private loadLastAuto(): { backup: Date; pull: Date; push: Date } {\n        return {\n            backup: new Date(\n                this.plugin.localStorage.getLastAutoBackup() ?? \"\"\n            ),\n            pull: new Date(this.plugin.localStorage.getLastAutoPull() ?? \"\"),\n            push: new Date(this.plugin.localStorage.getLastAutoPush() ?? \"\"),\n        };\n    }\n\n    async init() {\n        await this.setUpAutoCommitAndSync();\n        const lastAutos = this.loadLastAuto();\n\n        if (\n            this.plugin.settings.differentIntervalCommitAndPush &&\n            this.plugin.settings.autoPushInterval > 0\n        ) {\n            const diff = this.diff(\n                this.plugin.settings.autoPushInterval,\n                lastAutos.push\n            );\n            this.startAutoPush(diff);\n        }\n        if (this.plugin.settings.autoPullInterval > 0) {\n            const diff = this.diff(\n                this.plugin.settings.autoPullInterval,\n                lastAutos.pull\n            );\n            this.startAutoPull(diff);\n        }\n    }\n\n    unload() {\n        this.clearAutoPull();\n        this.clearAutoPush();\n        this.clearAutoCommitAndSync();\n    }\n\n    /**\n     * Clears all timers and sets all timers to their current settings.\n     *\n     * This does not calculate any differences to last autos or commits.\n     * Should only be used when settings are changed.\n     */\n    reload(...type: (\"commit\" | \"push\" | \"pull\")[]) {\n        if (this.plugin.localStorage.getPausedAutomatics()) return;\n\n        if (type.contains(\"commit\")) {\n            this.clearAutoCommitAndSync();\n            if (this.plugin.settings.autoSaveInterval > 0) {\n                this.startAutoCommitAndSync(\n                    this.plugin.settings.autoSaveInterval\n                );\n            }\n        }\n        if (type.contains(\"push\")) {\n            this.clearAutoPush();\n            if (\n                this.plugin.settings.differentIntervalCommitAndPush &&\n                this.plugin.settings.autoPushInterval > 0\n            ) {\n                this.startAutoPush(this.plugin.settings.autoPushInterval);\n            }\n        }\n        if (type.contains(\"pull\")) {\n            this.clearAutoPull();\n            if (this.plugin.settings.autoPullInterval > 0) {\n                this.startAutoPull(this.plugin.settings.autoPullInterval);\n            }\n        }\n    }\n\n    /**\n     * Starts the auto commit-and-sync with the correct remaining time.\n     *\n     * Additionally, if `setLastSaveToLastCommit` is enabled, the last auto commit-and-sync\n     * is set to the last commit time.\n     */\n    private async setUpAutoCommitAndSync() {\n        if (this.plugin.settings.setLastSaveToLastCommit) {\n            this.clearAutoCommitAndSync();\n            const lastCommitDate =\n                await this.plugin.gitManager.getLastCommitTime();\n            if (lastCommitDate) {\n                this.saveLastAuto(lastCommitDate, \"backup\");\n            }\n        }\n\n        if (!this.timeoutIDCommitAndSync && !this.plugin.autoCommitDebouncer) {\n            const lastAutos = this.loadLastAuto();\n\n            if (this.plugin.settings.autoSaveInterval > 0) {\n                const diff = this.diff(\n                    this.plugin.settings.autoSaveInterval,\n                    lastAutos.backup\n                );\n                this.startAutoCommitAndSync(diff);\n            }\n        }\n    }\n\n    private startAutoCommitAndSync(minutes?: number) {\n        let time = (minutes ?? this.plugin.settings.autoSaveInterval) * 60000;\n        if (this.plugin.settings.autoBackupAfterFileChange) {\n            if (minutes === 0) {\n                this.doAutoCommitAndSync();\n            } else {\n                this.plugin.autoCommitDebouncer = debounce(\n                    () => this.doAutoCommitAndSync(),\n                    time,\n                    true\n                );\n            }\n        } else {\n            // max timeout in js\n            if (time > 2147483647) time = 2147483647;\n            this.timeoutIDCommitAndSync = window.setTimeout(\n                () => this.doAutoCommitAndSync(),\n                time\n            );\n        }\n    }\n\n    // This is used for both auto commit-and-sync and commit only\n    private doAutoCommitAndSync(): void {\n        this.plugin.promiseQueue.addTask(\n            async () => {\n                // Re-check if the auto commit should run now or be postponed,\n                // because the last commit time has changed\n                if (this.plugin.settings.setLastSaveToLastCommit) {\n                    const lastCommitDate =\n                        await this.plugin.gitManager.getLastCommitTime();\n                    if (lastCommitDate) {\n                        this.saveLastAuto(lastCommitDate, \"backup\");\n                        const diff = this.diff(\n                            this.plugin.settings.autoSaveInterval,\n                            lastCommitDate\n                        );\n                        if (diff > 0) {\n                            this.startAutoCommitAndSync(diff);\n                            // Return false to mark the next iteration\n                            // already being scheduled\n                            return false;\n                        }\n                    }\n                }\n                const onlyStaged = this.plugin.settings.autoCommitOnlyStaged;\n                if (this.plugin.settings.differentIntervalCommitAndPush) {\n                    await this.plugin.commit({ fromAuto: true, onlyStaged });\n                } else {\n                    await this.plugin.commitAndSync({\n                        fromAutoBackup: true,\n                        onlyStaged,\n                    });\n                }\n                return true;\n            },\n            (schedule) => {\n                // Don't schedule if the next iteration is already scheduled\n                if (schedule !== false) {\n                    this.saveLastAuto(new Date(), \"backup\");\n                    this.startAutoCommitAndSync();\n                }\n            }\n        );\n    }\n\n    private startAutoPull(minutes?: number) {\n        let time = (minutes ?? this.plugin.settings.autoPullInterval) * 60000;\n        // max timeout in js\n        if (time > 2147483647) time = 2147483647;\n\n        this.timeoutIDPull = window.setTimeout(() => this.doAutoPull(), time);\n    }\n\n    private doAutoPull(): void {\n        this.plugin.promiseQueue.addTask(\n            () => this.plugin.pullChangesFromRemote(),\n            () => {\n                this.saveLastAuto(new Date(), \"pull\");\n                this.startAutoPull();\n            }\n        );\n    }\n\n    private startAutoPush(minutes?: number) {\n        let time = (minutes ?? this.plugin.settings.autoPushInterval) * 60000;\n        // max timeout in js\n        if (time > 2147483647) time = 2147483647;\n\n        this.timeoutIDPush = window.setTimeout(() => this.doAutoPush(), time);\n    }\n\n    private doAutoPush(): void {\n        this.plugin.promiseQueue.addTask(\n            () => this.plugin.push(),\n            () => {\n                this.saveLastAuto(new Date(), \"push\");\n                this.startAutoPush();\n            }\n        );\n    }\n\n    private clearAutoCommitAndSync(): boolean {\n        let wasActive = false;\n        if (this.timeoutIDCommitAndSync) {\n            window.clearTimeout(this.timeoutIDCommitAndSync);\n            this.timeoutIDCommitAndSync = undefined;\n            wasActive = true;\n        }\n        if (this.plugin.autoCommitDebouncer) {\n            this.plugin.autoCommitDebouncer?.cancel();\n            this.plugin.autoCommitDebouncer = undefined;\n            wasActive = true;\n        }\n        return wasActive;\n    }\n\n    private clearAutoPull(): boolean {\n        if (this.timeoutIDPull) {\n            window.clearTimeout(this.timeoutIDPull);\n            this.timeoutIDPull = undefined;\n            return true;\n        }\n        return false;\n    }\n\n    private clearAutoPush(): boolean {\n        if (this.timeoutIDPush) {\n            window.clearTimeout(this.timeoutIDPush);\n            this.timeoutIDPush = undefined;\n            return true;\n        }\n        return false;\n    }\n\n    /**\n     * Calculates the minutes until the next auto action. >= 0\n     *\n     * This is done by the difference between the setting and the time since the last auto action, but at least 0.\n     */\n    private diff(setting: number, lastAuto: Date) {\n        const now = new Date();\n        const diff =\n            setting -\n            Math.round((now.getTime() - lastAuto.getTime()) / 1000 / 60);\n        return Math.max(0, diff);\n    }\n}\n"
  },
  {
    "path": "src/commands.ts",
    "content": "import { Notice, Platform, TFolder, WorkspaceLeaf } from \"obsidian\";\nimport { HISTORY_VIEW_CONFIG, SOURCE_CONTROL_VIEW_CONFIG } from \"./constants\";\nimport { SimpleGit } from \"./gitManager/simpleGit\";\nimport ObsidianGit from \"./main\";\nimport { openHistoryInGitHub, openLineInGitHub } from \"./openInGitHub\";\nimport { ChangedFilesModal } from \"./ui/modals/changedFilesModal\";\nimport { GeneralModal } from \"./ui/modals/generalModal\";\nimport { IgnoreModal } from \"./ui/modals/ignoreModal\";\nimport { assertNever } from \"./utils\";\nimport { togglePreviewHunk } from \"./editor/signs/tooltip\";\n\nexport function addCommmands(plugin: ObsidianGit) {\n    const app = plugin.app;\n\n    plugin.addCommand({\n        id: \"edit-gitignore\",\n        name: \"Edit .gitignore\",\n        callback: async () => {\n            const path = plugin.gitManager.getRelativeVaultPath(\".gitignore\");\n            if (!(await app.vault.adapter.exists(path))) {\n                await app.vault.adapter.write(path, \"\");\n            }\n            const content = await app.vault.adapter.read(path);\n            const modal = new IgnoreModal(app, content);\n            const res = await modal.openAndGetReslt();\n            if (res !== undefined) {\n                await app.vault.adapter.write(path, res);\n                await plugin.refresh();\n            }\n        },\n    });\n    plugin.addCommand({\n        id: \"open-git-view\",\n        name: \"Open source control view\",\n        callback: async () => {\n            const leafs = app.workspace.getLeavesOfType(\n                SOURCE_CONTROL_VIEW_CONFIG.type\n            );\n            let leaf: WorkspaceLeaf;\n            if (leafs.length === 0) {\n                leaf =\n                    app.workspace.getRightLeaf(false) ??\n                    app.workspace.getLeaf();\n                await leaf.setViewState({\n                    type: SOURCE_CONTROL_VIEW_CONFIG.type,\n                });\n            } else {\n                leaf = leafs.first()!;\n            }\n            await app.workspace.revealLeaf(leaf);\n\n            // Is not needed for the first open, but allows to refresh the view\n            // per hotkey even if already opened\n            app.workspace.trigger(\"obsidian-git:refresh\");\n        },\n    });\n    plugin.addCommand({\n        id: \"open-history-view\",\n        name: \"Open history view\",\n        callback: async () => {\n            const leafs = app.workspace.getLeavesOfType(\n                HISTORY_VIEW_CONFIG.type\n            );\n            let leaf: WorkspaceLeaf;\n            if (leafs.length === 0) {\n                leaf =\n                    app.workspace.getRightLeaf(false) ??\n                    app.workspace.getLeaf();\n                await leaf.setViewState({\n                    type: HISTORY_VIEW_CONFIG.type,\n                });\n            } else {\n                leaf = leafs.first()!;\n            }\n            await app.workspace.revealLeaf(leaf);\n\n            // Is not needed for the first open, but allows to refresh the view\n            // per hotkey even if already opened\n            app.workspace.trigger(\"obsidian-git:refresh\");\n        },\n    });\n\n    plugin.addCommand({\n        id: \"open-diff-view\",\n        name: \"Open diff view\",\n        checkCallback: (checking) => {\n            const file = app.workspace.getActiveFile();\n            if (checking) {\n                return file !== null;\n            } else {\n                const filePath = plugin.gitManager.getRelativeRepoPath(\n                    file!.path,\n                    true\n                );\n                plugin.tools.openDiff({\n                    aFile: filePath,\n                    aRef: \"\",\n                });\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"view-file-on-github\",\n        name: \"Open file on GitHub\",\n        editorCallback: (editor, { file }) => {\n            if (file) return openLineInGitHub(editor, file, plugin.gitManager);\n        },\n    });\n\n    plugin.addCommand({\n        id: \"view-history-on-github\",\n        name: \"Open file history on GitHub\",\n        editorCallback: (_, { file }) => {\n            if (file) return openHistoryInGitHub(file, plugin.gitManager);\n        },\n    });\n\n    plugin.addCommand({\n        id: \"pull\",\n        name: \"Pull\",\n        callback: () =>\n            plugin.promiseQueue.addTask(() => plugin.pullChangesFromRemote()),\n    });\n\n    plugin.addCommand({\n        id: \"fetch\",\n        name: \"Fetch\",\n        callback: () => plugin.promiseQueue.addTask(() => plugin.fetch()),\n    });\n\n    plugin.addCommand({\n        id: \"switch-to-remote-branch\",\n        name: \"Switch to remote branch\",\n        callback: () =>\n            plugin.promiseQueue.addTask(() => plugin.switchRemoteBranch()),\n    });\n\n    plugin.addCommand({\n        id: \"add-to-gitignore\",\n        name: \"Add file to .gitignore\",\n        checkCallback: (checking) => {\n            const file = app.workspace.getActiveFile();\n            if (checking) {\n                return file !== null;\n            } else {\n                plugin\n                    .addFileToGitignore(file!.path, file instanceof TFolder)\n                    .catch((e) => plugin.displayError(e));\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"push\",\n        name: \"Commit-and-sync\",\n        callback: () =>\n            plugin.promiseQueue.addTask(() =>\n                plugin.commitAndSync({ fromAutoBackup: false })\n            ),\n    });\n\n    plugin.addCommand({\n        id: \"backup-and-close\",\n        name: \"Commit-and-sync and then close Obsidian\",\n        callback: () =>\n            plugin.promiseQueue.addTask(async () => {\n                await plugin.commitAndSync({ fromAutoBackup: false });\n                window.close();\n            }),\n    });\n\n    plugin.addCommand({\n        id: \"commit-push-specified-message\",\n        name: \"Commit-and-sync with specific message\",\n        callback: () =>\n            plugin.promiseQueue.addTask(() =>\n                plugin.commitAndSync({\n                    fromAutoBackup: false,\n                    requestCustomMessage: true,\n                })\n            ),\n    });\n\n    plugin.addCommand({\n        id: \"commit\",\n        name: \"Commit all changes\",\n        callback: () =>\n            plugin.promiseQueue.addTask(() =>\n                plugin.commit({ fromAuto: false })\n            ),\n    });\n\n    plugin.addCommand({\n        id: \"commit-specified-message\",\n        name: \"Commit all changes with specific message\",\n        callback: () =>\n            plugin.promiseQueue.addTask(() =>\n                plugin.commit({\n                    fromAuto: false,\n                    requestCustomMessage: true,\n                })\n            ),\n    });\n\n    plugin.addCommand({\n        id: \"commit-smart\",\n        name: \"Commit\",\n        callback: () =>\n            plugin.promiseQueue.addTask(async () => {\n                const status = await plugin.updateCachedStatus();\n                const onlyStaged = status.staged.length > 0;\n                return plugin.commit({\n                    fromAuto: false,\n                    requestCustomMessage: false,\n                    onlyStaged: onlyStaged,\n                });\n            }),\n    });\n\n    plugin.addCommand({\n        id: \"commit-staged\",\n        name: \"Commit staged\",\n        checkCallback: function (checking) {\n            // Don't show this command in command palette, because the\n            // commit-smart command is more useful. Still provide this command\n            // for hotkeys and automation.\n            if (checking) return false;\n\n            plugin.promiseQueue.addTask(async () => {\n                return plugin.commit({\n                    fromAuto: false,\n                    requestCustomMessage: false,\n                });\n            });\n        },\n    });\n\n    if (Platform.isDesktopApp) {\n        plugin.addCommand({\n            id: \"commit-amend-staged-specified-message\",\n            name: \"Amend staged\",\n            callback: () =>\n                plugin.promiseQueue.addTask(() =>\n                    plugin.commit({\n                        fromAuto: false,\n                        requestCustomMessage: true,\n                        onlyStaged: true,\n                        amend: true,\n                    })\n                ),\n        });\n    }\n\n    plugin.addCommand({\n        id: \"commit-smart-specified-message\",\n        name: \"Commit with specific message\",\n        callback: () =>\n            plugin.promiseQueue.addTask(async () => {\n                const status = await plugin.updateCachedStatus();\n                const onlyStaged = status.staged.length > 0;\n                return plugin.commit({\n                    fromAuto: false,\n                    requestCustomMessage: true,\n                    onlyStaged: onlyStaged,\n                });\n            }),\n    });\n\n    plugin.addCommand({\n        id: \"commit-staged-specified-message\",\n        name: \"Commit staged with specific message\",\n        checkCallback: function (checking) {\n            // Same reason as for commit-staged\n            if (checking) return false;\n            return plugin.promiseQueue.addTask(() =>\n                plugin.commit({\n                    fromAuto: false,\n                    requestCustomMessage: true,\n                    onlyStaged: true,\n                })\n            );\n        },\n    });\n\n    plugin.addCommand({\n        id: \"push2\",\n        name: \"Push\",\n        callback: () => plugin.promiseQueue.addTask(() => plugin.push()),\n    });\n\n    plugin.addCommand({\n        id: \"stage-current-file\",\n        name: \"Stage current file\",\n        checkCallback: (checking) => {\n            const file = app.workspace.getActiveFile();\n            if (checking) {\n                return file !== null;\n            } else {\n                plugin.promiseQueue.addTask(() => plugin.stageFile(file!));\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"unstage-current-file\",\n        name: \"Unstage current file\",\n        checkCallback: (checking) => {\n            const file = app.workspace.getActiveFile();\n            if (checking) {\n                return file !== null;\n            } else {\n                plugin.promiseQueue.addTask(() => plugin.unstageFile(file!));\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"edit-remotes\",\n        name: \"Edit remotes\",\n        callback: () =>\n            plugin.editRemotes().catch((e) => plugin.displayError(e)),\n    });\n\n    plugin.addCommand({\n        id: \"remove-remote\",\n        name: \"Remove remote\",\n        callback: () =>\n            plugin.removeRemote().catch((e) => plugin.displayError(e)),\n    });\n\n    plugin.addCommand({\n        id: \"set-upstream-branch\",\n        name: \"Set upstream branch\",\n        callback: () =>\n            plugin.setUpstreamBranch().catch((e) => plugin.displayError(e)),\n    });\n\n    plugin.addCommand({\n        id: \"delete-repo\",\n        name: \"CAUTION: Delete repository\",\n        callback: async () => {\n            const repoExists = await app.vault.adapter.exists(\n                `${plugin.settings.basePath}/.git`\n            );\n            if (repoExists) {\n                const modal = new GeneralModal(plugin, {\n                    options: [\"NO\", \"YES\"],\n                    placeholder:\n                        \"Do you really want to delete the repository (.git directory)? plugin action cannot be undone.\",\n                    onlySelection: true,\n                });\n                const shouldDelete = (await modal.openAndGetResult()) === \"YES\";\n                if (shouldDelete) {\n                    await app.vault.adapter.rmdir(\n                        `${plugin.settings.basePath}/.git`,\n                        true\n                    );\n                    new Notice(\n                        \"Successfully deleted repository. Reloading plugin...\"\n                    );\n                    plugin.unloadPlugin();\n                    await plugin.init({ fromReload: true });\n                }\n            } else {\n                new Notice(\"No repository found\");\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"init-repo\",\n        name: \"Initialize a new repo\",\n        callback: () =>\n            plugin.createNewRepo().catch((e) => plugin.displayError(e)),\n    });\n\n    plugin.addCommand({\n        id: \"clone-repo\",\n        name: \"Clone an existing remote repo\",\n        callback: () =>\n            plugin.cloneNewRepo().catch((e) => plugin.displayError(e)),\n    });\n\n    plugin.addCommand({\n        id: \"list-changed-files\",\n        name: \"List changed files\",\n        callback: async () => {\n            if (!(await plugin.isAllInitialized())) return;\n\n            try {\n                const status = await plugin.updateCachedStatus();\n                if (status.changed.length + status.staged.length > 500) {\n                    plugin.displayError(\"Too many changes to display\");\n                    return;\n                }\n\n                new ChangedFilesModal(plugin, status.all).open();\n            } catch (e) {\n                plugin.displayError(e);\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"switch-branch\",\n        name: \"Switch branch\",\n        callback: () => {\n            plugin.switchBranch().catch((e) => plugin.displayError(e));\n        },\n    });\n\n    plugin.addCommand({\n        id: \"create-branch\",\n        name: \"Create new branch\",\n        callback: () => {\n            plugin.createBranch().catch((e) => plugin.displayError(e));\n        },\n    });\n\n    plugin.addCommand({\n        id: \"delete-branch\",\n        name: \"Delete branch\",\n        callback: () => {\n            plugin.deleteBranch().catch((e) => plugin.displayError(e));\n        },\n    });\n\n    plugin.addCommand({\n        id: \"discard-all\",\n        name: \"CAUTION: Discard all changes\",\n        callback: async () => {\n            const res = await plugin.discardAll();\n            switch (res) {\n                case \"discard\":\n                    new Notice(\"Discarded all changes in tracked files.\");\n                    break;\n                case \"delete\":\n                    new Notice(\"Discarded all files.\");\n                    break;\n                case false:\n                    break;\n                default:\n                    assertNever(res);\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"pause-automatic-routines\",\n        name: \"Pause/Resume automatic routines\",\n        callback: () => {\n            const pause = !plugin.localStorage.getPausedAutomatics();\n            plugin.localStorage.setPausedAutomatics(pause);\n            if (pause) {\n                plugin.automaticsManager.unload();\n                new Notice(`Paused automatic routines.`);\n            } else {\n                plugin.automaticsManager.reload(\"commit\", \"push\", \"pull\");\n                new Notice(`Resumed automatic routines.`);\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"raw-command\",\n        name: \"Raw command\",\n        checkCallback: (checking) => {\n            const gitManager = plugin.gitManager;\n            if (checking) {\n                // only available on desktop\n                return gitManager instanceof SimpleGit;\n            } else {\n                plugin.tools\n                    .runRawCommand()\n                    .catch((e) => plugin.displayError(e));\n            }\n        },\n    });\n\n    plugin.addCommand({\n        id: \"toggle-line-author-info\",\n        name: \"Toggle line author information\",\n        callback: () =>\n            plugin.settingsTab?.configureLineAuthorShowStatus(\n                !plugin.settings.lineAuthor.show\n            ),\n    });\n\n    plugin.addCommand({\n        id: \"reset-hunk\",\n        name: \"Reset hunk\",\n        editorCheckCallback(checking, _, __) {\n            if (checking) {\n                return (\n                    plugin.settings.hunks.hunkCommands &&\n                    plugin.hunkActions.editor !== undefined\n                );\n            }\n\n            plugin.hunkActions.resetHunk();\n        },\n    });\n\n    plugin.addCommand({\n        id: \"stage-hunk\",\n        name: \"Stage hunk\",\n        editorCheckCallback: (checking, _, __) => {\n            if (checking) {\n                return (\n                    plugin.settings.hunks.hunkCommands &&\n                    plugin.hunkActions.editor !== undefined\n                );\n            }\n            plugin.promiseQueue.addTask(() => plugin.hunkActions.stageHunk());\n        },\n    });\n\n    plugin.addCommand({\n        id: \"preview-hunk\",\n        name: \"Preview hunk\",\n        editorCheckCallback: (checking, _, __) => {\n            if (checking) {\n                return (\n                    plugin.settings.hunks.hunkCommands &&\n                    plugin.hunkActions.editor !== undefined\n                );\n            }\n            const editor = plugin.hunkActions.editor!.editor;\n            togglePreviewHunk(editor);\n        },\n    });\n\n    plugin.addCommand({\n        id: \"next-hunk\",\n        name: \"Go to next hunk\",\n        editorCheckCallback: (checking, _, __) => {\n            if (checking) {\n                return (\n                    plugin.settings.hunks.hunkCommands &&\n                    plugin.hunkActions.editor !== undefined\n                );\n            }\n            plugin.hunkActions.goToHunk(\"next\");\n        },\n    });\n\n    plugin.addCommand({\n        id: \"prev-hunk\",\n        name: \"Go to previous hunk\",\n        editorCheckCallback: (checking, _, __) => {\n            if (checking) {\n                return (\n                    plugin.settings.hunks.hunkCommands &&\n                    plugin.hunkActions.editor !== undefined\n                );\n            }\n            plugin.hunkActions.goToHunk(\"prev\");\n        },\n    });\n}\n"
  },
  {
    "path": "src/constants.ts",
    "content": "import { Platform } from \"obsidian\";\nimport type { ObsidianGitSettings } from \"./types\";\nexport const DATE_FORMAT = \"YYYY-MM-DD\";\nexport const DATE_TIME_FORMAT_MINUTES = `${DATE_FORMAT} HH:mm`;\nexport const DATE_TIME_FORMAT_SECONDS = `${DATE_FORMAT} HH:mm:ss`;\n\nexport const GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH = 40;\n\nexport const CONFLICT_OUTPUT_FILE = \"conflict-files-obsidian-git.md\";\n\nexport const DEFAULT_SETTINGS: ObsidianGitSettings = {\n    commitMessage: \"vault backup: {{date}}\",\n    autoCommitMessage: \"vault backup: {{date}}\",\n    commitMessageScript: \"\",\n    commitDateFormat: DATE_TIME_FORMAT_SECONDS,\n    autoSaveInterval: 0,\n    autoPushInterval: 0,\n    autoPullInterval: 0,\n    autoPullOnBoot: false,\n    autoCommitOnlyStaged: false,\n    disablePush: false,\n    pullBeforePush: true,\n    disablePopups: false,\n    showErrorNotices: true,\n    disablePopupsForNoChanges: false,\n    listChangedFilesInMessageBody: false,\n    showStatusBar: true,\n    updateSubmodules: false,\n    syncMethod: \"merge\",\n    mergeStrategy: \"none\",\n    customMessageOnAutoBackup: false,\n    autoBackupAfterFileChange: false,\n    treeStructure: false,\n    refreshSourceControl: Platform.isDesktopApp,\n    basePath: \"\",\n    differentIntervalCommitAndPush: false,\n    changedFilesInStatusBar: false,\n    showedMobileNotice: false,\n    refreshSourceControlTimer: 7000,\n    showBranchStatusBar: true,\n    setLastSaveToLastCommit: false,\n    submoduleRecurseCheckout: false,\n    gitDir: \"\",\n    showFileMenu: true,\n    authorInHistoryView: \"hide\",\n    dateInHistoryView: false,\n    diffStyle: \"split\",\n    hunks: {\n        showSigns: false,\n        hunkCommands: false,\n        statusBar: \"disabled\",\n    },\n    lineAuthor: {\n        show: false,\n        followMovement: \"inactive\",\n        authorDisplay: \"initials\",\n        showCommitHash: false,\n        dateTimeFormatOptions: \"date\",\n        dateTimeFormatCustomString: DATE_TIME_FORMAT_MINUTES,\n        dateTimeTimezone: \"viewer-local\",\n        coloringMaxAge: \"1y\",\n        // colors were picked via:\n        // https://color.adobe.com/de/create/color-accessibility\n        colorNew: { r: 255, g: 150, b: 150 },\n        colorOld: { r: 120, g: 160, b: 255 },\n        textColorCss: \"var(--text-muted)\", //  more pronounced than line numbers, but less than the content text\n        ignoreWhitespace: false,\n        gutterSpacingFallbackLength: 5,\n    },\n};\n\nexport const SOURCE_CONTROL_VIEW_CONFIG = {\n    type: \"git-view\",\n    name: \"Source Control\",\n    icon: \"git-pull-request\",\n};\n\nexport const HISTORY_VIEW_CONFIG = {\n    type: \"git-history-view\",\n    name: \"History\",\n    icon: \"history\",\n};\n\nexport const SPLIT_DIFF_VIEW_CONFIG = {\n    type: \"split-diff-view\",\n    name: \"Diff view\",\n    icon: \"diff\",\n};\nexport const DIFF_VIEW_CONFIG = {\n    type: \"diff-view\",\n    name: \"Diff View\",\n    icon: \"git-pull-request\",\n};\n\nexport const DEFAULT_WIN_GIT_PATH = \"C:\\\\Program Files\\\\Git\\\\cmd\\\\git.exe\";\nexport const ASK_PASS_INPUT_FILE = \".git_credentials_input\";\nexport const ASK_PASS_SCRIPT_FILE = \"obsidian_askpass.sh\";\n\nexport const ASK_PASS_SCRIPT = `#!/bin/sh\n\nPROMPT=\"$1\"\nTEMP_FILE=\"$OBSIDIAN_GIT_CREDENTIALS_INPUT\"\n\ncleanup() {\n    rm -f \"$TEMP_FILE\" \"$TEMP_FILE.response\"\n}\ntrap cleanup EXIT\n\necho \"$PROMPT\" > \"$TEMP_FILE\"\n\nwhile [ ! -e \"$TEMP_FILE.response\" ]; do\n    if [ ! -e \"$TEMP_FILE\" ]; then\n        echo \"Trigger file got removed: Abort\" >&2\n        exit 1\n    fi\n    sleep 0.1\ndone\n\nRESPONSE=$(cat \"$TEMP_FILE.response\")\n\necho \"$RESPONSE\"\n`;\n\n/**\n * Copied from https://github.com/sindresorhus/binary-extensions/blob/main/binary-extensions.json\n */\nexport const BINARY_EXTENSIONS = [\n    \"3dm\",\n    \"3ds\",\n    \"3g2\",\n    \"3gp\",\n    \"7z\",\n    \"a\",\n    \"aac\",\n    \"adp\",\n    \"afdesign\",\n    \"afphoto\",\n    \"afpub\",\n    \"ai\",\n    \"aif\",\n    \"aiff\",\n    \"alz\",\n    \"ape\",\n    \"apk\",\n    \"appimage\",\n    \"ar\",\n    \"arj\",\n    \"asf\",\n    \"au\",\n    \"avi\",\n    \"bak\",\n    \"baml\",\n    \"bh\",\n    \"bin\",\n    \"bk\",\n    \"bmp\",\n    \"btif\",\n    \"bz2\",\n    \"bzip2\",\n    \"cab\",\n    \"caf\",\n    \"cgm\",\n    \"class\",\n    \"cmx\",\n    \"cpio\",\n    \"cr2\",\n    \"cur\",\n    \"dat\",\n    \"dcm\",\n    \"deb\",\n    \"dex\",\n    \"djvu\",\n    \"dll\",\n    \"dmg\",\n    \"dng\",\n    \"doc\",\n    \"docm\",\n    \"docx\",\n    \"dot\",\n    \"dotm\",\n    \"dra\",\n    \"DS_Store\",\n    \"dsk\",\n    \"dts\",\n    \"dtshd\",\n    \"dvb\",\n    \"dwg\",\n    \"dxf\",\n    \"ecelp4800\",\n    \"ecelp7470\",\n    \"ecelp9600\",\n    \"egg\",\n    \"eol\",\n    \"eot\",\n    \"epub\",\n    \"exe\",\n    \"f4v\",\n    \"fbs\",\n    \"fh\",\n    \"fla\",\n    \"flac\",\n    \"flatpak\",\n    \"fli\",\n    \"flv\",\n    \"fpx\",\n    \"fst\",\n    \"fvt\",\n    \"g3\",\n    \"gh\",\n    \"gif\",\n    \"graffle\",\n    \"gz\",\n    \"gzip\",\n    \"h261\",\n    \"h263\",\n    \"h264\",\n    \"icns\",\n    \"ico\",\n    \"ief\",\n    \"img\",\n    \"ipa\",\n    \"iso\",\n    \"jar\",\n    \"jpeg\",\n    \"jpg\",\n    \"jpgv\",\n    \"jpm\",\n    \"jxr\",\n    \"key\",\n    \"ktx\",\n    \"lha\",\n    \"lib\",\n    \"lvp\",\n    \"lz\",\n    \"lzh\",\n    \"lzma\",\n    \"lzo\",\n    \"m3u\",\n    \"m4a\",\n    \"m4v\",\n    \"mar\",\n    \"mdi\",\n    \"mht\",\n    \"mid\",\n    \"midi\",\n    \"mj2\",\n    \"mka\",\n    \"mkv\",\n    \"mmr\",\n    \"mng\",\n    \"mobi\",\n    \"mov\",\n    \"movie\",\n    \"mp3\",\n    \"mp4\",\n    \"mp4a\",\n    \"mpeg\",\n    \"mpg\",\n    \"mpga\",\n    \"mxu\",\n    \"nef\",\n    \"npx\",\n    \"numbers\",\n    \"nupkg\",\n    \"o\",\n    \"odp\",\n    \"ods\",\n    \"odt\",\n    \"oga\",\n    \"ogg\",\n    \"ogv\",\n    \"otf\",\n    \"ott\",\n    \"pages\",\n    \"pbm\",\n    \"pcx\",\n    \"pdb\",\n    \"pdf\",\n    \"pea\",\n    \"pgm\",\n    \"pic\",\n    \"png\",\n    \"pnm\",\n    \"pot\",\n    \"potm\",\n    \"potx\",\n    \"ppa\",\n    \"ppam\",\n    \"ppm\",\n    \"pps\",\n    \"ppsm\",\n    \"ppsx\",\n    \"ppt\",\n    \"pptm\",\n    \"pptx\",\n    \"psd\",\n    \"pya\",\n    \"pyc\",\n    \"pyo\",\n    \"pyv\",\n    \"qt\",\n    \"rar\",\n    \"ras\",\n    \"raw\",\n    \"resources\",\n    \"rgb\",\n    \"rip\",\n    \"rlc\",\n    \"rmf\",\n    \"rmvb\",\n    \"rpm\",\n    \"rtf\",\n    \"rz\",\n    \"s3m\",\n    \"s7z\",\n    \"scpt\",\n    \"sgi\",\n    \"shar\",\n    \"snap\",\n    \"sil\",\n    \"sketch\",\n    \"slk\",\n    \"smv\",\n    \"snk\",\n    \"so\",\n    \"stl\",\n    \"suo\",\n    \"sub\",\n    \"swf\",\n    \"tar\",\n    \"tbz\",\n    \"tbz2\",\n    \"tga\",\n    \"tgz\",\n    \"thmx\",\n    \"tif\",\n    \"tiff\",\n    \"tlz\",\n    \"ttc\",\n    \"ttf\",\n    \"txz\",\n    \"udf\",\n    \"uvh\",\n    \"uvi\",\n    \"uvm\",\n    \"uvp\",\n    \"uvs\",\n    \"uvu\",\n    \"viv\",\n    \"vob\",\n    \"war\",\n    \"wav\",\n    \"wax\",\n    \"wbmp\",\n    \"wdp\",\n    \"weba\",\n    \"webm\",\n    \"webp\",\n    \"whl\",\n    \"wim\",\n    \"wm\",\n    \"wma\",\n    \"wmv\",\n    \"wmx\",\n    \"woff\",\n    \"woff2\",\n    \"wrm\",\n    \"wvx\",\n    \"xbm\",\n    \"xif\",\n    \"xla\",\n    \"xlam\",\n    \"xls\",\n    \"xlsb\",\n    \"xlsm\",\n    \"xlsx\",\n    \"xlt\",\n    \"xltm\",\n    \"xltx\",\n    \"xm\",\n    \"xmind\",\n    \"xpi\",\n    \"xpm\",\n    \"xwd\",\n    \"xz\",\n    \"z\",\n    \"zip\",\n    \"zipx\",\n];\n"
  },
  {
    "path": "src/editor/control.ts",
    "content": "import type { EditorState } from \"@codemirror/state\";\nimport { StateField } from \"@codemirror/state\";\nimport type { EditorView } from \"@codemirror/view\";\nimport { editorEditorField, editorInfoField } from \"obsidian\";\nimport { eventsPerFilePathSingleton } from \"./eventsPerFilepath\";\nimport type { LineAuthoring, LineAuthoringId } from \"./lineAuthor/model\";\nimport { newComputationResultAsTransaction } from \"./lineAuthor/model\";\nimport {\n    hunksState,\n    newGitCompareResultAsTransaction,\n    type GitCompareResult,\n} from \"./signs/hunkState\";\n\n/*\n================== CONTROL ======================\nContains classes and function responsible for updating the model\ngiven the changes in the Obsidian UI.\n*/\n\n/**\n * Subscribes to changes in the files on a specific filepath.\n * It knows its corresponding editor and initiates editor view changes.\n */\nexport class FileSubscriber {\n    private lastSeenPath: string; // remember path to detect and adapt to renames\n\n    constructor(private state: EditorState) {\n        this.subscribeMe();\n    }\n\n    public notifyLineAuthoring(id: LineAuthoringId, la: LineAuthoring) {\n        if (this.view === undefined) {\n            console.warn(\n                `Git: View is not defined for editor cache key. Unforeseen situation. id: ${id}`\n            );\n            return;\n        }\n\n        // using \"this.state\" directly here leads to some problems when closing panes. Hence, \"this.view.state\"\n        const state = this.view.state;\n        const transaction = newComputationResultAsTransaction(id, la, state);\n        this.view.dispatch(transaction);\n    }\n\n    public notifyGitCompare(data: GitCompareResult) {\n        if (this.view === undefined) {\n            console.warn(\n                `Git: View is not defined for editor cache key. Unforeseen situation. id: `\n            );\n            //TODO removed it above in the error message\n            return;\n        }\n\n        // Prevent updates to stale subscribers\n        if (this.removeIfStale()) {\n            return;\n        }\n\n        // using \"this.state\" directly here leads to some problems when closing panes. Hence, \"this.view.state\"\n        const state = this.view.state;\n        const hunkState = state.field(hunksState);\n        if (\n            !hunkState ||\n            hunkState.compareText != data.compareText ||\n            hunkState.compareTextHead != data.compareTextHead\n        ) {\n            const transaction = newGitCompareResultAsTransaction(data, state);\n\n            this.view.dispatch(transaction);\n        }\n    }\n\n    public updateToNewState(state: EditorState) {\n        this.state = state;\n\n        // If no filepath was previously available subscribe now\n        if (!this.lastSeenPath && this.filepath) {\n            this.subscribeMe();\n            // the update of the view by starting a new computation is done by\n            // listening to rename events in the line authoring controller.\n        }\n\n        return this;\n    }\n\n    public removeIfStale(): boolean {\n        // If a new `subscribeNewEditor` field has been created, then this instance is stale.\n        // This happens when in the same leaf and `EditorView` a new file is opened\n        if (\n            this.view?.state.field(subscribeNewEditor, false) != this ||\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access\n            (this.view as any).destroyed\n        ) {\n            this.unsubscribeMe(this.lastSeenPath);\n            return true;\n        }\n        return false;\n    }\n\n    // When a file is renamed, the editor's filepath changes.\n    // So we resubscribe all editors to the new filepath.\n    public changeToNewFilepath(filepath: string) {\n        this.unsubscribeMe(this.lastSeenPath);\n        this.subscribeMe(filepath);\n        // the update of the view by starting a new computation is done by\n        // listening to rename events in the line authoring controller.\n    }\n\n    private subscribeMe(filepath?: string) {\n        filepath ??= this.filepath;\n        if (filepath === undefined) return; // happens on the very first editor after start.\n\n        eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers(\n            filepath,\n            (subs) => subs.add(this)\n        );\n        this.lastSeenPath = filepath;\n    }\n\n    private unsubscribeMe(oldFilepath: string) {\n        eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers(\n            oldFilepath,\n            (subs) => subs.delete(this)\n        );\n    }\n\n    private get filepath(): string | undefined {\n        return this.state.field(editorInfoField)?.file?.path;\n    }\n\n    private get view(): EditorView | undefined {\n        return this.state.field(editorEditorField);\n    }\n}\n\nexport type FileSubscribers = Set<FileSubscriber>;\n\n/**\n * The Codemirror {@link Extension} used to make each editor subscribe itself to this pub-sub.\n */\nexport const subscribeNewEditor: StateField<FileSubscriber> =\n    StateField.define<FileSubscriber>({\n        create: (state) => new FileSubscriber(state),\n        update: (v, transaction) => v.updateToNewState(transaction.state),\n        compare: (a, b) => a === b,\n    });\n"
  },
  {
    "path": "src/editor/editorIntegration.ts",
    "content": "import type ObsidianGit from \"src/main\";\nimport { LineAuthoringFeature } from \"./lineAuthor/lineAuthorIntegration\";\nimport { SignsFeature } from \"./signs/signsIntegration\";\nimport { subscribeNewEditor } from \"./control\";\n\nexport class EditorIntegration {\n    constructor(private plg: ObsidianGit) {}\n\n    lineAuthoringFeature: LineAuthoringFeature = new LineAuthoringFeature(\n        this.plg\n    );\n    signsFeature: SignsFeature = new SignsFeature(this.plg);\n\n    onUnloadPlugin() {\n        this.lineAuthoringFeature.deactivateFeature();\n        this.signsFeature.deactivateFeature();\n    }\n\n    onLoadPlugin() {\n        this.plg.registerEditorExtension(subscribeNewEditor);\n        this.lineAuthoringFeature.onLoadPlugin();\n        this.signsFeature.onLoadPlugin();\n    }\n\n    onReady() {\n        this.lineAuthoringFeature.conditionallyActivateBySettings();\n        this.signsFeature.conditionallyActivateBySettings();\n    }\n\n    activateLineAuthoring() {\n        this.lineAuthoringFeature.activateFeature();\n    }\n    deactiveLineAuthoring() {\n        this.lineAuthoringFeature.deactivateFeature();\n    }\n\n    refreshSignsSettings() {\n        const hunkSettings = this.plg.settings.hunks;\n        if (\n            hunkSettings.showSigns ||\n            hunkSettings.statusBar != \"disabled\" ||\n            hunkSettings.hunkCommands\n        ) {\n            this.signsFeature.deactivateFeature();\n            this.signsFeature.activateFeature();\n        } else {\n            this.signsFeature.deactivateFeature();\n        }\n    }\n}\n"
  },
  {
    "path": "src/editor/eventsPerFilepath.ts",
    "content": "import type { FileSubscriber, FileSubscribers } from \"./control\";\n\nconst SECONDS = 1000;\nconst REMOVE_STALES_FREQUENCY = 60 * SECONDS;\n\n/**\n * * stores the subscribers/editors interested in changed per filepath\n * * We need this pub-sub design, because a filepath may be opened in multiple editors\n *   and each editor should be updated asynchronously and independently.\n * * Subscribers can be cleared when the feature is deactivated\n */\nclass EventsPerFilePath {\n    private eventsPerFilepath: Map<string, FileSubscribers> = new Map();\n    private removeStalesSubscribersTimer: number;\n\n    constructor() {\n        this.startRemoveStalesSubscribersInterval();\n    }\n\n    /**\n     * Run the {@link handler} on the subscribers to {@link filepath}.\n     */\n    public ifFilepathDefinedTransformSubscribers<T>(\n        filepath: string | undefined,\n        handler: (lass: FileSubscribers) => T\n    ): T | undefined {\n        if (!filepath) return;\n\n        this.ensureInitialized(filepath);\n\n        return handler(this.eventsPerFilepath.get(filepath)!);\n    }\n\n    public forEachSubscriber(handler: (las: FileSubscriber) => void): void {\n        this.eventsPerFilepath.forEach((subs) => subs.forEach(handler));\n    }\n\n    private ensureInitialized(filepath: string) {\n        if (!this.eventsPerFilepath.get(filepath))\n            this.eventsPerFilepath.set(filepath, new Set());\n    }\n\n    private startRemoveStalesSubscribersInterval() {\n        this.removeStalesSubscribersTimer = window.setInterval(\n            () => this?.forEachSubscriber((las) => las?.removeIfStale()),\n            REMOVE_STALES_FREQUENCY\n        );\n    }\n\n    public clear() {\n        window.clearInterval(this.removeStalesSubscribersTimer);\n        this.eventsPerFilepath.clear();\n    }\n}\n\nexport const eventsPerFilePathSingleton = new EventsPerFilePath();\n"
  },
  {
    "path": "src/editor/lineAuthor/lineAuthorIntegration.ts",
    "content": "import type { Extension } from \"@codemirror/state\";\nimport type { EventRef, TAbstractFile, WorkspaceLeaf } from \"obsidian\";\nimport { MarkdownView, Platform, TFile } from \"obsidian\";\nimport { SimpleGit } from \"src/gitManager/simpleGit\";\nimport {\n    LineAuthorProvider,\n    enabledLineAuthorInfoExtensions,\n} from \"./lineAuthorProvider\";\nimport type { LineAuthorSettings } from \"./model\";\nimport { provideSettingsAccess } from \"./model\";\nimport { handleContextMenu } from \"./view/contextMenu\";\nimport { setTextColorCssBasedOnSetting } from \"./view/gutter/coloring\";\nimport { prepareGutterSearchForContextMenuHandling } from \"./view/gutter/gutterElementSearch\";\nimport type ObsidianGit from \"src/main\";\n\n/**\n * Manages the interaction between Obsidian (file-open event, modification event, etc.)\n * and the line authoring feature. It also manages the (de-) activation of the\n * line authoring functionality.\n */\nexport class LineAuthoringFeature {\n    private lineAuthorInfoProvider?: LineAuthorProvider;\n    private fileOpenEvent?: EventRef;\n    private workspaceLeafChangeEvent?: EventRef;\n    private fileModificationEvent?: EventRef;\n    private headChangeEvent?: EventRef;\n    private refreshOnCssChangeEvent?: EventRef;\n    private fileRenameEvent?: EventRef;\n    private gutterContextMenuEvent?: EventRef;\n    private codeMirrorExtensions: Extension[] = [];\n\n    constructor(private plg: ObsidianGit) {}\n\n    // ========================= INIT and DE-INIT ==========================\n\n    public onLoadPlugin() {\n        this.plg.registerEditorExtension(this.codeMirrorExtensions);\n        provideSettingsAccess(\n            () => this.plg.settings.lineAuthor,\n            (laSettings: LineAuthorSettings) => {\n                this.plg.settings.lineAuthor = laSettings;\n                void this.plg.saveSettings();\n            }\n        );\n    }\n\n    public conditionallyActivateBySettings() {\n        if (this.plg.settings.lineAuthor.show) {\n            this.activateFeature();\n        }\n    }\n\n    public activateFeature() {\n        try {\n            if (!this.isAvailableOnCurrentPlatform().available) return;\n\n            setTextColorCssBasedOnSetting(this.plg.settings.lineAuthor);\n\n            this.lineAuthorInfoProvider = new LineAuthorProvider(this.plg);\n\n            this.createEventHandlers();\n\n            this.activateCodeMirrorExtensions();\n\n            console.log(this.plg.manifest.name + \": Enabled line authoring.\");\n        } catch (e) {\n            console.warn(\"Git: Error while loading line authoring feature.\", e);\n            this.deactivateFeature();\n        }\n    }\n\n    /**\n     * Deactivates the feature. This function is very defensive, as it is also\n     * called to cleanup, if a critical error in the line authoring has occurred.\n     */\n    public deactivateFeature() {\n        this.destroyEventHandlers();\n\n        this.deactivateCodeMirrorExtensions();\n\n        this.lineAuthorInfoProvider?.destroy();\n        this.lineAuthorInfoProvider = undefined;\n\n        console.log(this.plg.manifest.name + \": Disabled line authoring.\");\n    }\n\n    public isAvailableOnCurrentPlatform(): {\n        available: boolean;\n        gitManager: SimpleGit;\n    } {\n        return {\n            available: this.plg.useSimpleGit && Platform.isDesktopApp,\n            gitManager:\n                this.plg.gitManager instanceof SimpleGit\n                    ? this.plg.gitManager\n                    : undefined!,\n        };\n    }\n\n    // ========================= REFRESH ==========================\n\n    public refreshLineAuthorViews() {\n        if (this.plg.settings.lineAuthor.show) {\n            this.deactivateFeature();\n            this.activateFeature();\n        }\n    }\n\n    // ========================= CODEMIRROR EXTENSIONS ==========================\n\n    private activateCodeMirrorExtensions() {\n        // Yes, we need to directly modify the array and notify the change to have\n        // toggleable Codemirror extensions.\n        this.codeMirrorExtensions.push(enabledLineAuthorInfoExtensions);\n        this.plg.app.workspace.updateOptions();\n\n        // Handle all already opened files\n        this.plg.app.workspace.iterateAllLeaves(this.handleWorkspaceLeaf);\n    }\n\n    private deactivateCodeMirrorExtensions() {\n        // Yes, we need to directly modify the array and notify the change to have\n        // toggleable Codemirror extensions.\n        for (const ext of this.codeMirrorExtensions) {\n            this.codeMirrorExtensions.remove(ext);\n        }\n        this.plg.app.workspace.updateOptions();\n    }\n\n    // ========================= HANDLERS ==========================\n\n    private createEventHandlers() {\n        this.gutterContextMenuEvent = this.createGutterContextMenuHandler();\n        this.fileOpenEvent = this.createFileOpenEvent();\n        this.workspaceLeafChangeEvent = this.createWorkspaceLeafChangeEvent();\n        this.fileModificationEvent = this.createVaultFileModificationHandler();\n        this.headChangeEvent = this.createHeadChangeEvent();\n        this.refreshOnCssChangeEvent = this.createCssRefreshHandler();\n        this.fileRenameEvent = this.createFileRenameEvent();\n\n        prepareGutterSearchForContextMenuHandling();\n\n        this.plg.registerEvent(this.gutterContextMenuEvent);\n        this.plg.registerEvent(this.refreshOnCssChangeEvent);\n        this.plg.registerEvent(this.fileOpenEvent);\n        this.plg.registerEvent(this.workspaceLeafChangeEvent);\n        this.plg.registerEvent(this.fileModificationEvent);\n        this.plg.registerEvent(this.headChangeEvent);\n        this.plg.registerEvent(this.fileRenameEvent);\n    }\n\n    private destroyEventHandlers() {\n        this.plg.app.workspace.offref(this.gutterContextMenuEvent!);\n        this.plg.app.workspace.offref(this.refreshOnCssChangeEvent!);\n        this.plg.app.workspace.offref(this.fileOpenEvent!);\n        this.plg.app.workspace.offref(this.workspaceLeafChangeEvent!);\n        this.plg.app.workspace.offref(this.refreshOnCssChangeEvent!);\n        this.plg.app.vault.offref(this.fileModificationEvent!);\n        this.plg.app.workspace.offref(this.headChangeEvent!);\n        this.plg.app.vault.offref(this.fileRenameEvent!);\n    }\n\n    private handleWorkspaceLeaf = (leaf: WorkspaceLeaf) => {\n        if (!this.lineAuthorInfoProvider) {\n            console.warn(\n                \"Git: undefined lineAuthorInfoProvider. Unexpected situation.\"\n            );\n            return;\n        }\n        const obsView = leaf?.view;\n\n        if (\n            !(obsView instanceof MarkdownView) ||\n            obsView.file == null ||\n            obsView?.allowNoFile === true\n        )\n            return;\n\n        this.lineAuthorInfoProvider\n            .trackChanged(obsView.file)\n            .catch(console.error);\n    };\n\n    private createFileOpenEvent(): EventRef {\n        return this.plg.app.workspace.on(\n            \"file-open\",\n            (file: TFile) =>\n                void this.lineAuthorInfoProvider\n                    ?.trackChanged(file)\n                    .catch(console.error)\n        );\n    }\n\n    private createWorkspaceLeafChangeEvent(): EventRef {\n        return this.plg.app.workspace.on(\n            \"active-leaf-change\",\n            this.handleWorkspaceLeaf\n        );\n    }\n\n    private createFileRenameEvent(): EventRef {\n        return this.plg.app.vault.on(\n            \"rename\",\n            (file, _old) =>\n                file instanceof TFile &&\n                this.lineAuthorInfoProvider?.trackChanged(file)\n        );\n    }\n\n    private createVaultFileModificationHandler() {\n        return this.plg.app.vault.on(\n            \"modify\",\n            (anyPath: TAbstractFile) =>\n                anyPath instanceof TFile &&\n                this.lineAuthorInfoProvider?.trackChanged(anyPath)\n        );\n    }\n\n    private createHeadChangeEvent(): EventRef {\n        return this.plg.app.workspace.on(\"obsidian-git:head-change\", () => {\n            this.refreshLineAuthorViews();\n        });\n    }\n\n    private createCssRefreshHandler(): EventRef {\n        return this.plg.app.workspace.on(\"css-change\", () =>\n            this.refreshLineAuthorViews()\n        );\n    }\n\n    private createGutterContextMenuHandler() {\n        return this.plg.app.workspace.on(\"editor-menu\", handleContextMenu);\n    }\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/lineAuthorProvider.ts",
    "content": "import type { Extension } from \"@codemirror/state\";\nimport { Prec } from \"@codemirror/state\";\nimport type { TFile } from \"obsidian\";\nimport { eventsPerFilePathSingleton } from \"src/editor/eventsPerFilepath\";\nimport type {\n    LineAuthoring,\n    LineAuthoringId,\n} from \"src/editor/lineAuthor/model\";\nimport { lineAuthorState, lineAuthoringId } from \"src/editor/lineAuthor/model\";\nimport { clearViewCache } from \"src/editor/lineAuthor/view/cache\";\nimport { lineAuthorGutter } from \"src/editor/lineAuthor/view/view\";\nimport type ObsidianGit from \"src/main\";\n\nexport { previewColor } from \"src/editor/lineAuthor/view/gutter/coloring\";\n/**\n * * handles changes in git head, filesystem, etc. by initiating computation\n * * Initiates the line authoring computation via\n * <a href=\"https://git-scm.com/docs/git-blame\">git-blame</a>\n * * notifies computation results and settings to subscribers (editors)\n * * deytroys cache and editor-subscribers when plugin is deactivated\n */\nexport class LineAuthorProvider {\n    /**\n     * Saves all computed line authoring results.\n     *\n     * See {@link LineAuthoringId}\n     */\n    private lineAuthorings: Map<LineAuthoringId, LineAuthoring> = new Map();\n\n    constructor(private plugin: ObsidianGit) {}\n\n    public async trackChanged(file: TFile) {\n        return this.trackChangedHelper(file).catch((reason) => {\n            console.warn(\"Git: Error in trackChanged.\" + reason);\n            // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors\n            return Promise.reject(reason);\n        });\n    }\n\n    private async trackChangedHelper(file: TFile) {\n        if (!file) return;\n\n        if (file.path === undefined) {\n            console.warn(\n                \"Git: Attempted to track change of undefined filepath. Unforeseen situation.\"\n            );\n            return;\n        }\n\n        return this.computeLineAuthorInfo(file.path);\n    }\n\n    public destroy() {\n        this.lineAuthorings.clear();\n        clearViewCache();\n    }\n\n    private async computeLineAuthorInfo(filepath: string) {\n        const gitManager =\n            this.plugin.editorIntegration.lineAuthoringFeature.isAvailableOnCurrentPlatform()\n                .gitManager;\n\n        const headRevision =\n            await gitManager.submoduleAwareHeadRevisonInContainingDirectory(\n                filepath\n            );\n\n        const fileHash = await gitManager.hashObject(filepath);\n\n        const key = lineAuthoringId(headRevision, fileHash, filepath);\n\n        if (key === undefined) {\n            return;\n        }\n\n        if (this.lineAuthorings.has(key)) {\n            // already computed. just tell the editor to update to the key's state\n        } else {\n            const gitAuthorResult = await gitManager.blame(\n                filepath,\n                this.plugin.settings.lineAuthor.followMovement,\n                this.plugin.settings.lineAuthor.ignoreWhitespace\n            );\n            this.lineAuthorings.set(key, gitAuthorResult);\n        }\n\n        this.notifyComputationResultToSubscribers(filepath, key);\n    }\n\n    private notifyComputationResultToSubscribers(\n        filepath: string,\n        key: string\n    ) {\n        eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers(\n            filepath,\n            (subs) =>\n                subs.forEach((sub) =>\n                    sub.notifyLineAuthoring(key, this.lineAuthorings.get(key)!)\n                )\n        );\n    }\n}\n\n// =========================================================\n\nexport const enabledLineAuthorInfoExtensions: Extension = Prec.high([\n    lineAuthorState,\n    lineAuthorGutter,\n]);\n"
  },
  {
    "path": "src/editor/lineAuthor/model.ts",
    "content": "import type { EditorState, Transaction } from \"@codemirror/state\";\nimport { StateEffect, StateField } from \"@codemirror/state\";\nimport type { Hasher } from \"js-sha256\";\nimport { sha256 } from \"js-sha256\";\nimport type { RGB } from \"obsidian\";\nimport { DEFAULT_SETTINGS } from \"src/constants\";\nimport { parseColoringMaxAgeDuration } from \"src/setting/settings\";\nimport type { Blame } from \"src/types\";\n\n/*\n================== MODEL ======================\nContains types and variables describing the essential\ncontents of the line authoring and further types.\n*/\n\n// use a more neutral word for this functionality\nexport type LineAuthoring = Blame | \"untracked\";\n\n/**\n * An identifier for each line authoring.\n *\n * For every {@link LineAuthoring} there is exactly one valid corresponding {@link LineAuthoringId}\n * This is used to disambiguate differing line authoring content.\n *\n * For instance, when there are UI-changes in an editor, we want to quickly figure out\n * whether the LineAuthoring has changed as well. Computing this cache-identifiying key/id\n * allows us to quickly find that out and avoid re-computing the result, if the corresponding\n * line authoring already has been computed. This is used in lineAuthorInfoProvider.ts.\n *\n * This implementation assumes, that each {@link LineAuthoring} is unique, given\n * the HEAD revision of the git repository, the hash of the current contents of the file and\n * the path to the file within the vault. The exact syntax is determined by {@link lineAuthoringId}.\n *\n * * HEAD: This ensures, that adding a new commit or changing the checked out revision\n *      forces re-computation.\n *   * We always want to use the submodule which contains the file rather than any super-project,\n *     as the file is only committed in lowest level submodule - and only it's HEAD revision\n *     will be updated during a commit.\n * * contents-hash: Whenever the contents of the file produce a different hash,\n *      the view needs updating - hence the re-computation.\n * * the path of the file in git matters as well, as the exact file content at different\n *      paths in a git repository can have different histories\n */\nexport type LineAuthoringId = string;\n\nexport function lineAuthoringId(\n    head: string,\n    objHash: string,\n    path: string\n): string | undefined {\n    if (head === undefined || objHash === undefined || path === undefined) {\n        return undefined;\n    }\n    return `head${head}-obj${objHash}-path${path}`;\n}\n\n// =================== LineAuthoring inside a Codemirror Transaction =====================\n\nexport type LineAuthoringWithChanges = {\n    la: LineAuthoring;\n    key: LineAuthoringId;\n    /**\n     * See {@link enrichUnsavedChanges}\n     */\n    lineOffsetsFromUnsavedChanges: Map<number, number>;\n};\n\n/**\n * The {@link StateEffect} used in Codemirror {@link Transaction}s to\n * update the {@link EditorState} with the {@link LineAuthoring}, that should be displayed.\n *\n * See users of {@link newComputationResultAsTransaction} for the value providers.\n * The {@link StateField} {@link lineAuthorState} hold the value of this transaction.\n */\nconst LineAuthoringContainerType =\n    StateEffect.define<LineAuthoringWithChanges>();\n\nexport function newComputationResultAsTransaction(\n    key: LineAuthoringId,\n    la: LineAuthoring,\n    state: EditorState\n): Transaction {\n    return state.update({\n        effects: LineAuthoringContainerType.of({\n            key,\n            la,\n            lineOffsetsFromUnsavedChanges: new Map(),\n        }),\n    });\n}\n\n// ================ Codemirror StateField containing the current Line Authoring ===================\n\n/**\n * The Codemirror {@link StateField} which contains the current {@link LineAuthoring}\n * that is being shown.\n *\n * The update method extracts the value from the StateEffect, if one is provided.\n *\n * Strictly speaking, if the StateEffect of a previous and outdated computation\n * appears after a new a recent one, it might happen, that the old and stale one\n * will be shown instead. This is because we only ever show the StateEffect which\n * was most recently in a transaction - and we do not track any time here.\n *\n * When caching this, please use {@link laStateDigest} to compute the key.\n */\nexport const lineAuthorState: StateField<LineAuthoringWithChanges | undefined> =\n    StateField.define<LineAuthoringWithChanges | undefined>({\n        create: (_state) => undefined,\n        /**\n         * The state can be updated from either an annotated transaction containing\n         * the newest line authoring (for the saved document) - or from\n         * unsaved changes of the document as the user is actively typing in the editor.\n         *\n         * In the first case, we take the new line authoring and discard anything we had remembered\n         * from unsaved changes. In the second case, we use the unsaved changes in {@link enrichUnsavedChanges} to pre-compute information to immediately update the\n         * line author gutter without needing to wait until the document is saved and the\n         * line authoring is properly computed.\n         */\n        update: (previous, transaction) => {\n            for (const effect of transaction.effects) {\n                if (effect.is(LineAuthoringContainerType)) {\n                    return effect.value;\n                }\n            }\n            return enrichUnsavedChanges(transaction, previous);\n        },\n        // compare cache keys.\n        // equality rate is >= 95% :)\n        // hence avoids recomputation of views\n        compare: (l, r) => l?.key === r?.key,\n    });\n\nexport function laStateDigest(\n    laState: LineAuthoringWithChanges | undefined\n): Hasher {\n    const digest = sha256.create();\n    if (!laState) return digest;\n\n    const { la, key, lineOffsetsFromUnsavedChanges } = laState;\n    digest.update(la === \"untracked\" ? \"t\" : \"f\");\n    digest.update(key);\n    for (const [k, v] of lineOffsetsFromUnsavedChanges.entries() ?? [])\n        digest.update([k, v]);\n    return digest;\n}\n\n// =============== Line Authoring Settings =================\n\nexport type LineAuthorSettings = {\n    show: boolean;\n    showCommitHash: boolean;\n    followMovement: LineAuthorFollowMovement;\n    authorDisplay: LineAuthorDisplay;\n    lastShownAuthorDisplay?: LineAuthorDisplay;\n    dateTimeFormatOptions: LineAuthorDateTimeFormatOptions;\n    lastShownDateTimeFormatOptions?: LineAuthorDateTimeFormatOptions;\n    dateTimeFormatCustomString: string;\n    dateTimeTimezone: LineAuthorTimezoneOption;\n    coloringMaxAge: string;\n    colorOld: RGB;\n    colorNew: RGB;\n    textColorCss: string;\n    ignoreWhitespace: boolean;\n    gutterSpacingFallbackLength: number;\n};\n\nexport type LineAuthorFollowMovement =\n    | \"inactive\"\n    | \"same-commit\"\n    | \"all-commits\";\n\nexport type LineAuthorDisplay =\n    | \"hide\"\n    | \"full\"\n    | \"first name\"\n    | \"last name\"\n    | \"initials\";\n\nexport type LineAuthorDateTimeFormatOptions =\n    | \"hide\"\n    | \"date\"\n    | \"datetime\"\n    | \"natural language\"\n    | \"custom\";\n\nexport type LineAuthorTimezoneOption =\n    | \"viewer-local\"\n    | \"author-local\"\n    | \"utc0000\";\n\n// ===============================================================\n\n/**\n * Global mutable container to get access to the latest Obsidian settings.\n *\n * This is stored here globally and populated during line author feature loading\n * via {@link provideSettingsAccess}.\n *\n * It is used to provide the editors with the recent settings when created, as this allows\n * us to create the correct spacing up-front as well as have the latest settings\n * when rendering.\n */\nexport const latestSettings = {\n    get: undefined! as () => LineAuthorSettings,\n    save: undefined! as (settings: LineAuthorSettings) => void,\n};\n\nexport function provideSettingsAccess(\n    settingsGetter: () => LineAuthorSettings,\n    settingsSetter: (settings: LineAuthorSettings) => void\n) {\n    latestSettings.get = settingsGetter;\n    latestSettings.save = settingsSetter;\n}\n\nexport function maxAgeInDaysFromSettings(settings: LineAuthorSettings) {\n    return (\n        parseColoringMaxAgeDuration(settings.coloringMaxAge)?.asDays() ??\n        parseColoringMaxAgeDuration(\n            DEFAULT_SETTINGS.lineAuthor.coloringMaxAge\n        )!.asDays()\n    );\n}\n\n/**\n * Given a transaction containing editor changes and the previous line author state,\n * we want to update the `lineOffsetsFromUnsavedChanges` in {@link LineAuthoringWithChanges}.\n *\n * This property contains for each line `ln` in the new document the following:\n * * if the line has not been changed, then it is not contained and `<map>.get(ln)` is undefined.\n * * if the line has been changed and its ChangeSet does not change the number of lines,\n *   then `<map>.get(ln)` is 0.\n * * if the line has been changed and its ChangeSet indicates that the number of lines has changed\n *   (e.g. removed or added lines), then all but the last lines in the ChangeSet will have\n *   `<map>.get(ln)=0` and the last line will have `<map>.get(ln)=n` where `n` is the number\n *   of added lines. If `n` is negative, then lines have been removed instead.\n */\nfunction enrichUnsavedChanges(\n    tr: Transaction,\n    prev: LineAuthoringWithChanges | undefined\n): LineAuthoringWithChanges | undefined {\n    if (!prev) return undefined;\n\n    if (!tr.changes.empty) {\n        tr.changes.iterChanges((fromA, toA, fromB, toB) => {\n            const oldDoc = tr.startState.doc;\n            const { newDoc } = tr;\n\n            const beforeFrom = oldDoc.lineAt(fromA).number;\n            const beforeTo = oldDoc.lineAt(toA).number;\n\n            const afterFrom = newDoc.lineAt(fromB).number;\n            const afterTo = newDoc.lineAt(toB).number;\n\n            const beforeLen = beforeTo - beforeFrom + 1;\n            const afterLen = afterTo - afterFrom + 1;\n\n            /*\n            Current change:\n            The lines beforeFrom..beforeTo (containing beforeLen lines) in the old doc\n            have been replaced by\n            the lines afterFrom..afterTo (containing afterLen lines) in the new doc.\n            */\n\n            // The lines afterFrom..afterTo for which we want to\n            // set an offset in lineOffsetsFromUnsavedChanges.\n            for (let afterI = afterFrom; afterI <= afterTo; afterI++) {\n                // Multiple changes can be made from the current transaction\n                // as well as from previous transactions since the last document save.\n                // Hence, we want to cumulate all offsets.\n                let offset =\n                    prev.lineOffsetsFromUnsavedChanges.get(afterI) ?? 0;\n\n                const isLastLine = afterTo === afterI;\n\n                // positive = added lines, negative = removed lines.\n                const changeInNumberOfLines = afterLen - beforeLen;\n                if (isLastLine) offset += changeInNumberOfLines;\n\n                prev.lineOffsetsFromUnsavedChanges.set(afterI, offset);\n            }\n        });\n    }\n\n    return prev;\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/view/cache.ts",
    "content": "import type { RangeSet } from \"@codemirror/state\";\nimport type { GutterMarker } from \"@codemirror/view\";\nimport { latestSettings } from \"src/editor/lineAuthor/model\";\nimport type { LineAuthoringGutter } from \"src/editor/lineAuthor/view/gutter/gutter\";\nimport { median } from \"src/utils\";\n\n/*\nVIEW-CACHE\nThis file contains temporarily cached information used in the view.\nThey make it also possible to have unintrusive and soft UI updates, when\nthe git line author information appears delayed.\nThe caches here are evicted whenever the line author feature is disabled/refreshed.\n*/\n\n/**\n * Clears the cache. This should be called whenever the settings are changed.\n *\n * Currently, the entire feature is re-loaded, which is why it suffices this to be called\n * in the disabler in `lineAuthorIntegration.ts`.\n */\nexport function clearViewCache() {\n    longestRenderedGutter = undefined;\n\n    renderedAgeInDaysForAdaptiveInitialColoring = [];\n    ageIdx = 0;\n\n    gutterInstances.clear();\n    gutterMarkersRangeSet.clear();\n\n    attachedGutterElements.clear();\n}\n\n/**\n * A cache containing the last maximally-sized encountered gutter together with its length and text.\n *\n * Whenever a longer gutter is encountered, it is saved via {@link conditionallyUpdateLongestRenderedGutter}.\n */\ntype LongestGutterCache = {\n    gutter: LineAuthoringGutter;\n    length: number;\n    text: string;\n};\nlet longestRenderedGutter: LongestGutterCache | undefined = undefined;\n\nexport const getLongestRenderedGutter = () => longestRenderedGutter;\n\n/**\n * Given a newly rendered gutter, update the {@link longestRenderedGutter} by comparing the\n * text lengths.\n *\n * If bigger, then update the global variable and persist the settings via {@link latestSettings.save}\n */\nexport function conditionallyUpdateLongestRenderedGutter(\n    gutter: LineAuthoringGutter,\n    text: string\n) {\n    const length = text.length;\n\n    if (length < (longestRenderedGutter?.length ?? 0)) return;\n\n    longestRenderedGutter = { gutter, length, text };\n\n    const settings = latestSettings.get();\n    if (length !== settings.gutterSpacingFallbackLength) {\n        settings.gutterSpacingFallbackLength = length;\n        latestSettings.save(settings);\n    }\n}\n\n/**\n * When a new file is opened, we need to already render the line gutter even before we\n * know the true git line authoring - and their true colors.\n *\n * Simply rendering them with the background color initially is not good, as the\n * UI update, when the result is available, is distracting and flickering.\n *\n * Hence, we adapt the initial color shown when opening and switching panes.\n *\n * The initial color is computed from the distribution of ages of each line commit\n * (in days). Currently, we use {@link ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE}`=50`\n * elements and the `median` to compute the color.\n */\nlet renderedAgeInDaysForAdaptiveInitialColoring: number[] = [];\n\nconst ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE = 15;\n\nlet ageIdx = 0;\nexport function recordRenderedAgeInDays(age: number) {\n    renderedAgeInDaysForAdaptiveInitialColoring[ageIdx] = age;\n    ageIdx = (ageIdx + 1) % ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE;\n}\n\nexport function computeAdaptiveInitialColoringAgeInDays(): number | undefined {\n    return median(renderedAgeInDaysForAdaptiveInitialColoring);\n}\n\n/**\n * Caches the {@link LineAuthoringGutter} instances created in `gutter.ts`.\n */\nexport const gutterInstances: Map<string, LineAuthoringGutter> = new Map();\n\n/**\n * Caches the computation of {@link computeLineAuthoringGutterMarkersRangeSet}.\n *\n * Despite the computation of the document digest and line-blocks, the performance\n * was measured to be faster with the caching.\n */\nexport const gutterMarkersRangeSet: Map<\n    string,\n    RangeSet<GutterMarker>\n> = new Map();\n\n/**\n * Stores all DOM-attached gutter elements so that they can be checked for being\n * under the mouse during a gutter context-menu event;\n */\nexport const attachedGutterElements: Set<HTMLElement> = new Set();\n"
  },
  {
    "path": "src/editor/lineAuthor/view/contextMenu.ts",
    "content": "import type { Editor, MarkdownView, Menu } from \"obsidian\";\nimport { DEFAULT_SETTINGS } from \"src/constants\";\nimport type { LineAuthorSettings } from \"src/editor/lineAuthor/model\";\nimport { findGutterElementUnderMouse } from \"src/editor/lineAuthor/view/gutter/gutterElementSearch\";\nimport { pluginRef } from \"src/pluginGlobalRef\";\nimport type { BlameCommit } from \"src/types\";\nimport { impossibleBranch } from \"src/utils\";\n\ntype ContextMenuConfigurableSettingsKeys =\n    | \"showCommitHash\"\n    | \"authorDisplay\"\n    | \"dateTimeFormatOptions\";\n\ntype CtxMenuCommitInfo = Pick<BlameCommit, \"hash\" | \"isZeroCommit\"> & {\n    isWaitingGutter: boolean;\n};\nconst COMMIT_ATTR = \"data-commit\";\n\nexport function handleContextMenu(\n    menu: Menu,\n    editor: Editor,\n    _mdv: MarkdownView\n) {\n    // Click was inside text-editor with active cursor. Don't trigger there.\n    if (editor.hasFocus()) return;\n\n    const gutterElement = findGutterElementUnderMouse();\n    if (!gutterElement) return;\n\n    const info = getCommitInfo(gutterElement);\n    if (!info) return;\n\n    // Zero-commit and waiting-for-result must not be copied\n    if (!info.isZeroCommit && !info.isWaitingGutter) {\n        addCopyHashMenuItem(info, menu);\n    }\n\n    addConfigurableLineAuthorSettings(\"showCommitHash\", menu);\n    addConfigurableLineAuthorSettings(\"authorDisplay\", menu);\n    addConfigurableLineAuthorSettings(\"dateTimeFormatOptions\", menu);\n}\n\nfunction addCopyHashMenuItem(commit: CtxMenuCommitInfo, menu: Menu) {\n    menu.addItem((item) =>\n        item\n            .setTitle(\"Copy commit hash\")\n            .setIcon(\"copy\")\n            .setSection(\"obs-git-line-author-copy\")\n            .onClick((_e) => navigator.clipboard.writeText(commit.hash))\n    );\n}\n\nfunction addConfigurableLineAuthorSettings(\n    key: ContextMenuConfigurableSettingsKeys,\n    menu: Menu\n) {\n    let title: string;\n    let actionNewValue: LineAuthorSettings[typeof key];\n\n    const settings = pluginRef.plugin!.settings.lineAuthor;\n    const currentValue = settings[key];\n    const currentlyShown =\n        typeof currentValue === \"boolean\"\n            ? currentValue\n            : currentValue !== \"hide\";\n\n    const defaultValue = DEFAULT_SETTINGS.lineAuthor[key];\n\n    if (key === \"showCommitHash\") {\n        title = \"Show commit hash\";\n        actionNewValue = <LineAuthorSettings[\"showCommitHash\"]>currentValue;\n    } else if (key === \"authorDisplay\") {\n        const showOption = settings.lastShownAuthorDisplay ?? defaultValue;\n        title = \"Show author \" + (currentlyShown ? currentValue : showOption);\n        actionNewValue = currentlyShown ? \"hide\" : showOption;\n    } else if (key === \"dateTimeFormatOptions\") {\n        const showOption =\n            settings.lastShownDateTimeFormatOptions ?? defaultValue;\n        title = \"Show \" + (currentlyShown ? currentValue : showOption);\n        title += !title.contains(\"date\") ? \" date\" : \"\";\n        actionNewValue = currentlyShown ? \"hide\" : showOption;\n    } else {\n        impossibleBranch(key);\n    }\n\n    menu.addItem((item) =>\n        item\n            .setTitle(title)\n            .setSection(\"obs-git-line-author-configure\") // group settings together\n            .setChecked(currentlyShown)\n            .onClick((_e) =>\n                pluginRef.plugin?.settingsTab?.lineAuthorSettingHandler(\n                    key,\n                    actionNewValue\n                )\n            )\n    );\n}\n\nexport function enrichCommitInfoForContextMenu(\n    commit: BlameCommit,\n    isWaitingGutter: boolean,\n    elt: HTMLElement\n) {\n    elt.setAttr(\n        COMMIT_ATTR,\n        JSON.stringify(<CtxMenuCommitInfo>{\n            hash: commit.hash,\n            isZeroCommit: commit.isZeroCommit,\n            isWaitingGutter,\n        })\n    );\n}\n\nfunction getCommitInfo(elt: HTMLElement): CtxMenuCommitInfo | undefined {\n    const commitInfoStr = elt.getAttr(COMMIT_ATTR);\n    return commitInfoStr\n        ? (JSON.parse(commitInfoStr) as CtxMenuCommitInfo)\n        : undefined;\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/view/gutter/coloring.ts",
    "content": "import type { App } from \"obsidian\";\nimport type { LineAuthorSettings } from \"src/editor/lineAuthor/model\";\nimport { maxAgeInDaysFromSettings } from \"src/editor/lineAuthor/model\";\nimport type { GitTimestamp } from \"src/types\";\n\n/**\n * Given the settings, it computes the background gutter color for the\n * oldest and newest commit.\n */\nexport function previewColor(\n    which: \"oldest\" | \"newest\",\n    settings: LineAuthorSettings\n) {\n    return which === \"oldest\"\n        ? coloringBasedOnCommitAge(0 /* epoch time: 1970 */, false, settings)\n              .color\n        : coloringBasedOnCommitAge(undefined, true, settings).color;\n}\n\n/**\n * Computes the `rgba(...)` color string describing the background color\n * for a commit timestamp {@link GitTimestamp} and the settings.\n *\n * It first computes the age x (from 0 to 1) of the commit where\n * 0 means now and 1 means maximum age (settings) or older.\n *\n * The zero commit gets the age 0.\n *\n * The coloring is then linearly interpolated between the two colors provided in the settings.\n *\n * Additional minor adjustments were made for dark/light mode, transparency, scaling\n * and also using more red-like colors near the newer ages.\n */\nexport function coloringBasedOnCommitAge(\n    commitAuthorEpochSeonds: GitTimestamp[\"epochSeconds\"] | undefined,\n    isZeroCommit: boolean,\n    settings: LineAuthorSettings\n): { color: string; daysSinceCommit: number } {\n    const maxAgeInDays = maxAgeInDaysFromSettings(settings);\n\n    const epochSecondsNow = Date.now() / 1000;\n    const authoringEpochSeconds = commitAuthorEpochSeonds ?? 0;\n\n    const secondsSinceCommit = isZeroCommit\n        ? 0\n        : epochSecondsNow - authoringEpochSeconds;\n\n    const daysSinceCommit = secondsSinceCommit / 60 / 60 / 24;\n\n    // 0 <= x <= 1, larger means older\n    // use n-th-root to make recent changes more prnounced\n    const x = Math.pow(\n        Math.clamp(daysSinceCommit / maxAgeInDays, 0, 1),\n        1 / 2.3\n    );\n\n    const dark = isDarkMode();\n\n    const color0 = settings.colorNew;\n    const color1 = settings.colorOld;\n\n    const scaling = dark ? 0.4 : 1;\n    const r = lin(color0.r, color1.r, x) * scaling;\n    const g = lin(color0.g, color1.g, x) * scaling;\n    const b = lin(color0.b, color1.b, x) * scaling;\n    const a = dark ? 0.75 : 0.25;\n\n    return { color: `rgba(${r},${g},${b},${a})`, daysSinceCommit };\n}\n\nfunction lin(z0: number, z1: number, x: number): number {\n    return z0 + (z1 - z0) * x;\n}\n\nfunction isDarkMode() {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access\n    return ((window as any).app as App)?.getTheme() === \"obsidian\"; // light mode is \"moonstone\"\n}\n\n/**\n * Set the CSS variable `--obs-git-gutter-text` based on the configured\n * value in the line author settings. This is necessary for proper text coloring.\n */\nexport function setTextColorCssBasedOnSetting(settings: LineAuthorSettings) {\n    document.body.style.setProperty(\n        \"--obs-git-gutter-text\",\n        settings.textColorCss\n    );\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/view/gutter/commitChoice.ts",
    "content": "import type { LineAuthoring } from \"src/editor/lineAuthor/model\";\nimport type { BlameCommit } from \"src/types\";\n\n/**\n * Chooses the newest commit from the {@link LineAuthoring} for the\n * lines {@link startLine} to {@link endLine} (inclusive).\n */\nexport function chooseNewestCommit(\n    lineAuthoring: Exclude<LineAuthoring, \"untracked\">,\n    startLine: number,\n    endLine: number\n): BlameCommit {\n    let newest: BlameCommit = undefined!;\n\n    for (let line = startLine; line <= endLine; line++) {\n        const currentHash = lineAuthoring.hashPerLine[line];\n        const currentCommit = lineAuthoring.commits.get(currentHash)!;\n\n        if (\n            !newest ||\n            currentCommit.isZeroCommit ||\n            isNewerThan(currentCommit, newest)\n        ) {\n            newest = currentCommit;\n        }\n    }\n\n    return newest;\n}\n\nfunction isNewerThan(left: BlameCommit, right: BlameCommit): boolean {\n    const l = left.author?.epochSeconds ?? 0;\n    const r = right.author?.epochSeconds ?? 0;\n    return l > r;\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/view/gutter/gutter.ts",
    "content": "import { GutterMarker } from \"@codemirror/view\";\nimport { sha256 } from \"js-sha256\";\nimport { moment, setTooltip } from \"obsidian\";\nimport { DATE_FORMAT, DATE_TIME_FORMAT_MINUTES } from \"src/constants\";\nimport type {\n    LineAuthorDateTimeFormatOptions,\n    LineAuthorDisplay,\n    LineAuthorSettings,\n    LineAuthorTimezoneOption,\n    LineAuthoring,\n} from \"src/editor/lineAuthor/model\";\nimport { latestSettings } from \"src/editor/lineAuthor/model\";\nimport {\n    attachedGutterElements,\n    conditionallyUpdateLongestRenderedGutter,\n    getLongestRenderedGutter,\n    gutterInstances,\n    recordRenderedAgeInDays,\n} from \"src/editor/lineAuthor/view/cache\";\nimport { enrichCommitInfoForContextMenu } from \"src/editor/lineAuthor/view/contextMenu\";\nimport { coloringBasedOnCommitAge } from \"src/editor/lineAuthor/view/gutter/coloring\";\nimport { chooseNewestCommit } from \"src/editor/lineAuthor/view/gutter/commitChoice\";\nimport type { BlameCommit } from \"src/types\";\nimport {\n    impossibleBranch,\n    prefixOfLengthAsWhitespace,\n    resizeToLength,\n    strictDeepEqual,\n} from \"src/utils\";\n\nconst VALUE_NOT_FOUND_FALLBACK = \"-\";\n\nconst NEW_CHANGE_CHARACTER = \"+\";\nconst NEW_CHANGE_NUMBER_OF_CHARACTERS = 3;\n\nconst DIFFERING_AUTHOR_COMMITTER_MARKER = \"*\";\n\nconst NON_WHITESPACE_REGEXP = /\\S/g;\nconst UNINTRUSIVE_CHARACTER_FOR_WAITING_RENDERING = \"%\";\n\n/**\n * A simple text gutter used to hold space until the real results are available.\n */\nexport class TextGutter extends GutterMarker {\n    constructor(public text: string) {\n        super();\n    }\n\n    eq(other: TextGutter): boolean {\n        return other instanceof TextGutter && this.text === other.text;\n    }\n\n    toDOM() {\n        return document.createTextNode(this.text);\n    }\n\n    destroy(dom: HTMLElement): void {\n        if (!dom) {\n            return; // sometimes, it doesn't exist anymore.\n        }\n    }\n}\n\n/**\n * Renders the given {@link LineAuthoring} for the lines {@link startLine}\n * to {@link endLine}.\n */\nexport class LineAuthoringGutter extends GutterMarker {\n    private precomputedDomProvider?: () => HTMLElement;\n    public readonly point = false;\n    public readonly elementClass = \"obs-git-blame-gutter\";\n\n    /**\n     * **This should only be called {@link lineAuthoringGutterMarker}!**\n     *\n     * We want to avoid creating the same instance multiple times for improved performance.\n     */\n    constructor(\n        public readonly lineAuthoring: Exclude<LineAuthoring, \"untracked\">,\n        public readonly startLine: number,\n        public readonly endLine: number,\n        public readonly key: string,\n        public readonly settings: LineAuthorSettings,\n        public readonly options?: \"waiting-for-result\"\n    ) {\n        super();\n    }\n\n    // Equality used by CodeMirror for optimisations\n    public eq(other: GutterMarker): boolean {\n        return (\n            this.key === (<LineAuthoringGutter>other)?.key &&\n            this.startLine === (<LineAuthoringGutter>other)?.startLine &&\n            this.endLine === (<LineAuthoringGutter>other)?.endLine &&\n            this?.options === (<LineAuthoringGutter>other)?.options\n        );\n    }\n\n    /**\n     * Renders to a Html node.\n     *\n     * It choses the newest commit within the line-range,\n     * renders it, makes adjustments for fake-commits and finally warps\n     * it into HTML.\n     *\n     * The DOM is actually precomputed with {@link computeDom},\n     * which provides a finaliser to run before the DOM is handed over to CodeMirror.\n     * This is done, because this method is called frequently. It is called,\n     * whenever a gutter gets into the viewport and needs to be rendered.\n     *\n     * The age in days is recorded via {@link recordRenderedAgeInDays} to enable adaptive coloring.\n     */\n    public toDOM() {\n        this.precomputedDomProvider =\n            this.precomputedDomProvider ?? this.computeDom();\n        return this.precomputedDomProvider();\n    }\n\n    public destroy(dom: HTMLElement): void {\n        if (!dom) {\n            return; // sometimes, it doesn't exist anymore.\n        }\n\n        // this is called frequently, when the gutter moves outside of the view.\n        if (!document.body.contains(dom)) {\n            attachedGutterElements.delete(dom);\n        }\n    }\n\n    /**\n     * Prepares the DOM for this gutter.\n     */\n    private computeDom() {\n        const commit = chooseNewestCommit(\n            this.lineAuthoring,\n            this.startLine,\n            this.endLine\n        );\n\n        let toBeRenderedText = commit.isZeroCommit\n            ? \"\"\n            : this.renderNonZeroCommit(commit);\n\n        const isTrueCommit =\n            !commit.isZeroCommit && this.options !== \"waiting-for-result\";\n\n        if (isTrueCommit) {\n            conditionallyUpdateLongestRenderedGutter(this, toBeRenderedText);\n        } else {\n            toBeRenderedText = this.adaptTextForFakeCommit(\n                commit,\n                toBeRenderedText,\n                this.options\n            );\n        }\n\n        const domProvider = this.createHtmlNode(\n            commit,\n            toBeRenderedText,\n            this.options === \"waiting-for-result\"\n        );\n\n        return domProvider;\n    }\n\n    private createHtmlNode(\n        commit: BlameCommit,\n        text: string,\n        isWaitingGutter: boolean\n    ) {\n        const templateElt = window.createDiv();\n\n        templateElt.setText(text);\n\n        const { color, daysSinceCommit } = coloringBasedOnCommitAge(\n            commit?.author?.epochSeconds,\n            commit?.isZeroCommit,\n            this.settings\n        );\n\n        templateElt.style.backgroundColor = color;\n\n        templateElt.setAttribute(\"data-author\", commit?.author?.name ?? \"\");\n        templateElt.setAttribute(\n            \"data-author-email\",\n            commit?.author?.email ?? \"\"\n        );\n\n        setTooltip(templateElt, commit?.summary ?? \"\");\n\n        enrichCommitInfoForContextMenu(commit, isWaitingGutter, templateElt);\n\n        function prepareForDomAttachment(): HTMLElement {\n            // clone node before attachment, as attached DOMs may get destroyed.\n            const elt = templateElt.cloneNode(true) as HTMLElement;\n            attachedGutterElements.add(elt);\n            // only record real dates\n            if (!isWaitingGutter) recordRenderedAgeInDays(daysSinceCommit);\n            return elt;\n        }\n\n        return prepareForDomAttachment;\n    }\n\n    private renderNonZeroCommit(commit: BlameCommit) {\n        const optionalShortHash = this.settings.showCommitHash\n            ? this.renderHash(commit)\n            : \"\";\n\n        const optionalAuthorName =\n            this.settings.authorDisplay === \"hide\"\n                ? \"\"\n                : `${this.renderAuthorName(\n                      commit,\n                      this.settings.authorDisplay\n                  )}`;\n\n        const optionalAuthoringDate =\n            this.settings.dateTimeFormatOptions === \"hide\"\n                ? \"\"\n                : `${this.renderAuthoringDate(\n                      commit,\n                      this.settings.dateTimeFormatOptions,\n                      this.settings.dateTimeFormatCustomString,\n                      this.settings.dateTimeTimezone\n                  )}`;\n\n        const parts = [\n            optionalShortHash,\n            optionalAuthorName,\n            optionalAuthoringDate,\n        ];\n\n        return parts.filter((x) => x.length >= 1).join(\" \");\n    }\n\n    private renderHash(nonZeroCommit: BlameCommit) {\n        return nonZeroCommit.hash.substring(0, 6);\n    }\n\n    private renderAuthorName(\n        nonZeroCommit: BlameCommit,\n        authorDisplay: Exclude<LineAuthorDisplay, \"hide\">\n    ): string {\n        const name = nonZeroCommit?.author?.name ?? \"\";\n        const words = name.split(\" \").filter((word) => word.length >= 1); // non-empty words\n\n        let rendered;\n        switch (authorDisplay) {\n            case \"initials\": // take every words first letter captitalized\n                rendered = words.map((word) => word[0].toUpperCase()).join(\"\");\n                break;\n            case \"first name\":\n                rendered = words.first() ?? VALUE_NOT_FOUND_FALLBACK;\n                break;\n            case \"last name\":\n                rendered = words.last() ?? VALUE_NOT_FOUND_FALLBACK;\n                break;\n            case \"full\":\n                rendered = name;\n                break;\n            default:\n                return impossibleBranch(authorDisplay);\n        }\n\n        // add trailing * if author and comitter are different.\n        if (!strictDeepEqual(nonZeroCommit?.author, nonZeroCommit?.committer)) {\n            rendered = rendered + DIFFERING_AUTHOR_COMMITTER_MARKER;\n        }\n\n        return rendered;\n    }\n\n    private renderAuthoringDate(\n        nonZeroCommit: BlameCommit,\n        dateTimeFormatOptions: Exclude<LineAuthorDateTimeFormatOptions, \"hide\">,\n        dateTimeFormatCustomString: string,\n        dateTimeTimezone: LineAuthorTimezoneOption\n    ) {\n        const FALLBACK_COMMIT_DATE = \"?\";\n        if (nonZeroCommit?.author?.epochSeconds === undefined)\n            return FALLBACK_COMMIT_DATE;\n\n        let dateTimeFormatting: string | ((time: moment.Moment) => string);\n\n        // adapt dateTimeFormatting based on the settings\n        switch (dateTimeFormatOptions) {\n            case \"date\":\n                dateTimeFormatting = DATE_FORMAT;\n                break;\n            case \"datetime\":\n                dateTimeFormatting = DATE_TIME_FORMAT_MINUTES;\n                break;\n            case \"custom\":\n                dateTimeFormatting = dateTimeFormatCustomString;\n                break;\n            case \"natural language\":\n                dateTimeFormatting = (time) => {\n                    const diff = time.diff(moment());\n                    const addFluentSuffix = true; // 2 weeks -> 2 weeks ago\n                    return moment.duration(diff).humanize(addFluentSuffix);\n                };\n                break;\n            default:\n                return impossibleBranch(dateTimeFormatOptions);\n        }\n\n        let authoringDate: moment.Moment = moment.unix(\n            nonZeroCommit.author.epochSeconds\n        );\n\n        // moment usually shows the above authoring date in the viewer local timezone.\n        // when we want to show it in the absolute UTC time-zone, we'll need to provide\n        // and adapt the utcOffset\n        switch (dateTimeTimezone) {\n            case \"viewer-local\": // moment uses local timezone by default.\n                break;\n            case \"author-local\":\n                authoringDate = authoringDate.utcOffset(\n                    nonZeroCommit.author.tz\n                );\n                if (typeof dateTimeFormatting === \"string\")\n                    dateTimeFormatting += \" Z\"; // add explicit time-zone, as this is not clear now.\n                break;\n            case \"utc0000\":\n                authoringDate = authoringDate.utc(); // convert to utc\n                if (typeof dateTimeFormatting === \"string\")\n                    dateTimeFormatting += \"[Z]\"; // add \"Z\" to indicate, that this is UTC+0000 time.\n                break;\n            default:\n                return impossibleBranch(dateTimeTimezone);\n        }\n\n        // compute formatting based on dateTimeFormatting\n        if (typeof dateTimeFormatting === \"string\") {\n            return authoringDate.format(dateTimeFormatting);\n        } else {\n            return dateTimeFormatting(authoringDate);\n        }\n    }\n\n    private adaptTextForFakeCommit(\n        commit: BlameCommit,\n        toBeRenderedText: string,\n        options?: \"waiting-for-result\"\n    ) {\n        // attempt to use longest text as template for fake commit.\n        const original = getLongestRenderedGutter()?.text ?? toBeRenderedText;\n\n        // replace template with + or % depending on whether its a zero commit or waiting-for-result.\n        // the % is used to make the UI update from % to the true characters unintrusive\n        // waiting-for-result has higher priority than zero commit\n        const fillCharacter =\n            options !== \"waiting-for-result\" && commit.isZeroCommit\n                ? NEW_CHANGE_CHARACTER\n                : UNINTRUSIVE_CHARACTER_FOR_WAITING_RENDERING;\n\n        toBeRenderedText = original.replace(\n            NON_WHITESPACE_REGEXP,\n            fillCharacter\n        );\n\n        // Adapt the text to the same length as previously rendered gutters.\n        // This ensures, that the frequent UI updates with differing line author lengths\n        // don't frequently shift the gutter size - which would also cause distracting UI updates.\n        const desiredTextLength =\n            latestSettings.get()?.gutterSpacingFallbackLength ??\n            toBeRenderedText.length;\n\n        toBeRenderedText = resizeToLength(\n            toBeRenderedText,\n            desiredTextLength,\n            fillCharacter\n        );\n\n        // For new changes, show only the a few + characters.\n        if (options !== \"waiting-for-result\" && commit.isZeroCommit) {\n            const numberOfLastCharactersToKeep = Math.min(\n                desiredTextLength,\n                NEW_CHANGE_NUMBER_OF_CHARACTERS\n            );\n            toBeRenderedText = prefixOfLengthAsWhitespace(\n                toBeRenderedText,\n                desiredTextLength - numberOfLastCharactersToKeep\n            );\n        }\n\n        return toBeRenderedText;\n    }\n}\n\n/**\n * Creates a {@link LineAuthoringGutter}.\n *\n * This function should be used instead of directly calling the constructor,\n * as we don't want to re-create the same instance multiple times, whenever the user\n * scrolls through a document. It simply stores the instances in the cache {@link gutterInstances}.\n */\nexport function lineAuthoringGutterMarker(\n    la: Exclude<LineAuthoring, \"untracked\">,\n    startLine: number,\n    endLine: number,\n    key: string,\n    settings: LineAuthorSettings,\n    options?: \"waiting-for-result\"\n) {\n    const digest = sha256.create();\n    digest.update(JSON.stringify(settings));\n    digest.update(`s${startLine}-e${endLine}-k${key}-o${options}`);\n\n    const cacheKey = digest.hex();\n\n    const cached = gutterInstances.get(cacheKey);\n    if (cached) return cached;\n\n    const result = new LineAuthoringGutter(\n        la,\n        startLine,\n        endLine,\n        key,\n        settings,\n        options\n    );\n    gutterInstances.set(cacheKey, result);\n    return result;\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/view/gutter/gutterElementSearch.ts",
    "content": "import { attachedGutterElements } from \"src/editor/lineAuthor/view/cache\";\n\nconst mouseXY = { x: -10, y: -10 };\n\n// todo. According to a discord message from the Obsidian Team, the source bug\n// will be fixed in the next release. Then this hack should be removed.\n/**\n * Stores the last MouseDownEvent clientX and clientY position.\n *\n * This is part of the 'hack' to be able to detect the line author gutter element below\n * the mouse as part of the context menu. This is necessary, as I couldn't find\n * a way to retrieve the target gutter from the Obsidian \"editor-menu\" event.\n */\nexport function prepareGutterSearchForContextMenuHandling() {\n    if (mouseXY.x === -10) {\n        // event listener is not yet registered\n        window.addEventListener(\"mousedown\", (e) => {\n            mouseXY.x = e.clientX;\n            mouseXY.y = e.clientY;\n        });\n    }\n}\n\nexport function findGutterElementUnderMouse(): HTMLElement | undefined {\n    for (const elt of attachedGutterElements) {\n        if (contains(elt, mouseXY)) return elt;\n    }\n}\n\nfunction contains(elt: HTMLElement, pt: { x: number; y: number }): boolean {\n    const { x, y, width, height } = elt.getBoundingClientRect();\n    return x <= pt.x && pt.x <= x + width && y <= pt.y && pt.y <= y + height;\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/view/gutter/initial.ts",
    "content": "import { moment } from \"obsidian\";\nimport { DEFAULT_SETTINGS } from \"src/constants\";\nimport type {\n    LineAuthoring,\n    LineAuthorSettings,\n} from \"src/editor/lineAuthor/model\";\nimport {\n    latestSettings,\n    maxAgeInDaysFromSettings,\n} from \"src/editor/lineAuthor/model\";\nimport { computeAdaptiveInitialColoringAgeInDays } from \"src/editor/lineAuthor/view/cache\";\nimport {\n    lineAuthoringGutterMarker,\n    TextGutter,\n} from \"src/editor/lineAuthor/view/gutter/gutter\";\nimport type { Blame, BlameCommit, GitTimestamp, UserEmail } from \"src/types\";\nimport { momentToEpochSeconds } from \"src/utils\";\n\n/**\n * The gutter used to reserve the space used for the line authoring before it is loaded.\n *\n * Until the true length is known, it uses the last saved `gutterSpacingFallbackLength`.\n */\nexport function initialSpacingGutter() {\n    const length =\n        latestSettings.get()?.gutterSpacingFallbackLength ??\n        DEFAULT_SETTINGS.lineAuthor.gutterSpacingFallbackLength;\n    return new TextGutter(Array(length).fill(\"-\").join(\"\"));\n}\n\n/**\n * Initial line authoring gutter with adaptive coloring for softer UI updates.\n *\n * **DO NOT CACHE THIS FUNCTION CALL, AS THE ADAPTIVE COLOR NEED TO BE FRESHLY CALCULATED.**\n */\nexport function initialLineAuthoringGutter(settings: LineAuthorSettings) {\n    const { lineAuthoring, ageForInitialRender } =\n        adaptiveInitialColoredWaitingLineAuthoring(settings);\n    return lineAuthoringGutterMarker(\n        lineAuthoring,\n        1,\n        1,\n        \"initialGutter\" + ageForInitialRender, // use a age coloring based cache key\n        settings,\n        \"waiting-for-result\"\n    );\n}\n\n/**\n * Creates a line authoring with an adaptive initial color based on {@link computeAdaptiveInitialColoringAgeInDays} (previously recorded ages).\n *\n * If no such color is available, then it takes the 25% of the max age as the color.\n * e.g. for max age = 100 days, this means it'll use the color for the age of 25 days.\n * This case only happens on each (re-)start of Obsidian.\n *\n * We use a waiting-gutter, to have it rendered - so that we can use it's rendered text\n * and transform it into unintrusive placeholder characters.\n */\nexport function adaptiveInitialColoredWaitingLineAuthoring(\n    settings: LineAuthorSettings\n): {\n    lineAuthoring: Exclude<LineAuthoring, \"untracked\">;\n    ageForInitialRender: number;\n} {\n    const ageForInitialRender: number =\n        computeAdaptiveInitialColoringAgeInDays() ??\n        maxAgeInDaysFromSettings(settings) * 0.25;\n\n    const slightlyOlderAgeForInitialRender: moment.Moment = moment().add(\n        -ageForInitialRender,\n        \"days\"\n    );\n\n    const dummyAuthor = <UserEmail & GitTimestamp>{\n        name: \"\",\n        epochSeconds: momentToEpochSeconds(slightlyOlderAgeForInitialRender),\n        tz: \"+0000\",\n    };\n\n    const dummyCommit = <BlameCommit>{\n        hash: \"waiting-for-result\",\n        author: dummyAuthor,\n        committer: dummyAuthor,\n        isZeroCommit: false,\n    };\n\n    return {\n        lineAuthoring: <Blame>{\n            hashPerLine: [undefined!, \"waiting-for-result\"],\n            commits: new Map([[\"waiting-for-result\", dummyCommit]]),\n        },\n        ageForInitialRender,\n    };\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/view/gutter/untrackedFile.ts",
    "content": "import { zeroCommit } from \"src/gitManager/simpleGit\";\nimport type { LineAuthorSettings } from \"src/editor/lineAuthor/model\";\nimport { lineAuthoringGutterMarker } from \"src/editor/lineAuthor/view/gutter/gutter\";\nimport type { Blame } from \"src/types\";\n\n/**\n * The gutter to show on untracked files.\n */\nexport function newUntrackedFileGutter(\n    key: string,\n    settings: LineAuthorSettings\n) {\n    const dummyLineAuthoring = <Blame>{\n        hashPerLine: [undefined!, \"000000\"],\n        commits: new Map([[\"000000\", zeroCommit]]),\n    };\n    return lineAuthoringGutterMarker(dummyLineAuthoring, 1, 1, key, settings);\n}\n"
  },
  {
    "path": "src/editor/lineAuthor/view/view.ts",
    "content": "import type { Extension, Range, Text } from \"@codemirror/state\";\nimport { RangeSet } from \"@codemirror/state\";\nimport type { EditorView, GutterMarker } from \"@codemirror/view\";\nimport { gutter } from \"@codemirror/view\";\nimport type {\n    LineAuthoringWithChanges,\n    LineAuthorSettings,\n} from \"src/editor/lineAuthor/model\";\nimport {\n    laStateDigest,\n    latestSettings,\n    lineAuthorState,\n} from \"src/editor/lineAuthor/model\";\nimport {\n    getLongestRenderedGutter,\n    gutterMarkersRangeSet,\n} from \"src/editor/lineAuthor/view/cache\";\nimport {\n    lineAuthoringGutterMarker,\n    TextGutter,\n} from \"src/editor/lineAuthor/view/gutter/gutter\";\nimport {\n    initialLineAuthoringGutter,\n    initialSpacingGutter,\n} from \"src/editor/lineAuthor/view/gutter/initial\";\nimport { newUntrackedFileGutter } from \"src/editor/lineAuthor/view/gutter/untrackedFile\";\nimport { between } from \"src/utils\";\n\n/*\n================== VIEW ======================\nContains classes, variables and functions describing\nhow the MODEL is rendered to a view.\n*/\n\nconst UNDISPLAYED = new TextGutter(\"\");\n\n/**\n * The line author gutter as a Codemirror {@link Extension}.\n *\n * It simply renderes the line authoring state from the {@link lineAuthorState} state-field.\n */\nexport const lineAuthorGutter: Extension = gutter({\n    class: \"line-author-gutter-container\",\n    markers(view) {\n        // this is called a few times on every keystroke / cursor-move. Hence, it is efficient\n        const lineAuthoring = view.state.field(lineAuthorState, false);\n        return lineAuthoringGutterMarkersRangeSet(view, lineAuthoring);\n    },\n    lineMarkerChange(update) {\n        const newLineAuthoringId = laStateDigest(\n            update.state.field(lineAuthorState)\n        );\n        const oldLineAuthoringId = laStateDigest(\n            update.startState.field(lineAuthorState)\n        );\n        return oldLineAuthoringId !== newLineAuthoringId;\n    },\n    renderEmptyElements: true,\n    initialSpacer: (view) => {\n        temporaryWorkaroundGutterSpacingForRenderedLineAuthoring(view);\n        return initialSpacingGutter();\n    },\n    updateSpacer: (_sp, update) => {\n        temporaryWorkaroundGutterSpacingForRenderedLineAuthoring(update.view);\n        return getLongestRenderedGutter()?.gutter ?? initialSpacingGutter();\n    },\n});\n\n/**\n * Creates the gutter markers as a {@link RangeSet}.\n *\n * The computation result is cached for better performance via a SHA-256 `cacheKey`.\n * The actual computation happens in {@link computeLineAuthoringGutterMarkersRangeSet}.\n */\nfunction lineAuthoringGutterMarkersRangeSet(\n    view: EditorView,\n    optLA?: LineAuthoringWithChanges\n): RangeSet<GutterMarker> {\n    const digest = laStateDigest(optLA);\n\n    const doc = view.state.doc;\n    // We don't digest this, even though it is used as an argument for the computation\n    // This is because a change in the doc is only reflected in the line authoring\n    // when the doc is saved. But saving changes the line authoring key anyways.\n\n    // Each line is part of a block of 1 or more lines. Within a block only the newest\n    // commit should be shown. Hence, we collect the start and end positions for each block here.\n    const lineBlockEndPos: Map<number, [number, number]> = new Map();\n    for (let line = 1; line <= doc.lines; line++) {\n        const from = doc.line(line).from;\n        const to = view.lineBlockAt(from).to;\n        lineBlockEndPos.set(line, [from, to]);\n        digest.update([from, to, 0]);\n    }\n\n    const laSettings = latestSettings.get();\n    digest.update(\"s\" + Object.values(latestSettings).join(\",\"));\n\n    const cacheKey = digest.hex();\n\n    const cached = gutterMarkersRangeSet.get(cacheKey);\n    if (cached) return cached;\n\n    // This is called infrequently enough to put the computation there.\n    const { result, allowCache } = computeLineAuthoringGutterMarkersRangeSet(\n        doc,\n        lineBlockEndPos,\n        laSettings,\n        optLA\n    );\n    if (allowCache) gutterMarkersRangeSet.set(cacheKey, result);\n    return result;\n}\n\nfunction computeLineAuthoringGutterMarkersRangeSet(\n    doc: Text,\n    blocksPerLine: Map<number, [number, number]>,\n    settings: LineAuthorSettings,\n    optLA?: LineAuthoringWithChanges\n): { result: RangeSet<GutterMarker>; allowCache: boolean } {\n    let allowCache = true; // invocations of initialLineAuthoringGutter shouldn't be cached\n\n    const docLastLine = doc.lines;\n\n    const ranges: Range<GutterMarker>[] = [];\n    function add(from: number, to: number | undefined, gutter: GutterMarker) {\n        return ranges.push(gutter.range(from, to));\n    }\n\n    const lineFrom = computeLineMappingForUnsavedChanges(docLastLine, optLA);\n\n    const emptyDoc = doc.length === 0;\n\n    const lastLineIsEmpty =\n        doc.iterLines(docLastLine, docLastLine + 1).next().value === \"\";\n\n    for (let startLine = 1; startLine <= docLastLine; startLine++) {\n        const [from, to] = blocksPerLine.get(startLine)!;\n        const endLine = doc.lineAt(to).number;\n\n        if (emptyDoc) {\n            add(from, to, UNDISPLAYED);\n            continue;\n        }\n\n        if (startLine === docLastLine && lastLineIsEmpty) {\n            add(from, to, UNDISPLAYED);\n            continue;\n        }\n\n        if (optLA === undefined) {\n            add(from, to, initialLineAuthoringGutter(settings));\n            allowCache = false;\n            continue;\n        }\n\n        const { key, la } = optLA;\n\n        if (la === \"untracked\") {\n            add(from, to, newUntrackedFileGutter(la, settings));\n            continue;\n        }\n\n        const lastAuthorLine = la.hashPerLine.length - 1;\n\n        const laStartLine = lineFrom[startLine];\n        const laEndLine = lineFrom[endLine];\n\n        if (laEndLine && laEndLine > lastAuthorLine) {\n            add(from, to, UNDISPLAYED);\n        }\n\n        if (\n            laStartLine !== undefined &&\n            between(1, laStartLine, lastAuthorLine) &&\n            laEndLine !== undefined &&\n            between(1, laEndLine, lastAuthorLine)\n        ) {\n            add(\n                from,\n                to,\n                lineAuthoringGutterMarker(\n                    la,\n                    laStartLine,\n                    laEndLine,\n                    key,\n                    settings\n                )\n            );\n            continue;\n        }\n\n        // unsaved changes quick gutter update. scenario 1\n        if (lastAuthorLine < 1) {\n            // file was empty, but now it's being written\n            add(from, to, initialLineAuthoringGutter(settings));\n            allowCache = false;\n            continue;\n        }\n\n        // unsaved changes quick gutter update. scenario 2\n        const start = Math.clamp(laStartLine ?? startLine, 1, lastAuthorLine);\n        const end = Math.clamp(laEndLine ?? endLine, 1, lastAuthorLine);\n\n        add(\n            from,\n            to,\n            lineAuthoringGutterMarker(\n                la,\n                start,\n                end,\n                key + \"computing\",\n                settings,\n                \"waiting-for-result\"\n            )\n        );\n    }\n\n    return { result: RangeSet.of(ranges, /* sort = */ true), allowCache };\n}\n\n// todo. explain.\nfunction computeLineMappingForUnsavedChanges(\n    docLastLine: number,\n    optLA: LineAuthoringWithChanges | undefined\n): (number | undefined)[] {\n    if (!optLA?.lineOffsetsFromUnsavedChanges) {\n        return Array.from(\n            new Array<number | undefined>(docLastLine + 1),\n            (ln) => ln\n        );\n    }\n\n    const lineFrom: (number | undefined)[] = [undefined];\n    let cumulativeLineOffset = 0; // may be negative\n\n    for (let ln = 1; ln <= docLastLine; ln++) {\n        const unsavedChanges = optLA.lineOffsetsFromUnsavedChanges.get(ln);\n        cumulativeLineOffset += unsavedChanges ?? 0; // compute cumulative sum of line offsets\n        // if no unsaved changes are there for the current line, then use\n        // the cumulative offset, otherwise return undefined - which will be rendered as 'computing'\n        lineFrom[ln] =\n            unsavedChanges === undefined\n                ? ln - cumulativeLineOffset\n                : undefined;\n    }\n\n    return lineFrom;\n}\n\n/**\n * This applies a tempoary workaround for custom gutters for Obsidian v1.0.\n *\n * As of writing, the following problem exists:\n * * When the line authoring is rendered without anything else (i.e. line numbers)\n *   the spacing is messed up and obscures the text.\n * * When the line authoring is shown together with the line numbers everything is fine.\n *\n * See the bug report: https://forum.obsidian.md/t/added-editor-gutter-overlaps-and-obscures-editor-content/45217\n *\n * The conclusion of the analysis is, that we want to reset the `margin-left` style\n * property of the `.cm-gutters` container element **if and only if** the line authoring\n * is rendered. For this reason, the initialSpacer and updatesSpacer callbacks in\n * {@link lineAuthorGutter} call this function which reset the corresponding style.\n *\n * TODO: Remove this workaround, when this is fixed within Obsidian itself.\n */\nfunction temporaryWorkaroundGutterSpacingForRenderedLineAuthoring(\n    view: EditorView\n) {\n    const guttersContainers =\n        view.dom.querySelectorAll<HTMLElement>(\".cm-gutters\");\n    guttersContainers.forEach((cont) => {\n        if (!cont?.style) return;\n        if (!cont.style.marginLeft) {\n            cont.style.marginLeft = \"unset\";\n        }\n    });\n}\n"
  },
  {
    "path": "src/editor/signs/changesStatusBar.ts",
    "content": "import type ObsidianGit from \"src/main\";\nimport type { Hunk } from \"./hunks\";\nimport { MarkdownView, TFile } from \"obsidian\";\n\nexport class ChangesStatusBar {\n    constructor(\n        private statusBarEl: HTMLElement,\n        private readonly plugin: ObsidianGit\n    ) {\n        statusBarEl.addClass(\"git-changes-status-bar\");\n        if (plugin.settings.hunks.statusBar === \"colored\") {\n            statusBarEl.addClass(\"git-changes-status-bar-colored\");\n        }\n\n        statusBarEl.setAttr(\"aria-label\", \"Git diff of the current editor\");\n        this.statusBarEl.setAttribute(\"data-tooltip-position\", \"top\");\n        plugin.app.workspace.on(\"active-leaf-change\", (leaf) => {\n            if (\n                !leaf ||\n                (leaf.getRoot() == plugin.app.workspace.rootSplit &&\n                    !(leaf.view instanceof MarkdownView))\n            ) {\n                this.statusBarEl.empty();\n            }\n        });\n    }\n\n    display(hunks: Hunk[], file: TFile | null): void {\n        const mdView =\n            this.plugin.app.workspace.getActiveViewOfType(MarkdownView);\n        if (!mdView || mdView.file?.path !== file?.path) {\n            return;\n        }\n\n        let added: number = 0,\n            changed: number = 0,\n            deleted: number = 0;\n        for (const hunk of hunks) {\n            added += Math.max(0, hunk.added.count - hunk.removed.count);\n            changed += Math.min(hunk.added.count, hunk.removed.count);\n            deleted += Math.max(0, hunk.removed.count - hunk.added.count);\n        }\n        this.statusBarEl.empty();\n        if (added > 0) {\n            this.statusBarEl.createSpan({\n                text: `+${added} `,\n                cls: \"git-add\",\n            });\n        }\n        if (changed > 0) {\n            this.statusBarEl.createSpan({\n                text: `~${changed} `,\n                cls: \"git-change\",\n            });\n        }\n        if (deleted > 0) {\n            this.statusBarEl.createSpan({\n                text: `-${deleted}`,\n                cls: \"git-delete\",\n            });\n        }\n    }\n\n    remove() {\n        this.statusBarEl.remove();\n    }\n}\n"
  },
  {
    "path": "src/editor/signs/diff.ts",
    "content": "import { Hunks, type Hunk } from \"../signs/hunks\";\nimport { Chunk } from \"@codemirror/merge\";\nimport { ChangeDesc, Text } from \"@codemirror/state\";\nimport { lineFromPos } from \"./hunkState\";\n\n// function diffMatchPatch(\n//     text1: string,\n//     text2: string\n// ): diff.ChangeObject<string>[] {\n//     const toChars = linesToChars(text1, text2);\n//     const lineText1 = toChars.chars1;\n//     const lineText2 = toChars.chars2;\n//     const lineArray = toChars.lineArray;\n//     let diffs = makeDiff(lineText1, lineText2, {\n//         checkLines: false,\n//     });\n//     diffs = cleanupSemantic(diffs);\n//     charsToLines(diffs, lineArray);\n//     const res: diff.ChangeObject<string>[] = [];\n//     for (let i = 0; i < diffs.length; i++) {\n//         res.push({\n//             added: diffs[i][0] == 1 ? true : false,\n//             removed: diffs[i][0] == -1 ? true : false,\n//             value: diffs[i][1],\n//             count: diffs[i][1].split(\"\\n\").length - 1,\n//         });\n//     }\n//\n//     return res;\n// }\n\ntype RawHunk = {\n    oldStart: number;\n    oldLines: number;\n    newStart: number;\n    newLines: number;\n};\n// export interface ChangeObject {\n//     /**\n//      * The concatenated content of all the tokens represented by this change object - i.e. generally the text that is either added, deleted, or common, as a single string.\n//      * In cases where tokens are considered common but are non-identical (e.g. because an option like `ignoreCase` or a custom `comparator` was used), the value from the *new* string will be provided here.\n//      */\n//     value: string;\n//     /**\n//      * true if the value was inserted into the new string, otherwise false\n//      */\n//     added: boolean;\n//     /**\n//      * true if the value was removed from the old string, otherwise false\n//      */\n//     removed: boolean;\n// }\n//\n// function changesToRawHunks(diff: ChangeObject[]): RawHunk[] {\n//     diff.push({ value: \"\", added: false, removed: false }); // Append an empty value to make cleanup easier\n//\n//     const hunks = [];\n//     let oldRangeStart = 0,\n//         newRangeStart = 0,\n//         oldLine = 1,\n//         newLine = 1;\n//     for (let i = 0; i < diff.length; i++) {\n//         const current = diff[i];\n//         const linesCount =\n//             current.value.match(new RegExp(`\\n`, \"g\"))?.length ?? 0;\n//\n//         if (current.added || current.removed) {\n//             // If we have previous context, start with that\n//             if (!oldRangeStart) {\n//                 oldRangeStart = oldLine;\n//                 newRangeStart = newLine;\n//             }\n//\n//             // Track the updated file position\n//             // If the change affects the last line of the document and does not\n//             // end with '\\n' increase the line count by 1 because the last line\n//             // still needs to count.\n//             if (current.added) {\n//                 newLine += linesCount;\n//                 if (\n//                     !current.value.endsWith(\"\\n\") &&\n//                     (i + 2 == diff.length || i + 3 == diff.length)\n//                 ) {\n//                     newLine += 1;\n//                 }\n//             } else {\n//                 oldLine += linesCount;\n//                 if (\n//                     !current.value.endsWith(\"\\n\") &&\n//                     (i + 2 == diff.length || i + 3 == diff.length)\n//                 ) {\n//                     oldLine += 1;\n//                 }\n//             }\n//         } else {\n//             if (oldRangeStart) {\n//                 // if (current.value.startsWith(\"\\n\")) {\n//                 // }\n//                 const hunk = {\n//                     oldStart: oldRangeStart,\n//                     oldLines: oldLine - oldRangeStart,\n//                     newStart: newRangeStart,\n//                     newLines: newLine - newRangeStart,\n//                 };\n//                 hunks.push(hunk);\n//\n//                 // oldLine += 1;\n//                 // newLine += 1;\n//                 oldRangeStart = 0;\n//                 newRangeStart = 0;\n//                 // if (current.value.startsWith(\"\\n\")) {\n//                 //     oldLine += linesCount - 1;\n//                 //     newLine += linesCount - 1;\n//                 // } else {\n//                 oldLine += linesCount;\n//                 newLine += linesCount;\n//                 /* } */\n//             } else {\n//                 oldLine += linesCount;\n//                 newLine += linesCount;\n//             }\n//         }\n//     }\n//     return hunks;\n// }\n\nexport function rawHunksToHunks(\n    textA: string,\n    textB: string,\n    rawHunks: RawHunk[]\n): Hunk[] {\n    const hunks: Hunk[] = [];\n    const linesA = textA.split(\"\\n\");\n    const linesB = textB.split(\"\\n\");\n    for (const rawHunk of rawHunks) {\n        const { oldStart, oldLines, newStart, newLines } = rawHunk;\n        const hunk = Hunks.createHunk(oldStart, oldLines, newStart, newLines);\n        if (rawHunk.oldLines > 0) {\n            for (let i = oldStart; i < oldStart + oldLines; i++) {\n                hunk.removed.lines.push(linesA[i - 1]);\n            }\n            if (oldStart + oldLines > linesA.length && linesA.last() != \"\") {\n                hunk.removed.no_nl_at_eof = true;\n            }\n        }\n        if (rawHunk.newLines > 0) {\n            for (let i = newStart; i < newStart + newLines; i++) {\n                hunk.added.lines.push(linesB[i - 1]);\n            }\n            if (newStart + newLines > linesB.length && linesB.last() != \"\") {\n                hunk.added.no_nl_at_eof = true;\n            }\n        }\n        hunks.push(hunk);\n    }\n    return hunks;\n}\n\nexport function rawHunkFromChunk(\n    chunk: Chunk,\n    aDoc: Text,\n    bDoc: Text\n): RawHunk {\n    const oldStart = aDoc.lineAt(chunk.fromA).number;\n    const oldLines =\n        chunk.fromA == chunk.toA\n            ? 0\n            : lineFromPos(aDoc, chunk.endA) - oldStart + 1;\n    const newStart = bDoc.lineAt(chunk.fromB).number;\n    const newLines =\n        chunk.fromB == chunk.toB\n            ? 0\n            : lineFromPos(bDoc, chunk.endB) - newStart + 1;\n\n    const rawHunk = {\n        oldStart,\n        oldLines,\n        newStart,\n        newLines,\n    };\n\n    if (rawHunk.oldLines == 0) {\n        rawHunk.oldStart -= 1;\n    }\n    if (rawHunk.newLines == 0) {\n        rawHunk.newStart -= 1;\n    }\n\n    return rawHunk;\n}\n\nconst diffConfig = {\n    scanLimit: 1000,\n    timeout: 200,\n};\n\nfunction diffViaCMMerge(\n    textA: string,\n    textB: string,\n    chunks: readonly Chunk[] | undefined,\n    changes: ChangeDesc | undefined\n) {\n    const aDoc = Text.of(textA.split(\"\\n\"));\n    const bDoc = Text.of(textB.split(\"\\n\"));\n    const newChunks =\n        chunks && changes\n            ? Chunk.updateB(chunks, aDoc, bDoc, changes, diffConfig)\n            : Chunk.build(aDoc, bDoc, diffConfig);\n    const rawHunks: RawHunk[] = [];\n    for (let i = 0; i < newChunks.length; i++) {\n        const chunk = newChunks[i];\n\n        const rawHunk = rawHunkFromChunk(chunk, aDoc, bDoc);\n        rawHunks.push(rawHunk);\n    }\n    return {\n        hunks: rawHunksToHunks(textA, textB, rawHunks),\n        chunks: newChunks,\n    };\n}\n\nexport function computeHunks(\n    textA: string,\n    textB: string,\n    chunks: readonly Chunk[] | undefined,\n    changes: ChangeDesc | undefined\n): { hunks: Hunk[]; chunks: readonly Chunk[] } {\n    const res = diffViaCMMerge(textA, textB, chunks, changes);\n    return res;\n    // const lineDiff = diff.diffLines(textA, textB, {\n    //     newlineIsToken: true,\n    // });\n    // const lineDiff2 = diffLines(\n    //     prev.compareText,\n    //     transaction.newDoc.toString(),\n    //     {\n    //         newlineIsToken: false,\n    //     }\n    // );\n    // console.log(\"lineDiff\", lineDiff);\n    // console.log(\"lineDiff2\", lineDiff2);\n    // const rawHunks = toRawHunks(lineDiff);\n    // const rawHunks2 = structuredPatch(\n    //     \"fileA\",\n    //     \"fileB\",\n    //     prev.compareText,\n    //     transaction.newDoc.toString(),\n    //     \"\",\n    //     \"\",\n    //     { context: 0 }\n    // ).hunks;\n    // const linediff2 = diffMatchPatch(textA, textB);\n    // const rawHunks = changesToRawHunks(linediff2);\n    // console.log(\"rawHunks\", rawHunks);\n    // console.log(\"rawHunks3\", rawHunks3);\n    // console.log(\"rawHunks2\", rawHunks2);\n    // Adjust newStart for hunks which do not add any lines\n    // This is more in the style of git diff\n    // for (const hunk of rawHunks) {\n    //     if (hunk.newLines == 0) {\n    //         hunk.newStart -= 1;\n    //     }\n    //     if (hunk.oldLines == 0) {\n    //         hunk.oldStart -= 1;\n    //     }\n    // }\n    // console.log(\"linediff2\", linediff2);\n    // console.log(\"rawHunks\", rawHunks);\n    // const hunks = rawHunksToHunks(textA, textB, rawHunks);\n    // console.log(\"hunks\", hunks);\n    // return hunks;\n}\n"
  },
  {
    "path": "src/editor/signs/gutter.ts",
    "content": "import { RangeSet, StateField, Transaction } from \"@codemirror/state\";\nimport { EditorView, gutter, GutterMarker } from \"@codemirror/view\";\nimport { Hunks, type Hunk, type SignType } from \"./hunks\";\nimport {\n    DebouncedComputeHunksEffectType,\n    GitCompareResultEffectType,\n    hunksState,\n    HunksStateHelper,\n} from \"./hunkState\";\nimport { togglePreviewHunk } from \"./tooltip\";\n\nclass GitGutterMarker extends GutterMarker {\n    constructor(\n        readonly type: SignType,\n        readonly staged: boolean\n    ) {\n        super();\n    }\n\n    toDOM(_: EditorView) {\n        const marker = document.createElement(\"div\");\n        marker.className = `git-gutter-marker git-${this.type} ${\n            this.staged ? \"staged\" : \"unstaged\"\n        }`;\n        if (this.type == \"changedelete\") {\n            marker.setText(\"~\");\n        }\n        return marker;\n    }\n}\n\nexport const signsMarker = StateField.define({\n    create: () => RangeSet.empty,\n    update: (rangeSet, tr) => {\n        const data = tr.state.field(hunksState, false);\n        if (!data) {\n            return RangeSet.empty;\n        }\n        const newDebouncedHunks = tr.effects.some((effect) =>\n            effect.is(DebouncedComputeHunksEffectType)\n        );\n\n        // Show new hunks for new compare results independent of a doc change\n        const newCompareResult = tr.effects.some((effect) =>\n            effect.is(GitCompareResultEffectType)\n        );\n\n        if (\n            newDebouncedHunks ||\n            newCompareResult ||\n            ((tr.docChanged || rangeSet.size == 0) && data.isDirty == false)\n        ) {\n            const linesWithSign = new Set<number>();\n            const markers = getMarkers(tr, data.hunks, false, linesWithSign);\n            const stagedMarkers = getMarkers(\n                tr,\n                data.stagedHunks,\n                true,\n                linesWithSign\n            );\n            rangeSet = RangeSet.of([...markers, ...stagedMarkers], true);\n            return rangeSet;\n        } else if (tr.docChanged) {\n            rangeSet = rangeSet.map(tr.changes);\n        }\n        return rangeSet;\n    },\n});\n\nfunction getMarkers(\n    tr: Transaction,\n    hunks: Hunk[],\n    staged: boolean,\n    linesWithSign: Set<number>\n) {\n    const signs = [];\n    for (let i = 0; i < hunks.length; i++) {\n        const prevHunk = i > 0 ? hunks[i - 1] : undefined;\n        const nextHunk = i < hunks.length - 1 ? hunks[i + 1] : undefined;\n        const hunk = hunks[i];\n        signs.push(...Hunks.calcSigns(prevHunk, hunk, nextHunk));\n    }\n\n    const markers = [];\n    for (const sign of signs) {\n        if (linesWithSign.has(sign.lnum)) continue;\n        const lineInfo = tr.state.doc.line(sign.lnum);\n        linesWithSign.add(sign.lnum);\n        markers.push(\n            new GitGutterMarker(sign.type, staged).range(\n                lineInfo.from,\n                lineInfo.to\n            )\n        );\n    }\n    return markers;\n}\n\nexport const signsGutter = gutter({\n    class: \"git-signs-gutter\",\n    markers: (view) => view.state.field(signsMarker, false) ?? RangeSet.empty,\n    initialSpacer: (_) => {\n        return new GitGutterMarker(\"delete\", false);\n    },\n    domEventHandlers: {\n        click: (view, line, event) => {\n            const hunk =\n                HunksStateHelper.getHunkAtPos(view.state, line.from, false) ??\n                HunksStateHelper.getHunkAtPos(view.state, line.from, true);\n            if (!hunk) {\n                return false;\n            }\n\n            togglePreviewHunk(view, line.from);\n            event.preventDefault();\n            return false;\n        },\n    },\n});\n"
  },
  {
    "path": "src/editor/signs/hunkActions.ts",
    "content": "import { editorInfoField, type Editor } from \"obsidian\";\nimport { HunksStateHelper } from \"./hunkState\";\nimport type { EditorView } from \"codemirror\";\nimport type ObsidianGit from \"src/main\";\nimport { Hunks } from \"./hunks\";\nimport type { SimpleGit } from \"src/gitManager/simpleGit\";\n\nexport class HunkActions {\n    constructor(private readonly plugin: ObsidianGit) {}\n\n    get editor(): { obEditor: Editor; editor: EditorView } | undefined {\n        const obEditor = this.plugin.app.workspace.activeEditor?.editor;\n        // @ts-expect-error, not typed\n        const editor = obEditor?.cm as EditorView;\n\n        if (!obEditor || !HunksStateHelper.hasHunksData(editor.state)) {\n            return undefined;\n        }\n        return { editor, obEditor };\n    }\n\n    private get gitManager(): SimpleGit {\n        return this.plugin.gitManager as SimpleGit;\n    }\n\n    resetHunk(pos?: number): void {\n        if (!this.editor) {\n            return;\n        }\n        const { editor, obEditor } = this.editor;\n        const hunk = HunksStateHelper.getHunk(editor.state, false, pos);\n        if (hunk) {\n            let lstart: number, lend: number;\n            if (hunk.type === \"delete\") {\n                lstart = hunk.added.start + 1;\n                lend = hunk.added.start + 1;\n            } else {\n                lstart = hunk.added.start - 0;\n                lend = hunk.added.start - 1 + hunk.added.count;\n            }\n            const from = editor.state.doc.line(lstart).from;\n            const to =\n                hunk.type === \"delete\"\n                    ? editor.state.doc.line(lend).from\n                    : editor.state.doc.line(lend).to + 1;\n            let lines = hunk.removed.lines.join(\"\\n\");\n            if (hunk.removed.lines.length > 0 && !hunk.removed.no_nl_at_eof) {\n                lines += \"\\n\";\n            }\n\n            obEditor.replaceRange(\n                lines,\n                obEditor.offsetToPos(from),\n                obEditor.offsetToPos(to)\n            );\n\n            obEditor.setSelection(obEditor.offsetToPos(from));\n        }\n    }\n\n    async stageHunk(pos?: number): Promise<void> {\n        if (!(await this.plugin.isAllInitialized())) {\n            return;\n        }\n        if (!this.editor) {\n            return;\n        }\n        const { editor } = this.editor;\n\n        let hunk = HunksStateHelper.getHunk(editor.state, false, pos);\n        let invert = false;\n        if (!hunk) {\n            hunk = HunksStateHelper.getHunk(editor.state, true, pos);\n            invert = true;\n        }\n        if (!hunk) {\n            return;\n        }\n        const filepath = editor.state.field(editorInfoField).file!.path;\n\n        const patch =\n            Hunks.createPatch(filepath, [hunk], \"100644\", invert).join(\"\\n\") +\n            \"\\n\";\n        await this.gitManager.applyPatch(patch);\n\n        this.plugin.app.workspace.trigger(\"obsidian-git:refresh\");\n    }\n\n    goToHunk(direction: \"first\" | \"last\" | \"next\" | \"prev\"): void {\n        if (!this.editor) {\n            return;\n        }\n        const { editor, obEditor } = this.editor;\n        const hunks = HunksStateHelper.getHunks(editor.state, false);\n\n        const currentLine = obEditor.getCursor().line + 1;\n        const hunkIndex = Hunks.findNearestHunk(\n            currentLine,\n            hunks,\n            direction,\n            true\n        );\n        if (hunkIndex == undefined) {\n            return;\n        }\n        const hunk = hunks[hunkIndex];\n\n        if (hunk) {\n            const line = hunk.added.start - 1;\n            obEditor.setCursor(line, 0);\n            obEditor.scrollIntoView(\n                {\n                    from: { line: line, ch: 0 },\n                    to: { line: line + 1, ch: 0 },\n                },\n                true\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "src/editor/signs/hunkState.ts",
    "content": "import {\n    ChangeDesc,\n    EditorState,\n    StateEffect,\n    StateField,\n    Text,\n    Transaction,\n} from \"@codemirror/state\";\nimport { Hunks, type Hunk } from \"./hunks\";\nimport { computeHunks } from \"./diff\";\nimport type { Chunk } from \"@codemirror/merge\";\nimport { pluginRef } from \"src/pluginGlobalRef\";\nimport {\n    debounce,\n    editorEditorField,\n    editorInfoField,\n    type Debouncer,\n} from \"obsidian\";\n\n/**\n * Given a document and a position, return the corresponding line number in the\n * file.\n */\nexport function lineFromPos(doc: Text, pos: number): number {\n    const lineData = doc.lineAt(pos);\n    const no_nl_at_eof = !(\n        lineData.text.length == 0 && lineData.number == doc.lines\n    );\n    const fileLine = no_nl_at_eof ? lineData.number : lineData.number - 1;\n    return fileLine;\n}\n\nexport abstract class HunksStateHelper {\n    static hasHunksData(state: EditorState): boolean {\n        const data = state.field(hunksState, false);\n        return !!data && !data.isDirty;\n    }\n\n    static getHunks(state: EditorState, staged: boolean): Hunk[] {\n        const data = state.field(hunksState);\n        if (!data) return [];\n        return staged ? data.stagedHunks : data.hunks;\n    }\n\n    static getHunkAtPos(\n        state: EditorState,\n        pos: number,\n        staged: boolean\n    ): Hunk | undefined {\n        const data = state.field(hunksState);\n        if (!data) return undefined;\n        const line = state.doc.lineAt(pos).number;\n\n        const hunks = this.getHunks(state, staged);\n        return Hunks.findHunk(line, hunks)[0];\n    }\n\n    static getCursorHunk(\n        state: EditorState,\n        staged: boolean\n    ): Hunk | undefined {\n        const data = state.field(hunksState);\n        if (!data) return undefined;\n        const cursorLine = state.selection.main.head;\n        return this.getHunkAtPos(state, cursorLine, staged);\n    }\n\n    static getHunk(\n        state: EditorState,\n        staged: boolean,\n        pos?: number\n    ): Hunk | undefined {\n        if (pos != undefined) {\n            return this.getHunkAtPos(state, pos, staged);\n        }\n        if (state.selection.main.empty) {\n            return this.getCursorHunk(state, staged);\n        }\n\n        const from = state.selection.main.from;\n        const to = state.selection.main.to;\n        const fromLine = state.doc.lineAt(from).number;\n        const toLine = lineFromPos(state.doc, to);\n\n        const hunks = this.getHunks(state, staged);\n        const hunk = Hunks.createPartialHunk(hunks, fromLine, toLine);\n        if (!hunk) {\n            return undefined;\n        }\n\n        const data = state.field(hunksState)!;\n\n        if (staged) {\n            let stagedTop = fromLine;\n            let stagedBot = toLine;\n            for (const h of data.hunks) {\n                if (fromLine > h.vend) {\n                    stagedTop = stagedTop - (h.added.count - h.removed.count);\n                }\n                if (toLine > h.vend) {\n                    stagedBot = stagedBot - (h.added.count - h.removed.count);\n                }\n            }\n            hunk.added.lines = data\n                .compareText!.split(\"\\n\")\n                .slice(stagedTop - 1, stagedBot);\n            if (data.compareTextHead) {\n                hunk.removed.lines = data.compareTextHead\n                    .split(\"\\n\")\n                    .slice(\n                        hunk.removed.start - 1,\n                        hunk.removed.start - 1 + hunk.removed.count\n                    );\n            } else {\n                hunk.removed.lines = [];\n            }\n        } else {\n            hunk.added.lines = state.doc\n                .toString()\n                .split(\"\\n\")\n                .slice(fromLine - 1, toLine);\n            const no_nl_at_eof =\n                toLine === state.doc.lines &&\n                !state.doc.toString().endsWith(\"\\n\");\n            if (no_nl_at_eof) {\n                hunk.added.no_nl_at_eof = true;\n            }\n            hunk.removed.lines = data\n                .compareText!.split(\"\\n\")\n                .slice(\n                    hunk.removed.start - 1,\n                    hunk.removed.start - 1 + hunk.removed.count\n                );\n            if (\n                hunk.removed.start + hunk.removed.count - 1 ===\n                    data.compareText!.split(\"\\n\").length &&\n                !data.compareText!.endsWith(\"\\n\")\n            ) {\n                hunk.removed.no_nl_at_eof = true;\n            }\n        }\n        return hunk;\n    }\n}\n\nexport const hunksState: StateField<HunksData | undefined> = StateField.define<\n    HunksData | undefined\n>({\n    create: (_state) => undefined,\n    update: (previous, transaction) => {\n        const hunksData: HunksData = previous\n            ? { ...previous }\n            : {\n                  maxDiffTimeMs: 0,\n                  hunks: [],\n                  stagedHunks: [],\n                  chunks: undefined,\n                  isDirty: false,\n              };\n        let newCompare = false;\n\n        for (const effect of transaction.effects) {\n            if (effect.is(GitCompareResultEffectType)) {\n                hunksData.compareText = effect.value.compareText;\n                hunksData.compareTextHead = effect.value.compareTextHead;\n\n                // Only issue new hunk computation if compareText has changed\n                newCompare = previous?.compareText !== effect.value.compareText;\n                if (newCompare) {\n                    hunksData.chunks = undefined;\n                }\n            }\n            if (effect.is(DebouncedComputeHunksEffectType)) {\n                applyHunkComputation(\n                    hunksData,\n                    effect.value,\n                    transaction.state\n                );\n            }\n        }\n        if (hunksData.compareText !== undefined) {\n            if (newCompare || transaction.docChanged) {\n                hunksData.isDirty = true;\n                const res = scheduleHunkComputation(\n                    transaction,\n                    hunksData.compareText,\n                    hunksData.chunks,\n                    hunksData.maxDiffTimeMs\n                );\n                if (res) {\n                    applyHunkComputation(hunksData, res, transaction.state);\n                }\n            }\n        } else {\n            hunksData.compareText = undefined;\n            hunksData.compareTextHead = undefined;\n            hunksData.chunks = undefined;\n            hunksData.hunks = [];\n            hunksData.stagedHunks = [];\n            hunksData.isDirty = false;\n        }\n        return hunksData;\n    },\n});\n\nfunction applyHunkComputation(\n    hunkData: HunksData,\n    computeData: ComputedHunksData,\n    state: EditorState\n) {\n    hunkData.hunks = computeData.hunks;\n    hunkData.chunks = computeData.chunks;\n    hunkData.isDirty = false;\n    hunkData.maxDiffTimeMs = Math.max(\n        0.95 * hunkData.maxDiffTimeMs,\n        computeData.diffDuration\n    );\n    const file = state.field(editorInfoField).file;\n    pluginRef.plugin?.editorIntegration.signsFeature.changeStatusBar?.display(\n        hunkData.hunks,\n        file\n    );\n}\n\nexport const computeHunksDebouncerStateField = StateField.define<{\n    changeDesc?: ChangeDesc;\n    debouncer: Debouncer<\n        [\n            {\n                state: EditorState;\n                compareText: string;\n                previousChunks: readonly Chunk[] | undefined;\n                changeDesc: ChangeDesc | undefined;\n            },\n        ],\n        void\n    >;\n}>({\n    create: () => {\n        return {\n            debouncer: debounce(\n                (data) => {\n                    const { state, compareText, previousChunks, changeDesc } =\n                        data;\n                    const res = computeHunksTimed(\n                        state,\n                        compareText,\n                        previousChunks,\n                        changeDesc\n                    );\n                    state.field(editorEditorField).dispatch({\n                        effects: DebouncedComputeHunksEffectType.of(res),\n                    });\n                },\n                1000,\n                true\n            ),\n            maxDiffTimeMs: 0,\n        };\n    },\n    update: (data, transaction) => {\n        for (const effect of transaction.effects) {\n            if (effect.is(DebouncedComputeHunksEffectType)) {\n                data.changeDesc = undefined;\n                return data;\n            }\n        }\n        if (!data.changeDesc && transaction.changes) {\n            data.changeDesc = transaction.changes;\n        } else {\n            data.changeDesc = data.changeDesc?.composeDesc(transaction.changes);\n        }\n        return data;\n    },\n});\n\nfunction computeHunksTimed(\n    state: EditorState,\n    compareText: string,\n    previousChunks: readonly Chunk[] | undefined,\n    changeDesc: ChangeDesc | undefined\n): ComputedHunksData {\n    const editorText = state.doc.toString();\n\n    const startTime = performance.now();\n    const { hunks, chunks } = computeHunks(\n        compareText,\n        editorText,\n        previousChunks,\n        changeDesc\n    );\n    const diffDuration = performance.now() - startTime;\n    return { hunks, chunks, diffDuration };\n}\n\nfunction scheduleHunkComputation(\n    transaction: Transaction,\n    compareText: string,\n    previousChunks: readonly Chunk[] | undefined,\n    maxDiffTimeMs: number\n): ComputedHunksData | undefined {\n    const state = transaction.state;\n    const changeLength = Math.abs(\n        transaction.changes.length - transaction.changes.newLength\n    );\n\n    const debouncerField = state.field(computeHunksDebouncerStateField);\n\n    // Debounce large changes or if a previous diff took long time\n    if (changeLength > 1000 || maxDiffTimeMs > 16) {\n        debouncerField.debouncer({\n            state,\n            compareText,\n            previousChunks,\n            changeDesc: debouncerField.changeDesc,\n        });\n    } else {\n        // This technically breaks the immutability of the StateField, but I\n        // think it's acceptable here. The debouncer itself is not very\n        // immutable either way.\n        debouncerField.changeDesc = undefined;\n\n        return computeHunksTimed(\n            state,\n            compareText,\n            previousChunks,\n            transaction.changes\n        );\n    }\n}\n\nexport const GitCompareResultEffectType =\n    StateEffect.define<GitCompareResult>();\n\nexport const DebouncedComputeHunksEffectType =\n    StateEffect.define<ComputedHunksData>();\n\nexport type ComputedHunksData = {\n    hunks: Hunk[];\n    chunks: readonly Chunk[] | undefined;\n    diffDuration: number;\n};\n\nexport type HunksData = {\n    hunks: Hunk[];\n    stagedHunks: Hunk[];\n    chunks: readonly Chunk[] | undefined;\n    isDirty: boolean;\n    maxDiffTimeMs: number;\n} & GitCompareResult;\n\nexport type GitCompareResult = {\n    compareText?: string;\n    compareTextHead?: string;\n};\n\nexport function newGitCompareResultAsTransaction(\n    data: GitCompareResult,\n    state: EditorState\n): Transaction {\n    return state.update({\n        effects: GitCompareResultEffectType.of(data),\n    });\n}\n"
  },
  {
    "path": "src/editor/signs/hunks.ts",
    "content": "/**\n * This file contains code translated from Lua to TypeScript.\n * Original Source: https://github.com/lewis6991/gitsigns.nvim/blob/main/lua/gitsigns/hunks.lua\n * Original Author: Lewis Russell\n * License: MIT\n * Original Copyright (c) 2020 Lewis Russell\n */\nimport type { GitCompareResult } from \"./hunkState\";\n\nexport type HunkType = \"add\" | \"change\" | \"delete\";\n\nexport interface HunkNode {\n    start: number;\n    count: number;\n    lines: string[];\n    no_nl_at_eof?: true;\n}\n\nexport interface Hunk {\n    type: HunkType;\n    head: string;\n    added: HunkNode;\n    removed: HunkNode;\n    vend: number;\n}\n\nexport type SignType = HunkType | \"topdelete\" | \"changedelete\" | \"untracked\";\n\nexport interface Sign {\n    type: SignType;\n    /// Number of lines added/removed. Only set on the first line of a hunk.\n    count?: number;\n    lnum: number;\n}\n\nexport interface StatusObj {\n    added: number;\n    changed: number;\n    removed: number;\n}\n\nexport abstract class Hunks {\n    static createHunk(\n        oldStart: number,\n        oldCount: number,\n        newStart: number,\n        newCount: number\n    ): Hunk {\n        return {\n            removed: { start: oldStart, count: oldCount, lines: [] },\n            added: { start: newStart, count: newCount, lines: [] },\n            head:\n                `@@ -${oldStart}${oldCount > 0 ? `,${oldCount}` : \"\"} ` +\n                `+${newStart}${newCount > 0 ? `,${newCount}` : \"\"} @@`,\n            vend: newStart + Math.max(newCount - 1, 0),\n            type: newCount === 0 ? \"delete\" : oldCount === 0 ? \"add\" : \"change\",\n        };\n    }\n\n    static createPartialHunk(\n        hunks: Hunk[],\n        top: number,\n        bot: number\n    ): Hunk | undefined {\n        let pretop = top;\n        let precount = bot - top + 1;\n        let unused = 0;\n\n        for (const h of hunks) {\n            const addedInHunk = h.added.count - h.removed.count;\n            let addedInRange = 0;\n\n            if (h.added.start >= top && h.vend <= bot) {\n                addedInRange = addedInHunk;\n            } else {\n                const addedAboveBot = Math.max(\n                    0,\n                    bot + 1 - (h.added.start + h.removed.count)\n                );\n                const addedAboveTop = Math.max(\n                    0,\n                    top - (h.added.start + h.removed.count)\n                );\n\n                if (h.added.start >= top && h.added.start <= bot) {\n                    addedInRange = addedAboveBot;\n                } else if (h.vend >= top && h.vend <= bot) {\n                    addedInRange = addedInHunk - addedAboveTop;\n                    pretop = pretop - addedAboveTop;\n                } else if (h.added.start <= top && h.vend >= bot) {\n                    addedInRange = addedAboveBot - addedAboveTop;\n                    pretop = pretop - addedAboveTop;\n                } else {\n                    unused++;\n                }\n\n                if (top > h.vend) {\n                    pretop = pretop - addedInHunk;\n                }\n            }\n\n            precount = precount - addedInRange;\n        }\n\n        if (unused === hunks.length) {\n            return undefined;\n        }\n\n        if (precount === 0) {\n            pretop = pretop - 1;\n        }\n\n        return this.createHunk(pretop, precount, top, bot - top + 1);\n    }\n\n    patchLines(hunk: Hunk, stripCr: boolean = false): string[] {\n        const lines: string[] = [];\n\n        for (const l of hunk.removed.lines) {\n            lines.push(\"-\" + l);\n        }\n        for (const l of hunk.added.lines) {\n            lines.push(\"+\" + l);\n        }\n\n        if (stripCr) {\n            return lines.map((l) => l.replace(/\\r$/, \"\"));\n        }\n        return lines;\n    }\n\n    static parseDiffLine(line: string): Hunk {\n        const parts = line.split(\"@@\");\n        const diffkey = parts[1].trim();\n\n        // diffkey: \"-xx,n +yy,m\"\n        const tokens = diffkey.split(\" \");\n        const pre = tokens[0].substring(1).split(\",\");\n        const now = tokens[1].substring(1).split(\",\");\n\n        const hunk = this.createHunk(\n            parseInt(pre[0]),\n            parseInt(pre[1] || \"1\"),\n            parseInt(now[0]),\n            parseInt(now[1] || \"1\")\n        );\n\n        hunk.head = line;\n        return hunk;\n    }\n\n    private static changeEnd(hunk: Hunk): number {\n        if (hunk.added.count === 0) {\n            return hunk.added.start;\n        } else if (hunk.removed.count === 0) {\n            return hunk.added.start + hunk.added.count - 1;\n        } else {\n            return (\n                hunk.added.start +\n                Math.min(hunk.added.count, hunk.removed.count) -\n                1\n            );\n        }\n    }\n\n    static calcSigns(\n        prevHunk: Hunk | undefined,\n        hunk: Hunk,\n        nextHunk: Hunk | undefined,\n        minLnum: number = 1,\n        maxLnum: number = Infinity,\n        untracked?: boolean\n    ): Sign[] {\n        if (untracked && hunk.type !== \"add\") {\n            console.error(\n                `Invalid hunk with untracked=${untracked} hunk=\"${hunk.head}\"`\n            );\n            return [];\n        }\n\n        minLnum = Math.max(1, minLnum);\n\n        const { start, added, removed } = {\n            start: hunk.added.start,\n            added: hunk.added.count,\n            removed: hunk.removed.count,\n        };\n\n        const cend = this.changeEnd(hunk);\n\n        const topdelete =\n            hunk.type === \"delete\" &&\n            (start === 0 || (prevHunk && this.changeEnd(prevHunk) === start)) &&\n            (!nextHunk || nextHunk.added.start !== start + 1);\n\n        if (topdelete && minLnum === 1) {\n            minLnum = 0;\n        }\n\n        const signs: Sign[] = [];\n\n        for (\n            let lnum = Math.max(start, minLnum);\n            lnum <= Math.min(cend, maxLnum);\n            lnum++\n        ) {\n            const changedelete =\n                hunk.type === \"change\" &&\n                ((removed > added && lnum === cend) ||\n                    (prevHunk && prevHunk.added.start === 0));\n\n            signs.push({\n                type: topdelete\n                    ? \"topdelete\"\n                    : changedelete\n                      ? \"changedelete\"\n                      : untracked\n                        ? \"untracked\"\n                        : hunk.type,\n                count:\n                    lnum === start\n                        ? hunk.type === \"add\"\n                            ? added\n                            : removed\n                        : undefined,\n                lnum: lnum + (topdelete ? 1 : 0),\n            });\n        }\n\n        if (\n            hunk.type === \"change\" &&\n            added > removed &&\n            hunk.vend >= minLnum &&\n            cend <= maxLnum\n        ) {\n            for (\n                let lnum = Math.max(cend, minLnum);\n                lnum <= Math.min(hunk.vend, maxLnum);\n                lnum++\n            ) {\n                signs.push({\n                    type: \"add\",\n                    count: lnum === hunk.vend ? added - removed : undefined,\n                    lnum,\n                });\n            }\n        }\n\n        return signs;\n    }\n\n    static createPatch(\n        relpath: string,\n        hunks: Hunk[],\n        modeBits: string,\n        invert: boolean = false\n    ): string[] {\n        const results = [\n            `diff --git a/${relpath} b/${relpath}`,\n            `index 000000..000000 ${modeBits}`,\n            `--- a/${relpath}`,\n            `+++ b/${relpath}`,\n        ];\n\n        let offset = 0;\n\n        hunks = structuredClone(hunks);\n        for (const processHunk of hunks) {\n            let start = processHunk.removed.start;\n            let preCount = processHunk.removed.count;\n            let nowCount = processHunk.added.count;\n\n            if (processHunk.type === \"add\") {\n                start = start + 1;\n            }\n\n            let preLines = processHunk.removed.lines;\n            let nowLines = processHunk.added.lines;\n\n            if (invert) {\n                [preCount, nowCount] = [nowCount, preCount];\n                [preLines, nowLines] = [nowLines, preLines];\n            }\n\n            results.push(\n                `@@ -${start},${preCount} +${start + offset},${nowCount} @@`\n            );\n\n            for (const l of preLines) {\n                results.push(\"-\" + l);\n            }\n\n            if (\n                (invert ? processHunk.added : processHunk.removed).no_nl_at_eof\n            ) {\n                results.push(\"\\\\ No newline at end of file\");\n            }\n\n            for (const l of nowLines) {\n                results.push(\"+\" + l);\n            }\n\n            if (\n                (invert ? processHunk.removed : processHunk.added).no_nl_at_eof\n            ) {\n                results.push(\"\\\\ No newline at end of file\");\n            }\n\n            processHunk.removed.start = start + offset;\n            offset = offset + (nowCount - preCount);\n        }\n\n        return results;\n    }\n\n    getSummary(hunks: Hunk[]): StatusObj {\n        const status: StatusObj = { added: 0, changed: 0, removed: 0 };\n\n        for (const hunk of hunks) {\n            if (hunk.type === \"add\") {\n                status.added += hunk.added.count;\n            } else if (hunk.type === \"delete\") {\n                status.removed += hunk.removed.count;\n            } else if (hunk.type === \"change\") {\n                const add = hunk.added.count;\n                const remove = hunk.removed.count;\n                const delta = Math.min(add, remove);\n                status.changed += delta;\n                status.added += add - delta;\n                status.removed += remove - delta;\n            }\n        }\n\n        return status;\n    }\n\n    static findHunk(\n        lnum: number,\n        hunks?: Hunk[]\n    ): [Hunk, number] | [undefined, undefined] {\n        if (!hunks) return [undefined, undefined];\n\n        for (let i = 0; i < hunks.length; i++) {\n            const hunk = hunks[i];\n            if (lnum === 1 && hunk.added.start === 0 && hunk.vend === 0) {\n                return [hunk, i];\n            }\n\n            if (hunk.added.start <= lnum && hunk.vend >= lnum) {\n                return [hunk, i];\n            }\n        }\n\n        return [undefined, undefined];\n    }\n\n    static findNearestHunk(\n        lnum: number,\n        hunks: Hunk[],\n        direction: \"first\" | \"last\" | \"next\" | \"prev\",\n        wrap?: boolean\n    ): number | undefined {\n        if (hunks.length === 0) {\n            return undefined;\n        } else if (direction === \"first\") {\n            return 0;\n        } else if (direction === \"last\") {\n            return hunks.length - 1;\n        } else if (direction === \"next\") {\n            if (hunks[0].added.start > lnum) {\n                return 0;\n            }\n            for (let i = hunks.length - 1; i >= 0; i--) {\n                if (hunks[i].added.start <= lnum) {\n                    if (\n                        i + 1 < hunks.length &&\n                        hunks[i + 1].added.start > lnum\n                    ) {\n                        return i + 1;\n                    } else if (wrap) {\n                        return 0;\n                    }\n                }\n            }\n        } else if (direction === \"prev\") {\n            if (Math.max(hunks[hunks.length - 1].vend) < lnum) {\n                return hunks.length - 1;\n            }\n            for (let i = 0; i < hunks.length; i++) {\n                if (lnum <= Math.max(hunks[i].vend, 1)) {\n                    if (i > 0 && Math.max(hunks[i - 1].vend, 1) < lnum) {\n                        return i - 1;\n                    } else if (wrap) {\n                        return hunks.length - 1;\n                    }\n                }\n            }\n        }\n        return undefined;\n    }\n\n    compareHeads(a?: Hunk[], b?: Hunk[]): boolean {\n        if ((a === undefined) !== (b === undefined)) {\n            return true;\n        } else if (a && b && a.length !== b.length) {\n            return true;\n        }\n        for (let i = 0; i < (a || []).length; i++) {\n            if (b![i].head !== a![i].head) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private static compare(a: Hunk, b: Hunk): boolean {\n        if (a.added.start !== b.added.start) {\n            return false;\n        }\n\n        if (a.added.count !== b.added.count) {\n            return false;\n        }\n\n        for (let i = 0; i < a.added.count; i++) {\n            if (a.added.lines[i] !== b.added.lines[i]) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    private static filterCommon(a?: Hunk[], b?: Hunk[]): Hunk[] | undefined {\n        if (!a && !b) {\n            return undefined;\n        }\n\n        a = a || [];\n        b = b || [];\n\n        let aI = 0;\n        let bI = 0;\n\n        const ret: Hunk[] = [];\n\n        for (let _ = 0; _ < Math.max(a.length, b.length) + 1; _++) {\n            const aH = a[aI];\n            const bH = b[bI];\n\n            // End of a\n            if (!aH) {\n                break;\n            }\n\n            // End of b and add remaining a\n            if (!bH) {\n                for (let i = aI; i < a.length; i++) {\n                    ret.push(a[i]);\n                }\n                break;\n            }\n\n            if (aH.added.start > bH.added.start) {\n                bI++;\n            } else if (aH.added.start < bH.added.start) {\n                ret.push(aH);\n                aI++;\n            } else {\n                if (!this.compare(aH, bH)) {\n                    // let topOffset = 0;\n                    // for (let j = 0; j < aH.added.count; j++) {\n                    //     const lineA = aH.added.lines[j];\n                    //     const lineB = bH.added.lines[j];\n                    //\n                    //     if (!lineB) {\n                    //         topOffset = j;\n                    //         break;\n                    //     }\n                    //\n                    //     if (lineA !== lineB) {\n                    //         topOffset = j;\n                    //     }\n                    // }\n                    //\n                    // if (topOffset < aH.added.count) {\n                    //     const newHunk: Hunk = {\n                    //         head: aH.head,\n                    //         type: aH.type,\n                    //         added: {\n                    //             start: aH.added.start + topOffset,\n                    //             count: aH.added.count - topOffset,\n                    //             lines: aH.added.lines.slice(topOffset),\n                    //             no_nl_at_eof: aH.added.no_nl_at_eof,\n                    //         },\n                    //         removed: {\n                    //             start: aH.removed.start,\n                    //             count: aH.removed.count,\n                    //             lines: aH.removed.lines,\n                    //             no_nl_at_eof: aH.removed.no_nl_at_eof,\n                    //         },\n                    //         vend: aH.added.start + aH.added.count - 1,\n                    //     };\n                    //     ret.push(newHunk);\n                    // } else {\n                    ret.push(aH);\n                    // }\n                }\n                aI++;\n                bI++;\n            }\n        }\n\n        return ret;\n    }\n\n    static computeStagedHunks(\n        headHunks: Hunk[],\n        hunks: Hunk[],\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars\n        compare: GitCompareResult\n    ): Hunk[] {\n        const filteredHunks = Hunks.filterCommon(headHunks, hunks)!;\n        //\n        // // Update staged hunk added lines to match compareText and not\n        // // include unstaged changes\n        // const compareTextLines = compare.compareText!.split(\"\\n\");\n        // for (const hunk of filteredHunks) {\n        //     for (let i = 0; i < hunk.added.lines.length; i++) {\n        //         hunk.added.lines[i] =\n        //             compareTextLines[Math.max(0, hunk.removed.start - 1) + i];\n        //     }\n        //     let offset = 0;\n        //     for (\n        //         let i = 0;\n        //         i <\n        //         Math.min(hunk.added.lines.length, hunk.removed.lines.length);\n        //         i++\n        //     ) {\n        //         if (hunk.added.lines[i] != hunk.removed.lines[i]) {\n        //             break;\n        //         } else {\n        //             offset++;\n        //         }\n        //     }\n        //     if (offset > 0) {\n        //         hunk.added.lines = hunk.added.lines.slice(offset);\n        //         hunk.removed.lines = hunk.removed.lines.slice(offset);\n        //         hunk.added.start += offset;\n        //         hunk.removed.start += offset;\n        //         hunk.added.count -= offset;\n        //         hunk.removed.count -= offset;\n        //     }\n        // }\n        return filteredHunks;\n    }\n}\n"
  },
  {
    "path": "src/editor/signs/signsIntegration.ts",
    "content": "import type { Extension } from \"@codemirror/state\";\nimport type { EventRef, WorkspaceLeaf } from \"obsidian\";\nimport { MarkdownView, Platform, TFile } from \"obsidian\";\nimport { SimpleGit } from \"src/gitManager/simpleGit\";\nimport type ObsidianGit from \"src/main\";\nimport {\n    enabledHunksExtensions,\n    enabledSignsExtensions,\n    SignsProvider,\n} from \"./signsProvider\";\nimport { eventsPerFilePathSingleton } from \"../eventsPerFilepath\";\nimport { ChangesStatusBar } from \"./changesStatusBar\";\n\n/**\n * Manages the interaction between Obsidian (file-open event, modification event, etc.)\n * and the signs feature. It also manages the (de-) activation of the\n * signs functionality.\n */\nexport class SignsFeature {\n    private signsProvider?: SignsProvider;\n    private workspaceLeafChangeEvent?: EventRef;\n    private fileRenameEvent?: EventRef;\n    private intervalRefreshEvent?: number;\n    private pluginRefreshedEvent?: EventRef;\n    private gutterContextMenuEvent?: EventRef;\n    private codeMirrorExtensions: Extension[] = [];\n    public changeStatusBar?: ChangesStatusBar;\n\n    constructor(private plg: ObsidianGit) {}\n\n    // ========================= INIT and DE-INIT ==========================\n\n    public onLoadPlugin() {\n        this.plg.registerEditorExtension(this.codeMirrorExtensions);\n    }\n\n    public conditionallyActivateBySettings() {\n        if (\n            this.plg.settings.hunks.showSigns ||\n            this.plg.settings.hunks.statusBar != \"disabled\" ||\n            this.plg.settings.hunks.hunkCommands\n        ) {\n            this.activateFeature();\n        }\n    }\n\n    public activateFeature() {\n        try {\n            if (!this.isAvailableOnCurrentPlatform().available) return;\n\n            this.signsProvider = new SignsProvider(this.plg);\n\n            this.createEventHandlers();\n\n            this.activateCodeMirrorExtensions();\n\n            if (this.plg.settings.hunks.statusBar != \"disabled\") {\n                const statusBarEl = this.plg.addStatusBarItem();\n                this.changeStatusBar = new ChangesStatusBar(\n                    statusBarEl,\n                    this.plg\n                );\n            }\n        } catch (e) {\n            console.warn(\"Git: Error while loading signs feature.\", e);\n            this.deactivateFeature();\n        }\n    }\n\n    /**\n     * Deactivates the feature. This function is very defensive, as it is also\n     * called to cleanup, if a critical error in the line authoring has occurred.\n     */\n    public deactivateFeature() {\n        this.destroyEventHandlers();\n\n        this.deactivateCodeMirrorExtensions();\n\n        this.signsProvider?.destroy();\n        this.signsProvider = undefined;\n        this.changeStatusBar?.remove();\n        this.changeStatusBar = undefined;\n    }\n\n    public isAvailableOnCurrentPlatform(): {\n        available: boolean;\n        gitManager: SimpleGit;\n    } {\n        return {\n            available: this.plg.useSimpleGit && Platform.isDesktopApp,\n            gitManager:\n                this.plg.gitManager instanceof SimpleGit\n                    ? this.plg.gitManager\n                    : undefined!,\n        };\n    }\n\n    // ========================= REFRESH ==========================\n\n    public refresh() {\n        if (this.plg.settings.hunks.showSigns) {\n            this.plg.app.workspace.iterateAllLeaves(this.handleWorkspaceLeaf);\n        }\n    }\n\n    // ========================= CODEMIRROR EXTENSIONS ==========================\n\n    private activateCodeMirrorExtensions() {\n        // Yes, we need to directly modify the array and notify the change to have\n        // toggleable Codemirror extensions.\n        this.codeMirrorExtensions.push(enabledHunksExtensions);\n        if (this.plg.settings.hunks.showSigns) {\n            this.codeMirrorExtensions.push(...enabledSignsExtensions);\n        }\n        this.plg.app.workspace.updateOptions();\n\n        // Handle all already opened files\n        this.plg.app.workspace.iterateAllLeaves(this.handleWorkspaceLeaf);\n    }\n\n    private deactivateCodeMirrorExtensions() {\n        // Yes, we need to directly modify the array and notify the change to have\n        // toggleable Codemirror extensions.\n        for (const ext of this.codeMirrorExtensions) {\n            this.codeMirrorExtensions.remove(ext);\n        }\n        this.plg.app.workspace.updateOptions();\n    }\n\n    // ========================= HANDLERS ==========================\n\n    private createEventHandlers() {\n        this.workspaceLeafChangeEvent = this.createWorkspaceLeafChangeEvent();\n        this.fileRenameEvent = this.createFileRenameEvent();\n        this.pluginRefreshedEvent = this.createPluginRefreshedEvent();\n\n        this.intervalRefreshEvent = this.createIntervalRefreshEvent();\n\n        this.plg.registerEvent(this.workspaceLeafChangeEvent);\n        this.plg.registerEvent(this.fileRenameEvent);\n        this.plg.registerEvent(this.pluginRefreshedEvent);\n        this.plg.registerInterval(this.intervalRefreshEvent);\n    }\n\n    private destroyEventHandlers() {\n        this.plg.app.workspace.offref(this.workspaceLeafChangeEvent!);\n        this.plg.app.vault.offref(this.fileRenameEvent!);\n        this.plg.app.workspace.offref(this.pluginRefreshedEvent!);\n        this.plg.app.workspace.offref(this.gutterContextMenuEvent!);\n        window.clearInterval(this.intervalRefreshEvent);\n    }\n\n    private handleWorkspaceLeaf = (leaf: WorkspaceLeaf) => {\n        if (!this.signsProvider) {\n            console.warn(\"Git: undefined signsProvider. Unexpected situation.\");\n            return;\n        }\n        const obsView = leaf?.view;\n\n        if (\n            !(obsView instanceof MarkdownView) ||\n            obsView.file == null ||\n            obsView?.allowNoFile === true\n        )\n            return;\n\n        this.signsProvider.trackChanged(obsView.file).catch(console.error);\n    };\n\n    private createWorkspaceLeafChangeEvent(): EventRef {\n        return this.plg.app.workspace.on(\n            \"active-leaf-change\",\n            this.handleWorkspaceLeaf\n        );\n    }\n\n    private createFileRenameEvent(): EventRef {\n        return this.plg.app.vault.on(\"rename\", (file, _old) => {\n            // Notify all subscribers of the old filepath to resubscribe to the new filepath\n            eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers(\n                _old,\n                (subs) => {\n                    return subs.forEach((las) => {\n                        las.changeToNewFilepath(file.path);\n                    });\n                }\n            );\n            return (\n                file instanceof TFile && this.signsProvider?.trackChanged(file)\n            );\n        });\n    }\n\n    private createPluginRefreshedEvent(): EventRef {\n        return this.plg.app.workspace.on(\"obsidian-git:refresh\", () => {\n            this.refresh();\n        });\n    }\n\n    private createIntervalRefreshEvent(): number {\n        // Refresh every 10 seconds the active editor to account for external\n        // git index changes\n        return window.setInterval(() => {\n            if (this.plg.app.workspace.activeEditor?.file) {\n                this.signsProvider\n                    ?.trackChanged(this.plg.app.workspace.activeEditor.file)\n                    .catch(console.error);\n            }\n        }, 10 * 1000);\n    }\n}\n"
  },
  {
    "path": "src/editor/signs/signsProvider.ts",
    "content": "import type { Extension } from \"@codemirror/state\";\nimport type { TFile } from \"obsidian\";\nimport { eventsPerFilePathSingleton } from \"src/editor/eventsPerFilepath\";\nimport type ObsidianGit from \"src/main\";\nimport {\n    computeHunksDebouncerStateField,\n    hunksState,\n    type GitCompareResult,\n} from \"../signs/hunkState\";\nimport { signsGutter, signsMarker } from \"../signs/gutter\";\nimport {\n    cursorTooltipBaseTheme,\n    diffTooltipField,\n    selectedHunksState,\n} from \"./tooltip\";\n\nexport { previewColor } from \"src/editor/lineAuthor/view/gutter/coloring\";\nexport class SignsProvider {\n    constructor(private plugin: ObsidianGit) {}\n\n    public async trackChanged(file: TFile) {\n        return this.trackChangedHelper(file).catch((reason) => {\n            console.warn(\"Git: Error in trackChanged.\" + reason);\n            // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors\n            return Promise.reject(reason);\n        });\n    }\n\n    private async trackChangedHelper(file: TFile) {\n        if (!file) return;\n\n        if (file.path === undefined) {\n            console.warn(\n                \"Git: Attempted to track change of undefined filepath. Unforeseen situation.\"\n            );\n            return;\n        }\n\n        return this.computeSigns(file.path);\n    }\n\n    public destroy() {}\n\n    private async computeSigns(filepath: string) {\n        const gitManager =\n            this.plugin.editorIntegration.lineAuthoringFeature.isAvailableOnCurrentPlatform()\n                .gitManager;\n\n        // const headRevision =\n        //     await gitManager.submoduleAwareHeadRevisonInContainingDirectory(\n        //         filepath\n        //     );\n\n        const compareText = await gitManager\n            .show(\"\", filepath)\n            .catch(() => undefined);\n        // const compareTextHead = await gitManager\n        //     .show(headRevision, filepath)\n        //     .catch(() => undefined);\n        const compareTextHead = undefined;\n        this.notifySignComputationResultToSubscribers(filepath, {\n            compareText,\n            compareTextHead,\n        });\n    }\n\n    private notifySignComputationResultToSubscribers(\n        filepath: string,\n        data: GitCompareResult\n    ) {\n        eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers(\n            filepath,\n            (subs) => subs.forEach((sub) => sub.notifyGitCompare(data))\n        );\n    }\n}\n\nexport const enabledSignsExtensions: Extension[] = [\n    diffTooltipField,\n    cursorTooltipBaseTheme,\n    signsGutter,\n    signsMarker,\n    selectedHunksState,\n];\n\nexport const enabledHunksExtensions = [\n    hunksState,\n    computeHunksDebouncerStateField,\n];\n"
  },
  {
    "path": "src/editor/signs/tooltip.ts",
    "content": "import { EditorState, StateEffect, StateField } from \"@codemirror/state\";\nimport {\n    EditorView,\n    showTooltip,\n    type Tooltip,\n    type TooltipView,\n} from \"@codemirror/view\";\nimport { GitCompareResultEffectType, hunksState } from \"./hunkState\";\nimport { Hunks, type Hunk } from \"./hunks\";\nimport { html } from \"diff2html\";\nimport { ColorSchemeType } from \"diff2html/lib/types\";\nimport { pluginRef } from \"src/pluginGlobalRef\";\nimport { editorEditorField, MarkdownView, setIcon } from \"obsidian\";\n\nconst selectHunkEffectType = StateEffect.define<{\n    pos: number;\n    add: boolean;\n}>();\n\nexport function togglePreviewHunk(editor: EditorView, pos?: number) {\n    const state = editor.state;\n    const selectedHunks = state.field(selectedHunksState);\n    const hunksData = state.field(hunksState);\n    const line = state.doc.lineAt(pos ?? state.selection.main.head).number;\n\n    const hunk = Hunks.findHunk(line, hunksData?.hunks)[0];\n    if (!hunk) return;\n    const hunkStartPos = state.doc.line(Math.max(1, hunk.added.start)).from;\n\n    const isSelected = selectedHunks.has(hunkStartPos);\n    return state.field(editorEditorField).dispatch({\n        effects: selectHunkEffectType.of({\n            pos: hunkStartPos,\n            add: !isSelected,\n        }),\n    });\n}\n\nexport const selectedHunksState = StateField.define<Set<number>>({\n    create: () => new Set<number>(),\n    update(value, transaction) {\n        const newValue = new Set<number>();\n        for (const effect of transaction.effects) {\n            if (effect.is(selectHunkEffectType)) {\n                if (effect.value.add) {\n                    value.add(effect.value.pos);\n                } else {\n                    value.delete(effect.value.pos);\n                }\n            }\n        }\n        for (const pos of value) {\n            newValue.add(transaction.changes.mapPos(pos));\n        }\n        return newValue;\n    },\n});\n\nexport const diffTooltipField = StateField.define<readonly Tooltip[]>({\n    create: (state) => {\n        return getTooltips(state);\n    },\n    update(value, transaction) {\n        if (\n            transaction.docChanged ||\n            transaction.effects.some(\n                (e) =>\n                    e.is(GitCompareResultEffectType) ||\n                    e.is(selectHunkEffectType)\n            )\n        ) {\n            return getTooltips(transaction.state);\n        }\n        return value;\n    },\n    provide: (f) => showTooltip.computeN([f], (state) => state.field(f)),\n});\n\nexport const cursorTooltipBaseTheme = EditorView.baseTheme({\n    \".cm-tooltip.git-diff-tooltip\": {\n        \"z-index\": \"var(--layer-popover)\",\n        backgroundColor: \"var(--background-primary-alt)\",\n        border: \"var(--border-width) solid var(--background-primary-alt)\",\n        borderRadius: \"var(--radius-s)\",\n    },\n    \".cm-tooltip.git-diff-tooltip .tooltip-toolbar\": {\n        display: \"flex\",\n        padding: \"var(--size-2-1)\",\n    },\n});\n\nfunction getTooltips(state: EditorState): Tooltip[] {\n    const hunksData = state.field(hunksState);\n    if (hunksData) {\n        const selectedHunks = state.field(selectedHunksState);\n        return [...selectedHunks]\n            .map((selectedPos) => {\n                const line = state.doc.lineAt(selectedPos);\n                const hunk = Hunks.findHunk(line.number, hunksData.hunks)[0];\n                if (!hunk) return undefined;\n                return {\n                    pos: selectedPos,\n                    above: false,\n                    arrow: false,\n                    strictSide: true,\n                    clip: false,\n                    create: () => {\n                        return createTooltip(hunk, state, selectedPos);\n                    },\n                };\n            })\n            .filter((tip) => tip !== undefined);\n    } else {\n        return [];\n    }\n}\n\nfunction createTooltip(\n    hunk: Hunk,\n    state: EditorState,\n    pos: number\n): TooltipView {\n    const patch =\n        Hunks.createPatch(\"file\", [hunk], \"10064\", false).join(\"\\n\") + \"\\n\";\n    const patchHtml = html(patch, {\n        colorScheme: ColorSchemeType.AUTO,\n        diffStyle: \"word\",\n        drawFileList: false,\n    });\n    const diffEl = new DOMParser()\n        .parseFromString(patchHtml, \"text/html\")\n        .querySelector(\".d2h-file-diff\");\n\n    const contentEl = document.createElement(\"div\");\n\n    // toolbar\n    const toolbar = document.createElement(\"div\");\n    toolbar.addClass(\"tooltip-toolbar\");\n\n    const makeButton = (icon: string, label: string) => {\n        const btn = document.createElement(\"div\");\n        setIcon(btn, icon);\n        btn.setAttr(\"aria-label\", label);\n        btn.addClass(\"clickable-icon\");\n        return btn;\n    };\n\n    const closeBtn = makeButton(\"x\", \"Close hunk\");\n    const stageBtn = makeButton(\"plus\", \"Stage hunk\");\n    const resetBtn = makeButton(\"undo\", \"Reset hunk\");\n\n    toolbar.appendChild(closeBtn);\n    toolbar.appendChild(stageBtn);\n    toolbar.appendChild(resetBtn);\n\n    // append toolbar and diff\n    contentEl.appendChild(toolbar);\n    contentEl.appendChild(diffEl!);\n    contentEl.addClass(\"git-diff-tooltip\", \"git-diff\");\n\n    const editor = state.field(editorEditorField);\n    // handlers\n    closeBtn.onclick = () => {\n        togglePreviewHunk(editor, pos);\n    };\n\n    stageBtn.onclick = () => {\n        const plugin = pluginRef.plugin;\n        if (!plugin) return;\n        plugin.promiseQueue.addTask(() => plugin.hunkActions.stageHunk(pos));\n\n        togglePreviewHunk(editor, pos);\n    };\n\n    resetBtn.onclick = () => {\n        const plugin = pluginRef.plugin;\n        if (!plugin) return;\n        plugin.hunkActions.resetHunk(pos);\n        togglePreviewHunk(editor, pos);\n    };\n\n    const scope =\n        pluginRef.plugin?.app.workspace.getActiveViewOfType(\n            MarkdownView\n        )?.scope;\n\n    const eventHandler = scope?.register(null, \"Escape\", (_, __) => {\n        // close on escape\n        togglePreviewHunk(editor, pos);\n    });\n\n    return {\n        dom: contentEl,\n        destroy: () => {\n            if (eventHandler) {\n                scope?.unregister(eventHandler);\n            }\n        },\n        update: (update) => {\n            pos = update.changes.mapPos(pos);\n        },\n    };\n}\n"
  },
  {
    "path": "src/externalLibTypes.d.ts",
    "content": "declare module \"css-color-converter\" {\n    /* The following list of type definitions is incomplete! */\n\n    class Color {\n        toRgbaArray(): [number, number, number, number];\n        toRgbString(): string;\n        toRgbaString(): string;\n        toHslString(): string;\n        toHslaString(): string;\n        toHexString(): string;\n    }\n    function fromString(str: string): Color | null;\n}\n"
  },
  {
    "path": "src/gitManager/gitManager.ts",
    "content": "import { hostname as osHostname } from \"os\";\nimport { type App, moment, Platform } from \"obsidian\";\nimport type ObsidianGit from \"../main\";\nimport type {\n    BranchInfo,\n    DiffFile,\n    FileStatusResult,\n    LogEntry,\n    Status,\n    TreeItem,\n    UnstagedFile,\n} from \"../types\";\n\nexport abstract class GitManager {\n    readonly plugin: ObsidianGit;\n    readonly app: App;\n    constructor(plugin: ObsidianGit) {\n        this.plugin = plugin;\n        this.app = plugin.app;\n    }\n\n    abstract status(opts?: { path?: string }): Promise<Status>;\n\n    abstract commitAll(_: {\n        message: string;\n        status?: Status;\n        unstagedFiles?: UnstagedFile[];\n        amend?: boolean;\n    }): Promise<number | undefined>;\n\n    abstract commit(_: {\n        message: string;\n        amend?: boolean;\n    }): Promise<number | undefined>;\n\n    abstract stageAll(_: { dir?: string; status?: Status }): Promise<void>;\n\n    abstract unstageAll(_: { dir?: string; status?: Status }): Promise<void>;\n\n    abstract stage(filepath: string, relativeToVault: boolean): Promise<void>;\n\n    abstract unstage(filepath: string, relativeToVault: boolean): Promise<void>;\n\n    abstract discard(filepath: string): Promise<void>;\n\n    abstract discardAll(_: { dir?: string; status?: Status }): Promise<void>;\n\n    /**\n     * Use this method instead of {@link GitManager.status} to delete untracked files, becase on native git\n     * directories which only contain untracked files will only be listed by their directory name e.g. `thedir/` and not every file individually.\n     * This allows for more efficient deletion of untracked files.\n     *\n     * @param path - The path to the directory to get untracked paths in. If not specified, the whole repository is used.\n     */\n    abstract getUntrackedPaths(opts?: {\n        path?: string;\n        status?: Status;\n    }): Promise<string[]>;\n\n    abstract pull(): Promise<FileStatusResult[] | undefined>;\n\n    /**\n     * Pushes to the remote repository.\n     *\n     * @returns `numper`: number of pushed files\n     * @returns `undefined` for other states, but a notification is done elsewhere\n     * @returns `null` if push was successful, but changed files could not be determined\n     */\n    abstract push(): Promise<number | undefined | null>;\n\n    abstract getUnpushedCommits(): Promise<number>;\n\n    abstract canPush(): Promise<boolean>;\n\n    abstract checkRequirements(): Promise<\n        \"valid\" | \"missing-repo\" | \"missing-git\"\n    >;\n\n    abstract branchInfo(): Promise<BranchInfo>;\n\n    abstract checkout(branch: string, remote?: string): Promise<void>;\n\n    abstract createBranch(branch: string): Promise<void>;\n\n    abstract deleteBranch(branch: string, force: boolean): Promise<void>;\n\n    abstract branchIsMerged(branch: string): Promise<boolean>;\n\n    abstract init(): Promise<void>;\n\n    abstract clone(url: string, dir: string, depth?: number): Promise<void>;\n\n    abstract setConfig(\n        path: string,\n        value: string | number | boolean | undefined\n    ): Promise<void>;\n\n    abstract getConfig(\n        path: string,\n        scope?: string\n    ): Promise<string | undefined>;\n\n    abstract fetch(remote?: string): Promise<void>;\n\n    abstract setRemote(name: string, url: string): Promise<void>;\n\n    abstract getRemotes(): Promise<string[]>;\n\n    abstract getRemoteUrl(remote: string): Promise<string | undefined>;\n\n    abstract log(\n        file: string | undefined,\n        relativeToVault?: boolean,\n        limit?: number,\n        ref?: string\n    ): Promise<LogEntry[]>;\n\n    abstract getRemoteBranches(remote: string): Promise<string[]>;\n\n    abstract removeRemote(remoteName: string): Promise<void>;\n\n    abstract updateUpstreamBranch(remoteBranch: string): Promise<void>;\n\n    abstract updateGitPath(gitPath: string): Promise<void>;\n\n    abstract updateBasePath(basePath: string): Promise<void>;\n\n    abstract getDiffString(\n        filePath: string,\n        stagedChanges: boolean,\n        hash?: string\n    ): Promise<string>;\n\n    abstract getLastCommitTime(): Promise<Date | undefined>;\n\n    // Constructs a path relative to the vault from a path relative to the git repository\n    getRelativeVaultPath(path: string): string {\n        if (this.plugin.settings.basePath) {\n            return this.plugin.settings.basePath + \"/\" + path;\n        } else {\n            return path;\n        }\n    }\n\n    // Constructs a path relative to the git repository from a path relative to the vault\n    //\n    // @param doConversion - If false, the path is returned as is. This is added because that parameter is often passed on to functions where this method is called.\n    getRelativeRepoPath(\n        filePath: string,\n        doConversion: boolean = true\n    ): string {\n        if (doConversion) {\n            if (this.plugin.settings.basePath.length > 0) {\n                //Expect the case that the git repository is located inside the vault on mobile platform currently.\n                return filePath.substring(\n                    this.plugin.settings.basePath.length + 1\n                );\n            }\n        }\n        return filePath;\n    }\n\n    unload(): void {}\n\n    private _getTreeStructure<T = DiffFile | FileStatusResult>(\n        children: (T & { path: string })[],\n        beginLength = 0\n    ): TreeItem<T>[] {\n        const list: TreeItem<T>[] = [];\n        children = [...children];\n        while (children.length > 0) {\n            const first = children.first()!;\n            const restPath = first.path.substring(beginLength);\n            if (restPath.contains(\"/\")) {\n                const title = restPath.substring(0, restPath.indexOf(\"/\"));\n                const childrenWithSameTitle = children.filter((item) => {\n                    return item.path\n                        .substring(beginLength)\n                        .startsWith(title + \"/\");\n                });\n                childrenWithSameTitle.forEach((item) => children.remove(item));\n                const path = first.path.substring(\n                    0,\n                    restPath.indexOf(\"/\") + beginLength\n                );\n                list.push({\n                    title: title,\n                    path: path,\n                    vaultPath: this.getRelativeVaultPath(path),\n                    children: this._getTreeStructure(\n                        childrenWithSameTitle,\n                        (beginLength > 0\n                            ? beginLength + title.length\n                            : title.length) + 1\n                    ),\n                });\n            } else {\n                list.push({\n                    title: restPath,\n                    data: first,\n                    path: first.path,\n                    vaultPath: this.getRelativeVaultPath(first.path),\n                });\n                children.remove(first);\n            }\n        }\n        return list;\n    }\n\n    /*\n     * Sorts the children and simplifies the title\n     * If a node only contains another subdirectory, that subdirectory is moved up one level and integrated into the parent node\n     */\n    private simplify<T>(tree: TreeItem<T>[]): TreeItem<T>[] {\n        for (const node of tree) {\n            while (true) {\n                const singleChild = node.children?.length == 1;\n                const singleChildIsDir =\n                    node.children?.first()?.data == undefined;\n\n                if (\n                    !(\n                        node.children != undefined &&\n                        singleChild &&\n                        singleChildIsDir\n                    )\n                )\n                    break;\n                const child = node.children.first()!;\n                node.title += \"/\" + child.title;\n                node.data = child.data;\n                node.path = child.path;\n                node.vaultPath = child.vaultPath;\n                node.children = child.children;\n            }\n            if (node.children != undefined) {\n                this.simplify<T>(node.children);\n            }\n            node.children?.sort((a, b) => {\n                const dirCompare =\n                    (b.data == undefined ? 1 : 0) -\n                    (a.data == undefined ? 1 : 0);\n                if (dirCompare != 0) {\n                    return dirCompare;\n                } else {\n                    return a.title.localeCompare(b.title);\n                }\n            });\n        }\n        return tree.sort((a, b) => {\n            const dirCompare =\n                (b.data == undefined ? 1 : 0) - (a.data == undefined ? 1 : 0);\n            if (dirCompare != 0) {\n                return dirCompare;\n            } else {\n                return a.title.localeCompare(b.title);\n            }\n        });\n    }\n\n    getTreeStructure<T = DiffFile | FileStatusResult>(\n        children: (T & { path: string })[]\n    ): TreeItem<T>[] {\n        const tree = this._getTreeStructure<T>(children);\n\n        const res = this.simplify<T>(tree);\n        return res;\n    }\n\n    async formatCommitMessage(template: string): Promise<string> {\n        let status: Status | undefined;\n        if (template.includes(\"{{numFiles}}\")) {\n            status = await this.status();\n            const numFiles = status.staged.length;\n            template = template.replace(\"{{numFiles}}\", String(numFiles));\n        }\n        if (template.includes(\"{{hostname}}\")) {\n            let hostname = this.plugin.localStorage.getHostname() || \"\";\n            if (!hostname && Platform.isDesktopApp) {\n                hostname = osHostname();\n            }\n            template = template.replace(\"{{hostname}}\", hostname);\n        }\n\n        if (template.includes(\"{{files}}\")) {\n            status = status ?? (await this.status());\n\n            const changeset: { [key: string]: string[] } = {};\n            let files = \"\";\n            // If there are more than 100 files, we don't list them all\n            if (status.staged.length < 100) {\n                status.staged.forEach((value: FileStatusResult) => {\n                    if (value.index in changeset) {\n                        changeset[value.index].push(value.path);\n                    } else {\n                        changeset[value.index] = [value.path];\n                    }\n                });\n\n                const chunks = [];\n                for (const [action, files] of Object.entries(changeset)) {\n                    chunks.push(action + \" \" + files.join(\" \"));\n                }\n\n                files = chunks.join(\", \");\n            } else {\n                files = \"Too many files to list\";\n            }\n\n            template = template.replace(\"{{files}}\", files);\n        }\n\n        template = template.replace(\n            \"{{date}}\",\n            moment().format(this.plugin.settings.commitDateFormat)\n        );\n        if (this.plugin.settings.listChangedFilesInMessageBody) {\n            const status2 = status ?? (await this.status());\n            let files = \"\";\n            // If there are more than 100 files, we don't list them all\n            if (status2.staged.length < 100) {\n                files = status2.staged.map((e) => e.path).join(\"\\n\");\n            } else {\n                files = \"Too many files to list\";\n            }\n            template = template + \"\\n\\n\" + \"Affected files:\" + \"\\n\" + files;\n        }\n        return template;\n    }\n}\n"
  },
  {
    "path": "src/gitManager/isomorphicGit.ts",
    "content": "import { createPatch } from \"diff\";\nimport type {\n    AuthCallback,\n    AuthFailureCallback,\n    GitHttpRequest,\n    GitHttpResponse,\n    GitProgressEvent,\n    HttpClient,\n    Walker,\n    WalkerMap,\n} from \"isomorphic-git\";\nimport git, { Errors, readBlob } from \"isomorphic-git\";\nimport { Notice, requestUrl } from \"obsidian\";\nimport type ObsidianGit from \"../main\";\nimport type {\n    BranchInfo,\n    FileStatusResult,\n    LogEntry,\n    Status,\n    UnstagedFile,\n    WalkDifference,\n} from \"../types\";\nimport { CurrentGitAction, type DiffFile } from \"../types\";\nimport { GeneralModal } from \"../ui/modals/generalModal\";\nimport { splitRemoteBranch, worthWalking } from \"../utils\";\nimport { GitManager } from \"./gitManager\";\nimport { MyAdapter } from \"./myAdapter\";\nimport diff3Merge from \"diff3\";\n\nexport class IsomorphicGit extends GitManager {\n    private readonly FILE = 0;\n    private readonly HEAD = 1;\n    private readonly WORKDIR = 2;\n    private readonly STAGE = 3;\n    // Mapping from statusMatrix to git status codes based off git status --short\n    // See: https://isomorphic-git.org/docs/en/statusMatrix\n    private readonly status_mapping = {\n        \"000\": \"  \",\n        \"003\": \"AD\",\n        \"020\": \"??\",\n        \"022\": \"A \",\n        \"023\": \"AM\",\n        \"100\": \"D \",\n        \"101\": \" D\",\n        \"103\": \"MD\",\n        \"110\": \"DA\", // Technically, two files: first one is deleted \"D \" and second one is untracked \"??\"\n        \"111\": \"  \",\n        \"113\": \"MM\",\n        \"120\": \"DA\", // Same as \"110\"\n        \"121\": \" M\",\n        \"122\": \"M \",\n        \"123\": \"MM\",\n    };\n    private readonly noticeLength = 999_999;\n    private readonly fs = new MyAdapter(this.app.vault, this.plugin);\n\n    constructor(plugin: ObsidianGit) {\n        super(plugin);\n    }\n\n    getRepo(): {\n        fs: MyAdapter;\n        dir: string;\n        gitdir?: string;\n        onAuth: AuthCallback;\n        onAuthFailure: AuthFailureCallback;\n        http: HttpClient;\n    } {\n        return {\n            fs: this.fs,\n            dir: this.plugin.settings.basePath,\n            gitdir: this.plugin.settings.gitDir || undefined,\n            onAuth: () => {\n                return {\n                    username:\n                        this.plugin.localStorage.getUsername() ?? undefined,\n                    password:\n                        this.plugin.localStorage.getPassword() ?? undefined,\n                };\n            },\n            onAuthFailure: async () => {\n                new Notice(\n                    \"Authentication failed. Please try with different credentials\"\n                );\n                const username = await new GeneralModal(this.plugin, {\n                    placeholder: \"Specify your username\",\n                }).openAndGetResult();\n                if (username) {\n                    const password = await new GeneralModal(this.plugin, {\n                        placeholder:\n                            \"Specify your password/personal access token\",\n                        obscure: true,\n                    }).openAndGetResult();\n                    if (password) {\n                        this.plugin.localStorage.setUsername(username);\n                        this.plugin.localStorage.setPassword(password);\n                        return {\n                            username,\n                            password,\n                        };\n                    }\n                }\n                return { cancel: true };\n            },\n            http: {\n                async request({\n                    url,\n                    method,\n                    headers,\n                    body,\n                }: GitHttpRequest): Promise<GitHttpResponse> {\n                    // We can't stream yet, so collect body and set it to the ArrayBuffer\n                    // because that's what requestUrl expects\n                    let collectedBody: ArrayBuffer | undefined;\n                    if (body) {\n                        collectedBody = await asyncIteratorToArrayBuffer(body);\n                    }\n\n                    const res = await requestUrl({\n                        url,\n                        method,\n                        headers,\n                        body: collectedBody,\n                        throw: false,\n                    });\n\n                    return {\n                        url,\n                        method,\n                        headers: res.headers,\n                        body: arrayBufferToAsyncIterator(res.arrayBuffer),\n                        statusCode: res.status,\n                        statusMessage: res.status.toString(),\n                    };\n                },\n            },\n        };\n    }\n\n    async wrapFS<T>(call: Promise<T>): Promise<T> {\n        try {\n            const res = await call;\n            await this.fs.saveAndClear();\n            return res;\n        } catch (error) {\n            await this.fs.saveAndClear();\n            throw error;\n        }\n    }\n\n    async status(opts?: { path?: string }): Promise<Status> {\n        let notice: Notice | undefined;\n        const timeout = window.setTimeout(() => {\n            notice = new Notice(\n                \"This takes longer: Getting status\",\n                this.noticeLength\n            );\n        }, 20000);\n        try {\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.status });\n            const statusOpts = { ...this.getRepo() } as Parameters<\n                typeof git.statusMatrix\n            >[0];\n            if (opts?.path != undefined) {\n                statusOpts.filepaths = [`${opts.path}/`];\n            }\n            const status = (\n                await this.wrapFS(git.statusMatrix(statusOpts))\n            ).map((row) => this.getFileStatusResult(row));\n\n            const changed: FileStatusResult[] = [];\n            const staged: FileStatusResult[] = [];\n            const all: FileStatusResult[] = [];\n            for (const file of status) {\n                if (file.workingDir !== \" \") {\n                    changed.push(file);\n                }\n                if (file.index !== \" \" && file.index !== \"U\") {\n                    staged.push(file);\n                }\n                if (file.index != \" \" || file.workingDir != \" \") {\n                    all.push(file);\n                }\n            }\n            const conflicted: string[] = [];\n            window.clearTimeout(timeout);\n            notice?.hide();\n            return { all, changed, staged, conflicted };\n        } catch (error) {\n            window.clearTimeout(timeout);\n            notice?.hide();\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async commitAll({\n        message,\n        status,\n        unstagedFiles,\n    }: {\n        message: string;\n        status?: Status;\n        unstagedFiles?: UnstagedFile[];\n    }): Promise<number | undefined> {\n        try {\n            await this.checkAuthorInfo();\n\n            await this.stageAll({ status, unstagedFiles });\n            return this.commit({ message });\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async commit({\n        message,\n    }: {\n        message: string;\n        amend?: boolean;\n    }): Promise<undefined> {\n        try {\n            await this.checkAuthorInfo();\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.commit });\n            const formatMessage = await this.formatCommitMessage(message);\n            const hadConflict = this.plugin.localStorage.getConflict();\n            let parent: string[] | undefined = undefined;\n\n            if (hadConflict) {\n                const branchInfo = await this.branchInfo();\n                parent = [branchInfo.current!, branchInfo.tracking!];\n            }\n\n            await this.wrapFS(\n                git.commit({\n                    ...this.getRepo(),\n                    message: formatMessage,\n                    parent: parent,\n                })\n            );\n            this.plugin.localStorage.setConflict(false);\n            return;\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async stage(filepath: string, relativeToVault: boolean): Promise<void> {\n        const gitPath = this.getRelativeRepoPath(filepath, relativeToVault);\n        let vaultPath: string;\n        if (relativeToVault) {\n            vaultPath = filepath;\n        } else {\n            vaultPath = this.getRelativeVaultPath(filepath);\n        }\n        try {\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n            if (await this.app.vault.adapter.exists(vaultPath)) {\n                await this.wrapFS(\n                    git.add({ ...this.getRepo(), filepath: gitPath })\n                );\n            } else {\n                await this.wrapFS(\n                    git.remove({ ...this.getRepo(), filepath: gitPath })\n                );\n            }\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async stageAll({\n        dir,\n        status,\n        unstagedFiles,\n    }: {\n        dir?: string;\n        status?: Status;\n        unstagedFiles?: UnstagedFile[];\n    }): Promise<void> {\n        try {\n            if (status) {\n                await Promise.all(\n                    status.changed.map((file) =>\n                        file.workingDir !== \"D\"\n                            ? this.wrapFS(\n                                  git.add({\n                                      ...this.getRepo(),\n                                      filepath: file.path,\n                                  })\n                              )\n                            : git.remove({\n                                  ...this.getRepo(),\n                                  filepath: file.path,\n                              })\n                    )\n                );\n            } else {\n                const filesToStage =\n                    unstagedFiles ?? (await this.getUnstagedFiles(dir ?? \".\"));\n                await Promise.all(\n                    filesToStage.map(({ path, type }) =>\n                        type == \"D\"\n                            ? git.remove({ ...this.getRepo(), filepath: path })\n                            : this.wrapFS(\n                                  git.add({ ...this.getRepo(), filepath: path })\n                              )\n                    )\n                );\n            }\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async unstage(filepath: string, relativeToVault: boolean): Promise<void> {\n        try {\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n            filepath = this.getRelativeRepoPath(filepath, relativeToVault);\n            await this.wrapFS(\n                git.resetIndex({ ...this.getRepo(), filepath: filepath })\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async unstageAll({\n        dir,\n        status,\n    }: {\n        dir?: string;\n        status?: Status;\n    }): Promise<void> {\n        try {\n            let staged: string[];\n            if (status) {\n                staged = status.staged.map((file) => file.path);\n            } else {\n                const res = await this.getStagedFiles(dir ?? \".\");\n                staged = res.map(({ path }) => path);\n            }\n            await this.wrapFS(\n                Promise.all(\n                    staged.map((file) =>\n                        git.resetIndex({ ...this.getRepo(), filepath: file })\n                    )\n                )\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async discard(filepath: string): Promise<void> {\n        try {\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n            await this.wrapFS(\n                git.checkout({\n                    ...this.getRepo(),\n                    filepaths: [filepath],\n                    force: true,\n                })\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async discardAll({\n        dir,\n        status,\n    }: {\n        dir?: string;\n        status?: Status;\n    }): Promise<void> {\n        let files: string[] = [];\n        if (status) {\n            if (dir != undefined) {\n                files = status.changed\n                    .filter(\n                        (file) =>\n                            file.workingDir != \"U\" && file.path.startsWith(dir)\n                    )\n                    .map((file) => file.path);\n            } else {\n                files = status.changed\n                    .filter((file) => file.workingDir != \"U\")\n                    .map((file) => file.path);\n            }\n        } else {\n            files = (await this.getUnstagedFiles(dir))\n                .filter((file) => file.type != \"A\")\n                .map(({ path }) => path);\n        }\n\n        try {\n            await this.wrapFS(\n                git.checkout({\n                    ...this.getRepo(),\n                    filepaths: files,\n                    force: true,\n                })\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async getUntrackedPaths(opts: {\n        path?: string;\n        status?: Status;\n    }): Promise<string[]> {\n        const untrackedPaths: string[] = [];\n        if (opts.status) {\n            for (const file of opts.status.changed) {\n                if (\n                    file.index == \"U\" &&\n                    file.workingDir === \"U\" &&\n                    file.path.startsWith(\n                        opts.path != undefined ? `${opts.path}/` : \"\"\n                    )\n                ) {\n                    untrackedPaths.push(file.path);\n                }\n            }\n        } else {\n            const status = await this.status({ path: opts?.path });\n            for (const file of status.changed) {\n                if (file.index === \"U\" && file.workingDir === \"U\") {\n                    untrackedPaths.push(file.path);\n                }\n            }\n        }\n        return untrackedPaths;\n    }\n\n    getProgressText(action: string, event: GitProgressEvent): string {\n        let out = `${action} progress:`;\n        if (event.phase) {\n            out = `${out} ${event.phase}:`;\n        }\n        if (event.loaded) {\n            out = `${out} ${event.loaded}`;\n            if (event.total) {\n                out = `${out} of ${event.total}`;\n            }\n        }\n        return out;\n    }\n\n    resolveRef(ref: string): Promise<string> {\n        return this.wrapFS(git.resolveRef({ ...this.getRepo(), ref }));\n    }\n\n    async pull(): Promise<FileStatusResult[]> {\n        const progressNotice = this.showNotice(\"Initializing pull\");\n        try {\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.pull });\n\n            const localCommit = await this.resolveRef(\"HEAD\");\n            await this.fetch();\n            const branchInfo = await this.branchInfo();\n\n            await this.checkAuthorInfo();\n\n            const mergeRes = await this.wrapFS(\n                git.merge({\n                    ...this.getRepo(),\n                    ours: branchInfo.current,\n                    theirs: branchInfo.tracking!,\n                    abortOnConflict: false,\n                    mergeDriver:\n                        this.plugin.settings.mergeStrategy !== \"none\"\n                            ? ({ contents }) => {\n                                  const baseContent = contents[0];\n                                  const ourContent = contents[1];\n                                  const theirContent = contents[2];\n\n                                  const LINEBREAKS = /^.*(\\r?\\n|$)/gm;\n                                  const ours =\n                                      ourContent.match(LINEBREAKS) ?? [];\n                                  const base =\n                                      baseContent.match(LINEBREAKS) ?? [];\n                                  const theirs =\n                                      theirContent.match(LINEBREAKS) ?? [];\n                                  const result = diff3Merge(ours, base, theirs);\n                                  let mergedText = \"\";\n                                  for (const item of result) {\n                                      if (item.ok) {\n                                          mergedText += item.ok.join(\"\");\n                                      }\n                                      if (item.conflict) {\n                                          mergedText +=\n                                              this.plugin.settings\n                                                  .mergeStrategy === \"ours\"\n                                                  ? item.conflict.a.join(\"\")\n                                                  : item.conflict.b.join(\"\");\n                                      }\n                                  }\n                                  return { cleanMerge: true, mergedText };\n                              }\n                            : undefined,\n                })\n            );\n            if (!mergeRes.alreadyMerged) {\n                await this.wrapFS(\n                    git.checkout({\n                        ...this.getRepo(),\n                        ref: branchInfo.current,\n                        onProgress: (progress) => {\n                            if (progressNotice !== undefined) {\n                                progressNotice.noticeEl.innerText =\n                                    this.getProgressText(\"Checkout\", progress);\n                            }\n                        },\n                        remote: branchInfo.remote,\n                    })\n                );\n            }\n            progressNotice?.hide();\n\n            const upstreamCommit = await this.resolveRef(\"HEAD\");\n            const changedFiles = await this.getFileChangesCount(\n                localCommit,\n                upstreamCommit\n            );\n\n            this.showNotice(\"Finished pull\", false);\n\n            return changedFiles.map<FileStatusResult>((file) => ({\n                path: file.path,\n                workingDir: \"P\",\n                index: \"P\",\n                vaultPath: this.getRelativeVaultPath(file.path),\n            }));\n        } catch (error) {\n            progressNotice?.hide();\n            if (error instanceof Errors.MergeConflictError) {\n                await this.plugin.handleConflict(\n                    error.data.filepaths.map((file) =>\n                        this.getRelativeVaultPath(file)\n                    )\n                );\n            }\n\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async push(): Promise<number> {\n        if (!(await this.canPush())) {\n            return 0;\n        }\n        const progressNotice = this.showNotice(\"Initializing push\");\n        try {\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.status });\n            const status = await this.branchInfo();\n            const trackingBranch = status.tracking;\n            const currentBranch = status.current;\n            const numChangedFiles = (\n                await this.getFileChangesCount(currentBranch!, trackingBranch!)\n            ).length;\n\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.push });\n            const remote = await this.getCurrentRemote();\n\n            await this.wrapFS(\n                git.push({\n                    ...this.getRepo(),\n                    remote,\n                    onProgress: (progress) => {\n                        if (progressNotice !== undefined) {\n                            progressNotice.noticeEl.innerText =\n                                this.getProgressText(\"Pushing\", progress);\n                        }\n                    },\n                })\n            );\n            progressNotice?.hide();\n            return numChangedFiles;\n        } catch (error) {\n            progressNotice?.hide();\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async getUnpushedCommits(): Promise<number> {\n        const status = await this.branchInfo();\n        const trackingBranch = status.tracking;\n        const currentBranch = status.current;\n\n        if (trackingBranch == null || currentBranch == null) {\n            return 0;\n        }\n\n        const localCommit = await this.resolveRef(currentBranch);\n        const upstreamCommit = await this.resolveRef(trackingBranch);\n\n        const changedFiles = await this.getFileChangesCount(\n            localCommit,\n            upstreamCommit\n        );\n\n        return changedFiles.length;\n    }\n\n    async canPush(): Promise<boolean> {\n        const status = await this.branchInfo();\n        const trackingBranch = status.tracking;\n        const currentBranch = status.current;\n\n        const current = await this.resolveRef(currentBranch!);\n        const tracking = await this.resolveRef(trackingBranch!);\n\n        return current != tracking;\n    }\n\n    async checkRequirements(): Promise<\"valid\" | \"missing-repo\"> {\n        const headExists = await this.plugin.app.vault.adapter.exists(\n            `${this.getRepo().dir}/.git/HEAD`\n        );\n\n        return headExists ? \"valid\" : \"missing-repo\";\n    }\n\n    async branchInfo(): Promise<BranchInfo & { remote: string }> {\n        try {\n            const current = (await git.currentBranch(this.getRepo())) || \"\";\n\n            const branches = await git.listBranches(this.getRepo());\n\n            const remote =\n                (await this.getConfig(`branch.${current}.remote`)) ?? \"origin\";\n\n            const trackingBranch = (\n                await this.getConfig(`branch.${current}.merge`)\n            )?.split(\"refs/heads\")[1];\n\n            const tracking = trackingBranch\n                ? remote + trackingBranch\n                : undefined;\n\n            return {\n                current: current,\n                tracking: tracking,\n                branches: branches,\n                remote: remote,\n            };\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async getCurrentRemote(): Promise<string> {\n        const current = (await git.currentBranch(this.getRepo())) || \"\";\n\n        const remote =\n            (await this.getConfig(`branch.${current}.remote`)) ?? \"origin\";\n        return remote;\n    }\n\n    async checkout(branch: string, remote?: string): Promise<void> {\n        try {\n            return this.wrapFS(\n                git.checkout({\n                    ...this.getRepo(),\n                    ref: branch,\n                    force: !!remote,\n                    remote,\n                })\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async createBranch(branch: string): Promise<void> {\n        try {\n            await this.wrapFS(\n                git.branch({ ...this.getRepo(), ref: branch, checkout: true })\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async deleteBranch(branch: string): Promise<void> {\n        try {\n            await this.wrapFS(\n                git.deleteBranch({ ...this.getRepo(), ref: branch })\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    branchIsMerged(_: string): Promise<boolean> {\n        return Promise.resolve(true);\n    }\n\n    async init(): Promise<void> {\n        try {\n            await this.wrapFS(git.init(this.getRepo()));\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async clone(url: string, dir: string, depth?: number): Promise<void> {\n        const progressNotice = this.showNotice(\"Initializing clone\");\n        try {\n            await this.wrapFS(\n                git.clone({\n                    ...this.getRepo(),\n                    dir: dir,\n                    url: url,\n                    depth: depth,\n                    onProgress: (progress) => {\n                        if (progressNotice !== undefined) {\n                            progressNotice.noticeEl.innerText =\n                                this.getProgressText(\"Cloning\", progress);\n                        }\n                    },\n                })\n            );\n            progressNotice?.hide();\n        } catch (error) {\n            progressNotice?.hide();\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async setConfig(\n        path: string,\n        value: string | number | boolean | undefined\n    ): Promise<void> {\n        try {\n            return this.wrapFS(\n                git.setConfig({\n                    ...this.getRepo(),\n                    path: path,\n                    value: value,\n                })\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async getConfig(path: string): Promise<string> {\n        try {\n            return this.wrapFS(\n                git.getConfig({\n                    ...this.getRepo(),\n                    path: path,\n                }) as Promise<string>\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async fetch(remote?: string): Promise<void> {\n        const progressNotice = this.showNotice(\"Initializing fetch\");\n\n        try {\n            const args = {\n                ...this.getRepo(),\n                onProgress: (progress: GitProgressEvent) => {\n                    if (progressNotice !== undefined) {\n                        progressNotice.noticeEl.innerText =\n                            this.getProgressText(\"Fetching\", progress);\n                    }\n                },\n                remote: remote ?? (await this.getCurrentRemote()),\n            };\n\n            await this.wrapFS(git.fetch(args));\n            progressNotice?.hide();\n        } catch (error) {\n            this.plugin.displayError(error);\n            progressNotice?.hide();\n            throw error;\n        }\n    }\n\n    async setRemote(name: string, url: string): Promise<void> {\n        try {\n            await this.wrapFS(\n                git.addRemote({\n                    ...this.getRepo(),\n                    remote: name,\n                    url: url,\n                    force: true,\n                })\n            );\n        } catch (error) {\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async getRemoteBranches(remote: string): Promise<string[]> {\n        let remoteBranches = [];\n        remoteBranches.push(\n            ...(await this.wrapFS(\n                git.listBranches({ ...this.getRepo(), remote: remote })\n            ))\n        );\n\n        remoteBranches.remove(\"HEAD\");\n\n        //Align with simple-git\n        remoteBranches = remoteBranches.map((e) => `${remote}/${e}`);\n        return remoteBranches;\n    }\n\n    async getRemotes(): Promise<string[]> {\n        return (await this.wrapFS(git.listRemotes({ ...this.getRepo() }))).map(\n            (remoteUrl) => remoteUrl.remote\n        );\n    }\n\n    async removeRemote(remoteName: string): Promise<void> {\n        await this.wrapFS(\n            git.deleteRemote({ ...this.getRepo(), remote: remoteName })\n        );\n    }\n\n    async getRemoteUrl(remote: string): Promise<string | undefined> {\n        return (\n            await this.wrapFS(git.listRemotes({ ...this.getRepo() }))\n        ).filter((item) => item.remote == remote)[0]?.url;\n    }\n\n    async log(\n        _?: string,\n        __ = true,\n        limit?: number,\n        ref?: string\n    ): Promise<LogEntry[]> {\n        const logs = await this.wrapFS(\n            git.log({ ...this.getRepo(), depth: limit, ref: ref })\n        );\n\n        return Promise.all(\n            logs.map(async (log) => {\n                const completeMessage = log.commit.message.split(\"\\n\\n\");\n\n                return {\n                    message: completeMessage[0],\n                    author: {\n                        name: log.commit.author.name,\n                        email: log.commit.author.email,\n                    },\n                    body: completeMessage.slice(1).join(\"\\n\\n\"),\n                    date: new Date(\n                        log.commit.committer.timestamp\n                    ).toDateString(),\n                    diff: {\n                        changed: 0,\n                        files: (\n                            await this.getFileChangesCount(\n                                log.commit.parent.first()!,\n                                log.oid\n                            )\n                        ).map<DiffFile>((item) => {\n                            return {\n                                path: item.path,\n                                status: item.type,\n                                vaultPath: this.getRelativeVaultPath(item.path),\n                                hash: log.oid,\n                            };\n                        }),\n                    },\n                    hash: log.oid,\n                    refs: [],\n                };\n            })\n        );\n    }\n\n    updateBasePath(basePath: string): Promise<void> {\n        this.getRepo().dir = basePath;\n        return Promise.resolve();\n    }\n\n    async updateUpstreamBranch(remoteBranch: string): Promise<void> {\n        const [remote, branch] = splitRemoteBranch(remoteBranch);\n        const branchInfo = await this.branchInfo();\n\n        await this.wrapFS(\n            git.push({\n                ...this.getRepo(),\n                remote: remote,\n                remoteRef: branch,\n            })\n        );\n\n        await this.setConfig(\n            `branch.${branchInfo.current}.merge`,\n            `refs/heads/${branch}`\n        );\n    }\n\n    updateGitPath(_: string): Promise<void> {\n        // isomorphic-git library has its own git client\n        return Promise.resolve();\n    }\n\n    async getFileChangesCount(\n        commitHash1: string,\n        commitHash2: string\n    ): Promise<WalkDifference[]> {\n        return this.walkDifference({\n            walkers: [\n                git.TREE({ ref: commitHash1 }),\n                git.TREE({ ref: commitHash2 }),\n            ],\n        });\n    }\n\n    async walkDifference({\n        walkers,\n        dir: base,\n    }: {\n        walkers: Walker[];\n        dir?: string;\n    }): Promise<WalkDifference[]> {\n        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n        const res = await this.wrapFS(\n            git.walk({\n                ...this.getRepo(),\n                trees: walkers,\n                map: async function (filepath, [A, B]) {\n                    if (!worthWalking(filepath, base)) {\n                        return null;\n                    }\n\n                    if (\n                        (await A?.type()) === \"tree\" ||\n                        (await B?.type()) === \"tree\"\n                    ) {\n                        return;\n                    }\n\n                    // generate ids\n                    const Aoid = await A?.oid();\n                    const Boid = await B?.oid();\n\n                    // determine modification type\n                    let type = \"equal\";\n                    if (Aoid !== Boid) {\n                        type = \"M\";\n                    }\n                    if (Aoid === undefined) {\n                        type = \"A\";\n                    }\n                    if (Boid === undefined) {\n                        type = \"D\";\n                    }\n\n                    if (Aoid === undefined && Boid === undefined) {\n                        console.log(\"Something weird happened:\");\n                        console.log(A);\n                        console.log(B);\n                    }\n                    if (type === \"equal\") {\n                        return;\n                    }\n\n                    return {\n                        path: filepath,\n                        type: type,\n                    };\n                },\n            })\n        );\n        return res as WalkDifference[];\n    }\n\n    async getStagedFiles(\n        dir = \".\"\n    ): Promise<{ vaultPath: string; path: string }[]> {\n        const res = await this.walkDifference({\n            walkers: [git.TREE({ ref: \"HEAD\" }), git.STAGE()],\n            dir,\n        });\n        return res.map((file) => {\n            return {\n                vaultPath: this.getRelativeVaultPath(file.path),\n                path: file.path,\n            };\n        });\n    }\n\n    async getUnstagedFiles(base = \".\"): Promise<UnstagedFile[]> {\n        let notice: Notice | undefined;\n        const timeout = window.setTimeout(() => {\n            notice = new Notice(\n                \"This takes longer: Getting status\",\n                this.noticeLength\n            );\n        }, 20000);\n        try {\n            const repo = this.getRepo();\n            const res = await this.wrapFS<Promise<UnstagedFile[]>>(\n                //Modified from `git.statusMatrix`\n                // eslint-disable-next-line @typescript-eslint/no-unsafe-argument\n                git.walk({\n                    ...repo,\n                    trees: [git.WORKDIR(), git.STAGE()],\n                    map: async function (\n                        filepath,\n                        [workdir, stage]\n                    ): Promise<UnstagedFile | null | undefined> {\n                        // Ignore ignored files, but only if they are not already tracked.\n                        if (!stage && workdir) {\n                            const isIgnored = await git.isIgnored({\n                                ...repo,\n                                filepath,\n                            });\n                            if (isIgnored) {\n                                return null;\n                            }\n                        }\n                        // match against base path\n                        if (!worthWalking(filepath, base)) {\n                            return null;\n                        }\n                        // Late filter against file names\n                        // if (filter) {\n                        //     if (!filter(filepath)) return;\n                        // }\n\n                        const [workdirType, stageType] = await Promise.all([\n                            workdir && workdir.type(),\n                            stage && stage.type(),\n                        ]);\n\n                        const isBlob = [workdirType, stageType].includes(\n                            \"blob\"\n                        );\n\n                        // For now, bail on directories unless the file is also a blob in another tree\n                        if (\n                            (workdirType === \"tree\" ||\n                                workdirType === \"special\") &&\n                            !isBlob\n                        )\n                            return;\n\n                        if (stageType === \"commit\") return null;\n                        if (\n                            (stageType === \"tree\" || stageType === \"special\") &&\n                            !isBlob\n                        )\n                            return;\n\n                        // Figure out the oids for files, using the staged oid for the working dir oid if the stats match.\n                        const stageOid =\n                            stageType === \"blob\"\n                                ? await stage!.oid()\n                                : undefined;\n                        let workdirOid;\n                        if (workdirType === \"blob\" && stageType !== \"blob\") {\n                            // We don't actually NEED the sha. Any sha will do\n                            workdirOid = \"42\";\n                        } else if (workdirType === \"blob\") {\n                            workdirOid = await workdir!.oid();\n                        }\n                        if (!workdirOid) {\n                            return {\n                                path: filepath,\n                                type: \"D\",\n                            };\n                        }\n                        if (!stageOid) {\n                            return {\n                                path: filepath,\n                                type: \"A\",\n                            };\n                        }\n\n                        if (workdirOid !== stageOid) {\n                            return {\n                                path: filepath,\n                                type: \"M\",\n                            };\n                        }\n                        return null;\n                        // const entry = [undefined, headOid, workdirOid, stageOid];\n                        // const result = entry.map(value => entry.indexOf(value));\n                        // result.shift(); // remove leading undefined entry\n                        // return [filepath, ...result];\n                    },\n                })\n            );\n            window.clearTimeout(timeout);\n            notice?.hide();\n            return res;\n        } catch (error) {\n            window.clearTimeout(timeout);\n            notice?.hide();\n            this.plugin.displayError(error);\n            throw error;\n        }\n    }\n\n    async getDiffString(\n        filePath: string,\n        stagedChanges = false,\n        hash?: string\n    ): Promise<string> {\n        const vaultPath = this.getRelativeVaultPath(filePath);\n\n        const map: WalkerMap = async (file, [A]) => {\n            if (filePath == file) {\n                const oid = await A!.oid();\n                const contents = await git.readBlob({\n                    ...this.getRepo(),\n                    oid: oid,\n                });\n                return contents.blob;\n            }\n        };\n        if (hash) {\n            const commitContent = await readBlob({\n                ...this.getRepo(),\n                filepath: filePath,\n                oid: hash,\n            })\n                .then((headBlob) => new TextDecoder().decode(headBlob.blob))\n                .catch((err) => {\n                    if (err instanceof git.Errors.NotFoundError)\n                        return undefined;\n                    throw err;\n                });\n            const commit = await git.readCommit({\n                ...this.getRepo(),\n                oid: hash,\n            });\n\n            const previousContent = await readBlob({\n                ...this.getRepo(),\n                filepath: filePath,\n                oid: commit.commit.parent.first()!,\n            })\n                .then((headBlob) => new TextDecoder().decode(headBlob.blob))\n                .catch((err) => {\n                    if (err instanceof git.Errors.NotFoundError)\n                        return undefined;\n                    throw err;\n                });\n\n            const diff = createPatch(\n                vaultPath,\n                previousContent ?? \"\",\n                commitContent ?? \"\"\n            );\n            return diff;\n        }\n\n        const stagedBlob = (\n            (await git.walk({\n                ...this.getRepo(),\n                trees: [git.STAGE()],\n                map,\n            })) as Uint8Array[]\n        ).first();\n        const stagedContent = new TextDecoder().decode(stagedBlob);\n\n        if (stagedChanges) {\n            const headContent = await this.resolveRef(\"HEAD\")\n                .then((oid) =>\n                    readBlob({\n                        ...this.getRepo(),\n                        filepath: filePath,\n                        oid: oid,\n                    })\n                )\n                .then((headBlob) => new TextDecoder().decode(headBlob.blob))\n                .catch((err) => {\n                    if (err instanceof git.Errors.NotFoundError)\n                        return undefined;\n                    throw err;\n                });\n\n            const diff = createPatch(\n                vaultPath,\n                headContent ?? \"\",\n                stagedContent\n            );\n            return diff;\n        } else {\n            let workdirContent: string;\n            if (await this.app.vault.adapter.exists(vaultPath)) {\n                workdirContent = await this.app.vault.adapter.read(vaultPath);\n            } else {\n                workdirContent = \"\";\n            }\n\n            const diff = createPatch(vaultPath, stagedContent, workdirContent);\n            return diff;\n        }\n    }\n\n    async getLastCommitTime(): Promise<Date | undefined> {\n        const repo = this.getRepo();\n        const oid = await this.resolveRef(\"HEAD\");\n        const commit = await git.readCommit({ ...repo, oid: oid });\n        const date = commit.commit.committer.timestamp;\n        return new Date(date * 1000);\n    }\n\n    private getFileStatusResult(\n        row: [string, 0 | 1, 0 | 1 | 2, 0 | 1 | 2 | 3]\n    ): FileStatusResult {\n        // eslint-disable-next-line  @typescript-eslint/no-explicit-any\n        const status = (this.status_mapping as any)[\n            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n            `${row[this.HEAD]}${row[this.WORKDIR]}${row[this.STAGE]}`\n        ] as string;\n        // status will always be two characters\n        return {\n            index: status[0] == \"?\" ? \"U\" : status[0],\n            workingDir: status[1] == \"?\" ? \"U\" : status[1],\n            path: row[this.FILE],\n            vaultPath: this.getRelativeVaultPath(row[this.FILE]),\n        };\n    }\n\n    private async checkAuthorInfo(): Promise<void> {\n        const name = await this.getConfig(\"user.name\");\n        const email = await this.getConfig(\"user.email\");\n        if (!name || !email) {\n            throw Error(\n                \"Git author name and email are not set. Please set both fields in the settings.\"\n            );\n        }\n    }\n\n    private showNotice(message: string, infinity = true): Notice | undefined {\n        if (!this.plugin.settings.disablePopups) {\n            return new Notice(\n                message,\n                infinity ? this.noticeLength : undefined\n            );\n        }\n    }\n}\n\n// All because we can't use (for await)...\n\n// Convert a value to an Async Iterator\n// This will be easier with async generator functions.\n\n/*eslint-disable */\nfunction fromValue(value: any) {\n    let queue = [value];\n    return {\n        next() {\n            return Promise.resolve({\n                done: queue.length === 0,\n                value: queue.pop(),\n            });\n        },\n        return() {\n            queue = [];\n            return {};\n        },\n        [Symbol.asyncIterator]() {\n            return this;\n        },\n    };\n}\n\nasync function* arrayBufferToAsyncIterator(\n    buffer: ArrayBuffer\n): AsyncIterableIterator<Uint8Array> {\n    yield new Uint8Array(buffer);\n}\n\nasync function asyncIteratorToArrayBuffer(\n    iterator: AsyncIterableIterator<Uint8Array>\n): Promise<ArrayBuffer> {\n    const stream = new ReadableStream({\n        async start(controller) {\n            for await (const chunk of iterator) {\n                controller.enqueue(chunk);\n            }\n            controller.close();\n        },\n    });\n\n    const response = new Response(stream);\n    return await response.arrayBuffer();\n}\n"
  },
  {
    "path": "src/gitManager/myAdapter.ts",
    "content": "/* eslint-disable @typescript-eslint/require-await */\n/* eslint-disable @typescript-eslint/only-throw-error */\n/* eslint-disable @typescript-eslint/no-unsafe-assignment */\n/* eslint-disable @typescript-eslint/no-unsafe-member-access */\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { DataAdapter, Vault } from \"obsidian\";\nimport { normalizePath, TFile } from \"obsidian\";\nimport type ObsidianGit from \"../main\";\n\nexport class MyAdapter {\n    promises: any = {};\n    adapter: DataAdapter;\n    vault: Vault;\n    index: ArrayBuffer | undefined;\n    indexctime: number | undefined;\n    indexmtime: number | undefined;\n    lastBasePath: string | undefined;\n\n    constructor(\n        vault: Vault,\n        private readonly plugin: ObsidianGit\n    ) {\n        this.adapter = vault.adapter;\n        this.vault = vault;\n        this.lastBasePath = this.plugin.settings.basePath;\n\n        this.promises.readFile = this.readFile.bind(this);\n        this.promises.writeFile = this.writeFile.bind(this);\n        this.promises.readdir = this.readdir.bind(this);\n        this.promises.mkdir = this.mkdir.bind(this);\n        this.promises.rmdir = this.rmdir.bind(this);\n        this.promises.stat = this.stat.bind(this);\n        this.promises.unlink = this.unlink.bind(this);\n        this.promises.lstat = this.lstat.bind(this);\n        this.promises.readlink = this.readlink.bind(this);\n        this.promises.symlink = this.symlink.bind(this);\n    }\n    async readFile(path: string, opts: any) {\n        this.maybeLog(\"Read: \" + path + JSON.stringify(opts));\n        if (opts == \"utf8\" || opts.encoding == \"utf8\") {\n            const file = this.vault.getAbstractFileByPath(path);\n            if (file instanceof TFile) {\n                this.maybeLog(\"Reuse\");\n\n                return this.vault.read(file);\n            } else {\n                return this.adapter.read(path);\n            }\n        } else {\n            if (path.endsWith(this.gitDir + \"/index\")) {\n                if (this.plugin.settings.basePath != this.lastBasePath) {\n                    this.clearIndex();\n                    this.lastBasePath = this.plugin.settings.basePath;\n                    return this.adapter.readBinary(path);\n                }\n                return this.index ?? this.adapter.readBinary(path);\n            }\n            const file = this.vault.getAbstractFileByPath(path);\n            if (file instanceof TFile) {\n                this.maybeLog(\"Reuse\");\n\n                return this.vault.readBinary(file);\n            } else {\n                return this.adapter.readBinary(path);\n            }\n        }\n    }\n    async writeFile(path: string, data: string | ArrayBuffer) {\n        this.maybeLog(\"Write: \" + path);\n\n        if (typeof data === \"string\") {\n            const file = this.vault.getAbstractFileByPath(path);\n            if (file instanceof TFile) {\n                return this.vault.modify(file, data);\n            } else {\n                return this.adapter.write(path, data);\n            }\n        } else {\n            if (path.endsWith(this.gitDir + \"/index\")) {\n                this.index = data;\n                this.indexmtime = Date.now();\n                // this.adapter.writeBinary(path, data);\n            } else {\n                const file = this.vault.getAbstractFileByPath(path);\n                if (file instanceof TFile) {\n                    return this.vault.modifyBinary(file, data);\n                } else {\n                    return this.adapter.writeBinary(path, data);\n                }\n            }\n        }\n    }\n    async readdir(path: string) {\n        if (path === \".\") path = \"/\";\n        const res = await this.adapter.list(path);\n        const all = [...res.files, ...res.folders];\n        let formattedAll;\n        if (path !== \"/\") {\n            formattedAll = all.map((e) =>\n                normalizePath(e.substring(path.length))\n            );\n        } else {\n            formattedAll = all;\n        }\n        return formattedAll;\n    }\n    async mkdir(path: string) {\n        return this.adapter.mkdir(path);\n    }\n    async rmdir(path: string, opts: any) {\n        // eslint-disable-next-line @typescript-eslint/no-unsafe-argument\n        return this.adapter.rmdir(path, opts?.options?.recursive ?? false);\n    }\n    async stat(path: string) {\n        if (path.endsWith(this.gitDir + \"/index\")) {\n            if (\n                this.index !== undefined &&\n                this.indexctime != undefined &&\n                this.indexmtime != undefined\n            ) {\n                return {\n                    isFile: () => true,\n                    isDirectory: () => false,\n                    isSymbolicLink: () => false,\n                    size: this.index.byteLength,\n                    type: \"file\",\n                    ctimeMs: this.indexctime,\n                    mtimeMs: this.indexmtime,\n                };\n            } else {\n                const stat = await this.adapter.stat(path);\n                if (stat == undefined) {\n                    throw { code: \"ENOENT\" };\n                }\n                this.indexctime = stat.ctime;\n                this.indexmtime = stat.mtime;\n                return {\n                    ctimeMs: stat.ctime,\n                    mtimeMs: stat.mtime,\n                    size: stat.size,\n                    type: \"file\",\n                    isFile: () => true,\n                    isDirectory: () => false,\n                    isSymbolicLink: () => false,\n                };\n            }\n        }\n        if (path === \".\") path = \"/\";\n        const file = this.vault.getAbstractFileByPath(path);\n        this.maybeLog(\"Stat: \" + path);\n        if (file instanceof TFile) {\n            this.maybeLog(\"Reuse stat\");\n            return {\n                ctimeMs: file.stat.ctime,\n                mtimeMs: file.stat.mtime,\n                size: file.stat.size,\n                type: \"file\",\n                isFile: () => true,\n                isDirectory: () => false,\n                isSymbolicLink: () => false,\n            };\n        } else {\n            const stat = await this.adapter.stat(path);\n            if (stat) {\n                return {\n                    ctimeMs: stat.ctime,\n                    mtimeMs: stat.mtime,\n                    size: stat.size,\n                    type: stat.type === \"folder\" ? \"directory\" : stat.type,\n                    isFile: () => stat.type === \"file\",\n                    isDirectory: () => stat.type === \"folder\",\n                    isSymbolicLink: () => false,\n                };\n            } else {\n                // used to determine whether a file exists or not\n                throw { code: \"ENOENT\" };\n            }\n        }\n    }\n    async unlink(path: string) {\n        return this.adapter.remove(path);\n    }\n    async lstat(path: string) {\n        return this.stat(path);\n    }\n    async readlink(path: string) {\n        throw new Error(`readlink of (${path}) is not implemented.`);\n    }\n    async symlink(path: string) {\n        throw new Error(`symlink of (${path}) is not implemented.`);\n    }\n\n    async saveAndClear(): Promise<void> {\n        if (this.index !== undefined) {\n            await this.adapter.writeBinary(\n                this.plugin.gitManager.getRelativeVaultPath(\n                    this.gitDir + \"/index\"\n                ),\n                this.index,\n                {\n                    ctime: this.indexctime,\n                    mtime: this.indexmtime,\n                }\n            );\n        }\n        this.clearIndex();\n    }\n\n    clearIndex() {\n        this.index = undefined;\n        this.indexctime = undefined;\n        this.indexmtime = undefined;\n    }\n\n    private get gitDir(): string {\n        return this.plugin.settings.gitDir || \".git\";\n    }\n\n    private maybeLog(_: string) {\n        // console.log(text);\n    }\n}\n"
  },
  {
    "path": "src/gitManager/simpleGit.ts",
    "content": "import debug from \"debug\";\nimport * as fsPromises from \"fs/promises\";\nimport type { FileSystemAdapter } from \"obsidian\";\nimport { normalizePath, Notice, Platform } from \"obsidian\";\nimport * as path from \"path\";\nimport { resolve, sep } from \"path\";\nimport type * as simple from \"simple-git\";\nimport simpleGit, { GitError, CleanOptions } from \"simple-git\";\nimport {\n    ASK_PASS_INPUT_FILE,\n    ASK_PASS_SCRIPT,\n    ASK_PASS_SCRIPT_FILE,\n    DEFAULT_WIN_GIT_PATH,\n    GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH,\n} from \"src/constants\";\nimport type { LineAuthorFollowMovement } from \"src/editor/lineAuthor/model\";\nimport { GeneralModal } from \"src/ui/modals/generalModal\";\nimport type ObsidianGit from \"../main\";\nimport type {\n    Blame,\n    BlameCommit,\n    BranchInfo,\n    DiffFile,\n    FileStatusResult,\n    LogEntry,\n    Status,\n} from \"../types\";\nimport { CurrentGitAction, NoNetworkError } from \"../types\";\nimport { impossibleBranch, spawnAsync, splitRemoteBranch } from \"../utils\";\nimport { GitManager } from \"./gitManager\";\n\nexport class SimpleGit extends GitManager {\n    git: simple.SimpleGit;\n    absoluteRepoPath: string;\n    watchAbortController: AbortController | undefined;\n    useDefaultWindowsGitPath: boolean = false;\n    constructor(plugin: ObsidianGit) {\n        super(plugin);\n    }\n\n    async setGitInstance(ignoreError = false): Promise<void> {\n        if (await this.isGitInstalled()) {\n            const adapter = this.app.vault.adapter as FileSystemAdapter;\n            const vaultBasePath = adapter.getBasePath();\n            let basePath = vaultBasePath;\n            // Because the basePath setting is a relative path, a leading `/` must\n            // be appended before concatenating with the path.\n            if (this.plugin.settings.basePath) {\n                const exists = await adapter.exists(\n                    normalizePath(this.plugin.settings.basePath)\n                );\n                if (exists) {\n                    basePath = path.join(\n                        vaultBasePath,\n                        this.plugin.settings.basePath\n                    );\n                } else if (!ignoreError) {\n                    new Notice(\"ObsidianGit: Base path does not exist\");\n                }\n            }\n            this.absoluteRepoPath = basePath;\n\n            this.git = simpleGit({\n                baseDir: basePath,\n                binary:\n                    this.plugin.localStorage.getGitPath() ||\n                    (this.useDefaultWindowsGitPath\n                        ? DEFAULT_WIN_GIT_PATH\n                        : undefined),\n                config: [\"core.quotepath=off\"],\n                unsafe: {\n                    allowUnsafeCustomBinary: true,\n                },\n            });\n            const pathPaths = this.plugin.localStorage.getPATHPaths();\n            const envVars = this.plugin.localStorage.getEnvVars();\n            const gitDir = this.plugin.settings.gitDir;\n            const envs = { ...process.env };\n            if (pathPaths.length > 0) {\n                const path = pathPaths.join(\":\") + \":\" + envs[\"PATH\"];\n                envs[\"PATH\"] = path;\n            }\n            if (gitDir) {\n                envs[\"GIT_DIR\"] = gitDir;\n            }\n            for (const envVar of envVars) {\n                const [key, value] = envVar.split(\"=\");\n                envs[key] = value;\n            }\n\n            const SIMPLE_GIT_NAMESPACE = \"simple-git\";\n            const NAMESPACE_SEPARATOR = \",\";\n            const currentDebug = (localStorage.debug ?? \"\") as string;\n            const namespaces = currentDebug.split(NAMESPACE_SEPARATOR);\n\n            if (\n                !namespaces.includes(SIMPLE_GIT_NAMESPACE) &&\n                !namespaces.includes(`-${SIMPLE_GIT_NAMESPACE}`)\n            ) {\n                namespaces.push(SIMPLE_GIT_NAMESPACE);\n                debug.enable(namespaces.join(NAMESPACE_SEPARATOR));\n            }\n\n            if (await this.git.checkIsRepo()) {\n                // Resolve the relative root reported by git into an absolute path\n                // in case git resides in a different filesystem (eg, WSL)\n                const relativeRoot = await this.git.revparse(\"--show-cdup\");\n                const absoluteRoot = resolve(basePath + sep + relativeRoot);\n\n                this.absoluteRepoPath = absoluteRoot;\n                await this.git.cwd(absoluteRoot);\n            }\n\n            const absolutePluginConfigPath = path.join(\n                vaultBasePath,\n                this.app.vault.configDir,\n                \"plugins\",\n                \"obsidian-git\"\n            );\n            const askPassPath = path.join(\n                absolutePluginConfigPath,\n                ASK_PASS_SCRIPT_FILE\n            );\n\n            if (envs[\"SSH_ASKPASS\"] == undefined) {\n                envs[\"SSH_ASKPASS\"] = askPassPath;\n            }\n\n            // OpenSSH requires DISPLAY variable to be set for SSH_ASKPASS to\n            // detect a graphical environment. This is not the case for e.g.\n            // Windows. Setting SSH_ASKPASS_REQUIRE to \"force\" makes it use\n            // SSH_ASKPASS even without DISPLAY, which allows the askpass script\n            // to work on Windows as well.\n            envs[\"SSH_ASKPASS_REQUIRE\"] = \"force\";\n            envs[\"OBSIDIAN_GIT_CREDENTIALS_INPUT\"] = path.join(\n                absolutePluginConfigPath,\n                ASK_PASS_INPUT_FILE\n            );\n            if (envs[\"SSH_ASKPASS\"] == askPassPath) {\n                this.askpass().catch((e) => this.plugin.displayError(e));\n            }\n\n            envs[\"OBSIDIAN_GIT\"] = \"1\";\n\n            this.git = this.git.env(envs);\n        }\n    }\n\n    // Constructs a path relative to the vault from a path relative to the git repository\n    getRelativeVaultPath(filePath: string): string {\n        const adapter = this.app.vault.adapter as FileSystemAdapter;\n        const from = adapter.getBasePath();\n\n        const to = path.join(this.absoluteRepoPath, filePath);\n\n        let res = path.relative(from, to);\n        if (Platform.isWin) {\n            res = res.replace(/\\\\/g, \"/\");\n        }\n        return res;\n    }\n\n    // Constructs a path relative to the git repository from a path relative to the vault\n    //\n    // @param doConversion - If false, the path is returned as is. This is added because that parameter is often passed on to functions where this method is called.\n    getRelativeRepoPath(\n        filePath: string,\n        doConversion: boolean = true\n    ): string {\n        if (doConversion) {\n            const adapter = this.plugin.app.vault.adapter as FileSystemAdapter;\n            const vaultPath = adapter.getBasePath();\n            const from = this.absoluteRepoPath;\n            const to = path.join(vaultPath, filePath);\n            let res = path.relative(from, to);\n            if (Platform.isWin) {\n                res = res.replace(/\\\\/g, \"/\");\n            }\n            return res;\n        }\n        return filePath;\n    }\n\n    private get absPluginConfigPath(): string {\n        const adapter = this.app.vault.adapter as FileSystemAdapter;\n        const vaultPath = adapter.getBasePath();\n        return path.join(\n            vaultPath,\n            this.app.vault.configDir,\n            \"plugins\",\n            \"obsidian-git\"\n        );\n    }\n\n    private get relPluginConfigPath(): string {\n        return path.join(this.app.vault.configDir, \"plugins\", \"obsidian-git\");\n    }\n    async askpass(): Promise<void> {\n        const adapter = this.app.vault.adapter as FileSystemAdapter;\n        const relPluginConfigDir =\n            this.app.vault.configDir + \"/plugins/obsidian-git/\";\n\n        await this.addAskPassScriptToExclude();\n\n        await fsPromises.writeFile(\n            path.join(this.absPluginConfigPath, ASK_PASS_SCRIPT_FILE),\n            ASK_PASS_SCRIPT\n        );\n        await fsPromises.chmod(\n            path.join(this.absPluginConfigPath, ASK_PASS_SCRIPT_FILE),\n            0o755\n        );\n        this.watchAbortController = new AbortController();\n        const { signal } = this.watchAbortController;\n        try {\n            const watcher = fsPromises.watch(this.absPluginConfigPath, {\n                signal,\n            });\n\n            for await (const event of watcher) {\n                if (event.filename != ASK_PASS_INPUT_FILE) continue;\n                const triggerFilePath =\n                    relPluginConfigDir + ASK_PASS_INPUT_FILE;\n\n                // Wait a bit to ensure the file is fully removed\n                await new Promise((res) => setTimeout(res, 200));\n                if (!(await adapter.exists(triggerFilePath))) continue;\n\n                const data = await adapter.read(triggerFilePath);\n                let notice: Notice | undefined;\n                // The text is too long for the modal, so a notice is shown instead\n                if (data.length > 60) {\n                    notice = new Notice(data, 999_999);\n                }\n                const response = await new GeneralModal(this.plugin, {\n                    allowEmpty: true,\n                    obscure: true,\n                    placeholder:\n                        data.length > 60\n                            ? \"Enter a response to the message.\"\n                            : data,\n                }).openAndGetResult();\n                notice?.hide();\n\n                // Just in case the trigger file was removed while the modal was open\n                if (await adapter.exists(triggerFilePath)) {\n                    await adapter.write(\n                        `${triggerFilePath}.response`,\n                        response ?? \"\"\n                    );\n                }\n            }\n        } catch (error) {\n            this.plugin.displayError(error);\n            await fsPromises.rm(\n                path.join(this.absPluginConfigPath, ASK_PASS_SCRIPT_FILE),\n                { force: true }\n            );\n            await fsPromises.rm(\n                path.join(\n                    this.absPluginConfigPath,\n                    `${ASK_PASS_SCRIPT_FILE}.response`\n                ),\n                { force: true }\n            );\n            await new Promise((res) => setTimeout(res, 5000));\n            this.plugin.log(\"Retry watch for ask pass\");\n            await this.askpass();\n        }\n    }\n\n    /**\n     * Adds the askpass script to the exclude file of the git repository.\n     *\n     * This prevents the script from being tracked by git. This should be no\n     * problem as the script does not contain any sensitive data, but may\n     * cause issues with file permissions on other devices.\n     * See https://github.com/Vinzent03/obsidian-git/issues/903\n     */\n    async addAskPassScriptToExclude(): Promise<void> {\n        try {\n            if (!(await this.git.checkIsRepo())) {\n                return;\n            }\n            const absoluteExcludeFilePath = await this.git.revparse([\n                \"--path-format=absolute\",\n                \"--git-path\",\n                \"info/exclude\",\n            ]);\n\n            const vaultRelativeAskPassScriptFile = path.join(\n                this.app.vault.configDir,\n                \"plugins\",\n                \"obsidian-git\",\n                ASK_PASS_SCRIPT_FILE\n            );\n            const repoRelativeAskPassScriptFile = this.getRelativeRepoPath(\n                vaultRelativeAskPassScriptFile,\n                true\n            );\n\n            const content = await fsPromises.readFile(\n                absoluteExcludeFilePath,\n                \"utf-8\"\n            );\n            const lines = content.split(\"\\n\");\n            const contains = lines.some((line) =>\n                line.contains(repoRelativeAskPassScriptFile)\n            );\n            if (!contains) {\n                await fsPromises.appendFile(\n                    absoluteExcludeFilePath,\n                    repoRelativeAskPassScriptFile + \"\\n\"\n                );\n            }\n        } catch (error) {\n            // Catch any errors, because this is not critical\n            console.error(\n                \"Error while adding askpass script to exclude file:\",\n                error\n            );\n        }\n    }\n\n    unload(): void {\n        this.watchAbortController?.abort();\n    }\n\n    async status(opts?: { path?: string }): Promise<Status> {\n        const dir = opts?.path;\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.status });\n        const status = await this.git.status(\n            dir != undefined ? [\"--\", dir] : []\n        );\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.idle });\n\n        const allFilesFormatted = status.files.map<FileStatusResult>((e) => {\n            const res = this.formatPath(e);\n            return {\n                path: res.path,\n                from: res.from,\n                index: e.index === \"?\" ? \"U\" : e.index,\n                workingDir: e.working_dir === \"?\" ? \"U\" : e.working_dir,\n                vaultPath: this.getRelativeVaultPath(res.path),\n            };\n        });\n        return {\n            all: allFilesFormatted,\n            changed: allFilesFormatted.filter((e) => e.workingDir !== \" \"),\n            staged: allFilesFormatted.filter(\n                (e) => e.index !== \" \" && e.index != \"U\"\n            ),\n            conflicted: status.conflicted.map(\n                (path) => this.formatPath({ path }).path\n            ),\n        };\n    }\n\n    async submoduleAwareHeadRevisonInContainingDirectory(\n        filepath: string\n    ): Promise<string> {\n        const repoPath = this.getRelativeRepoPath(filepath);\n\n        const containingDirectory = path.dirname(repoPath);\n        const args = [\"-C\", containingDirectory, \"rev-parse\", \"HEAD\"];\n\n        const result = this.git.raw(args);\n        result.catch((err) =>\n            console.warn(\"obsidian-git: rev-parse error:\", err)\n        );\n        return (await result).trim();\n    }\n\n    async getSubmodulePaths(): Promise<string[]> {\n        return new Promise<string[]>((resolve) => {\n            this.git.outputHandler((_cmd, stdout, _stderr, args) => {\n                // Do not run this handler on other commands\n                if (!(args.contains(\"submodule\") && args.contains(\"foreach\"))) {\n                    return;\n                }\n\n                let body = \"\";\n                const root =\n                    (\n                        this.app.vault.adapter as FileSystemAdapter\n                    ).getBasePath() +\n                    (this.plugin.settings.basePath\n                        ? \"/\" + this.plugin.settings.basePath\n                        : \"\");\n                stdout.on(\"data\", (chunk: Buffer) => {\n                    body += chunk.toString(\"utf8\");\n                });\n                stdout.on(\"end\", () => {\n                    const submods = body.split(\"\\n\");\n\n                    // Remove words like `Entering` in front of each line and filter empty lines\n                    const strippedSubmods: string[] = submods\n                        .map((i) => {\n                            const submod = i.match(/'([^']*)'/);\n                            if (submod != undefined) {\n                                return root + \"/\" + submod[1] + sep;\n                            }\n                        })\n                        .filter((i): i is string => !!i);\n\n                    strippedSubmods.reverse();\n                    resolve(strippedSubmods);\n                });\n            });\n\n            this.git.subModule([\"foreach\", \"--recursive\", \"\"]).then(\n                () => {\n                    this.git.outputHandler(() => {});\n                },\n                (e) => this.plugin.displayError(e)\n            );\n        });\n    }\n\n    //Remove wrong `\"` like \"My file.md\"\n    formatPath(path: { from?: string; path: string }): {\n        path: string;\n        from?: string;\n    } {\n        function format(path?: string): string | undefined {\n            if (path == undefined) return undefined;\n\n            if (path.startsWith('\"') && path.endsWith('\"')) {\n                return path.substring(1, path.length - 1);\n            } else {\n                return path;\n            }\n        }\n        if (path.from != undefined) {\n            return {\n                from: format(path.from),\n                path: format(path.path)!,\n            };\n        } else {\n            return {\n                path: format(path.path)!,\n            };\n        }\n    }\n\n    async blame(\n        path: string,\n        trackMovement: LineAuthorFollowMovement,\n        ignoreWhitespace: boolean\n    ): Promise<Blame | \"untracked\"> {\n        path = this.getRelativeRepoPath(path);\n\n        if (!(await this.isTracked(path))) return \"untracked\";\n\n        const inSubmodule = await this.getSubmoduleOfFile(path);\n        const args = inSubmodule ? [\"-C\", inSubmodule.submodule] : [];\n        const relativePath = inSubmodule ? inSubmodule.relativeFilepath : path;\n\n        args.push(\"blame\", \"--porcelain\");\n\n        if (ignoreWhitespace) args.push(\"-w\");\n\n        const trackCArg = `-C${GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH}`;\n        switch (trackMovement) {\n            case \"inactive\":\n                break;\n            case \"same-commit\":\n                args.push(\"-C\", trackCArg);\n                break;\n            case \"all-commits\":\n                args.push(\"-C\", \"-C\", trackCArg);\n                break;\n            default:\n                impossibleBranch(trackMovement);\n        }\n\n        args.push(\"--\", relativePath);\n\n        const rawBlame = await this.git.raw(args);\n        return parseBlame(rawBlame);\n    }\n\n    async isTracked(path: string): Promise<boolean> {\n        const inSubmodule = await this.getSubmoduleOfFile(path);\n        const args = inSubmodule ? [\"-C\", inSubmodule.submodule] : [];\n        const relativePath = inSubmodule ? inSubmodule.relativeFilepath : path;\n\n        args.push(\"ls-files\", \"--\", relativePath);\n        return this.git.raw(args).then((x) => x.trim() !== \"\");\n    }\n\n    async commitAll({ message }: { message: string }): Promise<number> {\n        if (this.plugin.settings.updateSubmodules) {\n            this.plugin.setPluginState({ gitAction: CurrentGitAction.commit });\n            const submodulePaths = await this.getSubmodulePaths();\n            for (const item of submodulePaths) {\n                await this.git.cwd({ path: item, root: false }).add(\"-A\");\n                await this.git\n                    .cwd({ path: item, root: false })\n                    .commit(await this.formatCommitMessage(message));\n            }\n        }\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n\n        await this.git.add(\"-A\");\n\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.commit });\n\n        const res = await this.git.commit(\n            await this.formatCommitMessage(message)\n        );\n        this.app.workspace.trigger(\"obsidian-git:head-change\");\n\n        return res.summary.changes;\n    }\n\n    async commit({\n        message,\n        amend,\n    }: {\n        message: string;\n        amend?: boolean;\n    }): Promise<number> {\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.commit });\n\n        const res = (\n            await this.git.commit(\n                await this.formatCommitMessage(message),\n                amend ? [\"--amend\"] : []\n            )\n        ).summary.changes;\n        this.app.workspace.trigger(\"obsidian-git:head-change\");\n\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.idle });\n        return res;\n    }\n\n    async stage(path: string, relativeToVault: boolean): Promise<void> {\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n\n        path = this.getRelativeRepoPath(path, relativeToVault);\n        await this.git.add([\"--\", path]);\n\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.idle });\n    }\n\n    async stageAll({ dir }: { dir?: string }): Promise<void> {\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n        await this.git.add(dir ?? \"-A\");\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.idle });\n    }\n\n    async unstageAll({ dir }: { dir?: string }): Promise<void> {\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n        await this.git.reset(dir != undefined ? [\"--\", dir] : []);\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.idle });\n    }\n\n    async unstage(path: string, relativeToVault: boolean): Promise<void> {\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n\n        path = this.getRelativeRepoPath(path, relativeToVault);\n        await this.git.reset([\"--\", path]);\n\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.idle });\n    }\n\n    async discard(filepath: string): Promise<void> {\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.add });\n        if (await this.isTracked(filepath)) {\n            await this.git.checkout([\"--\", filepath]);\n        }\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.idle });\n    }\n\n    async applyPatch(patch: string): Promise<void> {\n        const patchPath = path.join(this.relPluginConfigPath, \"patch\");\n        await this.app.vault.adapter.write(patchPath, patch);\n        await this.git.applyPatch(patchPath, {\n            \"--cached\": null,\n            \"--unidiff-zero\": null,\n            \"--whitespace\": \"nowarn\",\n        });\n        await this.app.vault.adapter.remove(patchPath);\n    }\n\n    async getUntrackedPaths(opts: { path?: string }): Promise<string[]> {\n        const dir = opts?.path;\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.status });\n        const args = [];\n        if (dir != undefined) {\n            args.push(\"--\", dir);\n        }\n        const untrackedFiles = await this.git.clean(\n            CleanOptions.RECURSIVE + CleanOptions.DRY_RUN,\n            args\n        );\n\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.idle });\n        return untrackedFiles.paths;\n    }\n\n    async hashObject(filepath: string): Promise<string> {\n        // Need to use raw command here to ensure filenames are literally used.\n        // Perhaps we could file a PR? https://github.com/steveukx/git-js/blob/main/simple-git/src/lib/tasks/hash-object.ts\n        filepath = this.getRelativeRepoPath(filepath);\n        const inSubmodule = await this.getSubmoduleOfFile(filepath);\n        const args = inSubmodule ? [\"-C\", inSubmodule.submodule] : [];\n        const relativeFilepath = inSubmodule\n            ? inSubmodule.relativeFilepath\n            : filepath;\n\n        args.push(\"hash-object\", \"--\", relativeFilepath);\n\n        const revision = this.git.raw(args);\n        return revision;\n    }\n\n    async discardAll({ dir }: { dir?: string }): Promise<void> {\n        return this.discard(dir ?? \".\");\n    }\n\n    async pull(): Promise<FileStatusResult[] | undefined> {\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.pull });\n        try {\n            if (this.plugin.settings.updateSubmodules)\n                await this.git.subModule([\n                    \"update\",\n                    \"--remote\",\n                    \"--merge\",\n                    \"--recursive\",\n                ]);\n\n            const branchInfo = await this.branchInfo();\n            const localCommit = await this.git.revparse([branchInfo.current!]);\n\n            if (!branchInfo.tracking && this.plugin.settings.updateSubmodules) {\n                this.plugin.log(\n                    \"No tracking branch found. Ignoring pull of main repo and updating submodules only.\"\n                );\n                return;\n            }\n\n            await this.git.fetch();\n            const upstreamCommit = await this.git.revparse([\n                branchInfo.tracking!,\n            ]);\n\n            if (localCommit !== upstreamCommit) {\n                if (\n                    this.plugin.settings.syncMethod === \"merge\" ||\n                    this.plugin.settings.syncMethod === \"rebase\"\n                ) {\n                    try {\n                        const args = [branchInfo.tracking!];\n\n                        if (this.plugin.settings.mergeStrategy !== \"none\") {\n                            args.push(\n                                `--strategy-option=${this.plugin.settings.mergeStrategy}`\n                            );\n                        }\n\n                        switch (this.plugin.settings.syncMethod) {\n                            case \"merge\":\n                                await this.git.merge(args);\n                                break;\n                            case \"rebase\":\n                                await this.git.rebase(args);\n                        }\n                    } catch (err) {\n                        this.plugin.displayError(\n                            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n                            `Pull failed (${this.plugin.settings.syncMethod}): ${\"message\" in err ? err.message : err}`\n                        );\n                        return;\n                    }\n                } else if (this.plugin.settings.syncMethod === \"reset\") {\n                    try {\n                        await this.git.raw([\n                            \"update-ref\",\n                            `refs/heads/${branchInfo.current}`,\n                            upstreamCommit,\n                        ]);\n                        await this.unstageAll({});\n                    } catch (err) {\n                        this.plugin.displayError(\n                            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n                            `Sync failed (${this.plugin.settings.syncMethod}): ${\"message\" in err ? err.message : err}`\n                        );\n                    }\n                }\n                this.app.workspace.trigger(\"obsidian-git:head-change\");\n\n                const afterMergeCommit = await this.git.revparse([\n                    branchInfo.current!,\n                ]);\n\n                const filesChanged = await this.git.diff([\n                    `${localCommit}..${afterMergeCommit}`,\n                    \"--name-only\",\n                ]);\n\n                return filesChanged\n                    .split(/\\r\\n|\\r|\\n/)\n                    .filter((value) => value.length > 0)\n                    .map((e) => {\n                        return <FileStatusResult>{\n                            path: e,\n                            workingDir: \"P\",\n                            vaultPath: this.getRelativeVaultPath(e),\n                        };\n                    });\n            } else {\n                return [];\n            }\n        } catch (e) {\n            this.convertErrors(e);\n        }\n    }\n\n    async push(): Promise<number | undefined | null> {\n        this.plugin.setPluginState({ gitAction: CurrentGitAction.push });\n        try {\n            if (this.plugin.settings.updateSubmodules) {\n                const res = await this.git.subModule([\n                    \"foreach\",\n                    \"--recursive\",\n                    `tracking=$(git for-each-ref --format='%(upstream:short)' \"$(git symbolic-ref -q HEAD)\"); echo $tracking; if [ ! -z \"$(git diff --shortstat $tracking)\" ]; then git push; fi`,\n                ]);\n                console.log(res);\n            }\n            const status = await this.git.status();\n            const trackingBranch = status.tracking;\n            const currentBranch = status.current!;\n\n            if (!trackingBranch && this.plugin.settings.updateSubmodules) {\n                this.plugin.log(\n                    \"No tracking branch found. Ignoring push of main repo and updating submodules only.\"\n                );\n                return undefined;\n            }\n            let remoteChangedFiles: number | null = null;\n            if (trackingBranch) {\n                remoteChangedFiles = (\n                    await this.git.diffSummary([\n                        currentBranch,\n                        trackingBranch,\n                        \"--\",\n                    ])\n                ).changed;\n            }\n\n            await this.git.push();\n\n            return remoteChangedFiles;\n        } catch (e) {\n            this.convertErrors(e);\n        }\n    }\n\n    async getUnpushedCommits(): Promise<number> {\n        const status = await this.git.status();\n        const trackingBranch = status.tracking;\n        const currentBranch = status.current;\n\n        if (trackingBranch == null || currentBranch == null) {\n            return 0;\n        }\n        const [remote, _] = splitRemoteBranch(trackingBranch);\n        const remoteBranches = await this.getRemoteBranches(remote);\n        if (!remoteBranches.includes(trackingBranch)) {\n            this.plugin.log(\n                `Tracking branch ${trackingBranch} does not exist on remote ${remote}.`\n            );\n            return 0;\n        }\n\n        const remoteChangedFiles = (\n            await this.git.diffSummary([currentBranch, trackingBranch, \"--\"])\n        ).changed;\n\n        return remoteChangedFiles;\n    }\n\n    async canPush(): Promise<boolean> {\n        // allow pushing in submodules even if the root has no changes.\n        if (this.plugin.settings.updateSubmodules === true) {\n            return true;\n        }\n        const status = await this.git.status();\n        const trackingBranch = status.tracking;\n        const currentBranch = status.current!;\n        if (!trackingBranch) {\n            return false;\n        }\n        const remoteChangedFiles = (\n            await this.git.diffSummary([currentBranch, trackingBranch, \"--\"])\n        ).changed;\n\n        return remoteChangedFiles !== 0;\n    }\n\n    async checkRequirements(): Promise<\n        \"valid\" | \"missing-repo\" | \"missing-git\"\n    > {\n        if (!(await this.isGitInstalled())) {\n            return \"missing-git\";\n        }\n        if (!(await this.git.checkIsRepo())) {\n            return \"missing-repo\";\n        }\n        return \"valid\";\n    }\n\n    async branchInfo(): Promise<BranchInfo> {\n        const status = await this.git.status();\n        const branches = await this.git.branch([\"--no-color\"]);\n\n        return {\n            current: status.current || undefined,\n            tracking: status.tracking || undefined,\n            branches: branches.all,\n        };\n    }\n\n    async getRemoteUrl(remote: string): Promise<string | undefined> {\n        try {\n            return (await this.git.remote([\"get-url\", remote])) || undefined;\n        } catch (error) {\n            // Verify the error is at least not about git is not found or similar. Checks if the remote exists or not\n            if (String(error).contains(remote)) {\n                return undefined;\n            } else {\n                throw error;\n            }\n        }\n    }\n\n    // https://github.com/kometenstaub/obsidian-version-history-diff/issues/3\n    async log(\n        file: string | undefined,\n        relativeToVault = true,\n        limit?: number,\n        ref?: string\n    ): Promise<(LogEntry & { fileName?: string })[]> {\n        let path: string | undefined;\n        if (file) {\n            path = this.getRelativeRepoPath(file, relativeToVault);\n        }\n        const opts: Record<string, unknown> = {\n            file: path,\n            maxCount: limit,\n            // Ensures that the changed files are listed for merge commits as well and the commit is not repeated for each parent.\n            // This only lists the changed files for the first parent.\n            \"--diff-merges\": \"first-parent\",\n            \"--name-status\": null,\n        };\n        if (ref) {\n            opts[ref] = null;\n        }\n        const res = await this.git.log(opts);\n\n        return res.all.map<LogEntry>((e) => ({\n            ...e,\n            author: {\n                name: e.author_name,\n                email: e.author_email,\n            },\n            refs: e.refs.split(\", \").filter((e) => e.length > 0),\n            diff: {\n                ...e.diff!,\n                files:\n                    e.diff?.files.map<DiffFile>(\n                        (f: simple.DiffResultNameStatusFile) => ({\n                            ...f,\n                            status: f.status!,\n                            path: f.file,\n                            hash: e.hash,\n                            vaultPath: this.getRelativeVaultPath(f.file),\n                            fromPath: f.from,\n                            fromVaultPath:\n                                f.from != undefined\n                                    ? this.getRelativeVaultPath(f.from)\n                                    : undefined,\n                            binary: f.binary,\n                        })\n                    ) ?? [],\n            },\n            fileName: e.diff?.files.first()?.file,\n        }));\n    }\n\n    async show(\n        commitHash: string,\n        file: string,\n        relativeToVault = true\n    ): Promise<string> {\n        const path = this.getRelativeRepoPath(file, relativeToVault);\n\n        return this.git.show([commitHash + \":\" + path]);\n    }\n\n    async checkout(branch: string, remote?: string): Promise<void> {\n        if (remote) {\n            branch = `${remote}/${branch}`;\n        }\n        await this.git.checkout(branch);\n        if (this.plugin.settings.submoduleRecurseCheckout) {\n            const submodulePaths = await this.getSubmodulePaths();\n            for (const submodulePath of submodulePaths) {\n                const branchSummary = await this.git\n                    .cwd({ path: submodulePath, root: false })\n                    .branch();\n                if (Object.keys(branchSummary.branches).includes(branch)) {\n                    await this.git\n                        .cwd({ path: submodulePath, root: false })\n                        .checkout(branch);\n                }\n            }\n        }\n    }\n\n    async createBranch(branch: string): Promise<void> {\n        await this.git.checkout([\"-b\", branch]);\n    }\n\n    async deleteBranch(branch: string, force: boolean): Promise<void> {\n        await this.git.branch([force ? \"-D\" : \"-d\", branch]);\n    }\n\n    async branchIsMerged(branch: string): Promise<boolean> {\n        const notMergedBranches = await this.git.branch([\"--no-merged\"]);\n        return !notMergedBranches.all.contains(branch);\n    }\n\n    async init(): Promise<void> {\n        await this.git.init(false);\n    }\n\n    async clone(url: string, dir: string, depth?: number): Promise<void> {\n        await this.git.clone(\n            url,\n            path.join(\n                (this.app.vault.adapter as FileSystemAdapter).getBasePath(),\n                dir\n            ),\n            depth ? [\"--depth\", `${depth}`] : []\n        );\n\n        // Set required attributes like `absoluteRepoPath` and add the script to the exclude file if needed.\n        await this.setGitInstance();\n    }\n\n    async setConfig(path: string, value: string | undefined): Promise<void> {\n        if (value == undefined) {\n            await this.git.raw([\"config\", \"--local\", \"--unset\", path]);\n        } else {\n            await this.git.addConfig(path, value);\n        }\n    }\n\n    async getConfig(\n        path: string,\n        scope: \"local\" | \"global\" | \"all\" = \"local\"\n    ): Promise<string | undefined> {\n        const res = await this.git.getConfig(\n            path.toLowerCase(),\n            scope == \"all\" ? undefined : scope\n        );\n        return res.value ?? undefined;\n    }\n\n    async fetch(remote?: string): Promise<void> {\n        await this.git.fetch(remote != undefined ? [remote] : []);\n    }\n\n    async setRemote(name: string, url: string): Promise<void> {\n        if ((await this.getRemotes()).includes(name))\n            await this.git.remote([\"set-url\", name, url]);\n        else {\n            await this.git.remote([\"add\", name, url]);\n        }\n    }\n\n    async getRemoteBranches(remote: string): Promise<string[]> {\n        const res = await this.git.branch([\"-r\", \"--list\", `${remote}*`]);\n\n        const list = [];\n        for (const item in res.branches) {\n            list.push(res.branches[item].name);\n        }\n        return list;\n    }\n\n    async getRemotes() {\n        const res = await this.git.remote([]);\n        if (res) {\n            return res.trim().split(\"\\n\");\n        } else {\n            return [];\n        }\n    }\n\n    async removeRemote(remoteName: string) {\n        await this.git.removeRemote(remoteName);\n    }\n\n    /**\n     * @param remoteBranch - The remote branch to set as upstream, in the format \"remote/branch\"\n     */\n    async updateUpstreamBranch(remoteBranch: string) {\n        try {\n            // git 1.8+\n            await this.git.branch([\"--set-upstream-to\", remoteBranch]);\n        } catch {\n            try {\n                // git 1.7 - 1.8\n                await this.git.branch([\"--set-upstream\", remoteBranch]);\n            } catch {\n                // fallback for when setting upstream branch to a branch that does not exist on the remote yet. Setting it with push instead.\n                const [remote, remoteBranchName] =\n                    splitRemoteBranch(remoteBranch);\n                const branchInfo = await this.branchInfo();\n                await this.git.push([\n                    \"--set-upstream\",\n                    remote,\n                    `${branchInfo.current}:${remoteBranchName}`,\n                ]);\n            }\n        }\n    }\n\n    updateGitPath(_: string): Promise<void> {\n        return this.setGitInstance();\n    }\n\n    updateBasePath(_: string): Promise<void> {\n        return this.setGitInstance(true);\n    }\n\n    async getDiffString(\n        filePath: string,\n        stagedChanges = false,\n        hash?: string\n    ): Promise<string> {\n        if (stagedChanges)\n            return await this.git.diff([\"--cached\", \"--\", filePath]);\n        if (hash) return await this.git.show([`${hash}`, \"--\", filePath]);\n        else return await this.git.diff([\"--\", filePath]);\n    }\n\n    async diff(\n        file: string,\n        commit1: string,\n        commit2: string\n    ): Promise<string> {\n        return await this.git.diff([`${commit1}..${commit2}`, \"--\", file]);\n    }\n\n    async rawCommand(command: string): Promise<string> {\n        const parts = command.split(\" \"); // Very simple parsing, may need string-argv\n        const res = await this.git.raw(parts[0], ...parts.slice(1));\n        return res;\n    }\n\n    async getSubmoduleOfFile(\n        repositoryRelativeFile: string\n    ): Promise<{ submodule: string; relativeFilepath: string } | undefined> {\n        // Documentation: https://git-scm.com/docs/git-rev-parse\n\n        if (\n            !(await this.app.vault.adapter.exists(\n                path.dirname(repositoryRelativeFile)\n            ))\n        ) {\n            return undefined;\n        }\n\n        // git -C <dir-of-file> rev-parse --show-toplevel\n        // returns the submodules repository root as an absolute path\n        let submoduleRoot = await this.git.raw(\n            [\n                \"-C\",\n                path.dirname(repositoryRelativeFile),\n                \"rev-parse\",\n                \"--show-toplevel\",\n            ],\n            (err) => err && console.warn(\"get-submodule-of-file\", err?.message)\n        );\n        submoduleRoot = submoduleRoot.trim();\n\n        // git -C <dir-of-file> rev-parse --show-superproject-working-tree\n        // returns the parent git repository, if the file is in a submodule - otherwise empty.\n        const superProject = await this.git.raw(\n            [\n                \"-C\",\n                path.dirname(repositoryRelativeFile),\n                \"rev-parse\",\n                \"--show-superproject-working-tree\",\n            ],\n            (err) => err && console.warn(\"get-submodule-of-file\", err?.message)\n        );\n\n        if (superProject.trim() === \"\") {\n            return undefined; // not in submodule\n        }\n\n        const fsAdapter = this.app.vault.adapter as FileSystemAdapter;\n        const absolutePath = fsAdapter.getFullPath(\n            path.normalize(repositoryRelativeFile)\n        );\n        const newRelativePath = path.relative(submoduleRoot, absolutePath);\n\n        return { submodule: submoduleRoot, relativeFilepath: newRelativePath };\n    }\n\n    async getLastCommitTime(): Promise<Date | undefined> {\n        try {\n            const res = await this.git.log({ n: 1 });\n            if (res != null && res.latest != null) {\n                return new Date(res.latest.date);\n            }\n        } catch (error) {\n            if (error instanceof GitError) {\n                if (error.message.contains(\"does not have any commits yet\")) {\n                    return undefined;\n                }\n            } else {\n                throw error;\n            }\n        }\n    }\n\n    private async isGitInstalled(): Promise<boolean> {\n        // https://github.com/steveukx/git-js/issues/402\n        const gitPath = this.plugin.localStorage.getGitPath();\n        const command = await spawnAsync(gitPath || \"git\", [\"--version\"], {});\n\n        if (command.error) {\n            if (Platform.isWin && !gitPath) {\n                this.plugin.log(\n                    `Git not found in PATH. Checking standard installation path(${DEFAULT_WIN_GIT_PATH}) of Git for Windows.`\n                );\n                const command = await spawnAsync(DEFAULT_WIN_GIT_PATH, [\n                    \"--version\",\n                ]);\n                if (command.error) {\n                    console.error(command.error);\n                    return false;\n                } else {\n                    this.useDefaultWindowsGitPath = true;\n                }\n            } else {\n                console.error(command.error);\n                return false;\n            }\n        } else {\n            this.useDefaultWindowsGitPath = false;\n        }\n        return true;\n    }\n\n    private convertErrors(error: unknown): never {\n        if (error instanceof GitError) {\n            const message = String(error.message);\n            const networkFailure =\n                message.contains(\"Could not resolve host\") ||\n                message.contains(\"Unable to resolve host\") ||\n                message.contains(\"Unable to open connection\") ||\n                message.match(\n                    /ssh: connect to host .*? port .*?: Operation timed out/\n                ) != null ||\n                message.match(\n                    /ssh: connect to host .*? port .*?: Network is unreachable/\n                ) != null ||\n                message.match(\n                    /ssh: connect to host .*? port .*?: Undefined error: 0/\n                ) != null;\n            if (networkFailure) {\n                throw new NoNetworkError(message);\n            }\n        }\n        throw error;\n    }\n\n    async isFileTrackedByLFS(filePath: string): Promise<boolean> {\n        try {\n            // Checks if Gits filter attribute is set to lfs for the file, which means it is (or will be) tracked by LFS.\n            const result = await this.git.raw([\n                \"check-attr\",\n                \"filter\",\n                filePath,\n            ]);\n            return result.includes(\"filter: lfs\");\n        } catch (error) {\n            const errorMessage =\n                error instanceof Error ? error.message : String(error);\n            this.plugin.displayError(\n                `Error checking LFS status: ${errorMessage}`\n            );\n            return false;\n        }\n    }\n}\n\nexport const zeroCommit: BlameCommit = {\n    hash: \"000000\",\n    isZeroCommit: true,\n    summary: \"\",\n};\n\n// Parse git blame porcelain format: https://git-scm.com/docs/git-blame#_the_porcelain_format\nfunction parseBlame(blameOutputUnnormalized: string): Blame {\n    const blameOutput = blameOutputUnnormalized.replace(\"\\r\\n\", \"\\n\");\n\n    const blameLines = blameOutput.split(\"\\n\");\n\n    const result: Blame = {\n        commits: new Map(),\n        hashPerLine: [undefined!], // one-based indices\n        originalFileLineNrPerLine: [undefined!],\n        finalFileLineNrPerLine: [undefined!],\n        groupSizePerStartingLine: new Map(),\n    };\n\n    let line = 1;\n    for (let bi = 0; bi < blameLines.length; ) {\n        if (startsWithNonWhitespace(blameLines[bi])) {\n            const lineInfo = blameLines[bi].split(\" \");\n\n            const commitHash = parseLineInfoInto(lineInfo, line, result);\n            bi++;\n\n            // parse header values until a tab is encountered\n            for (; startsWithNonWhitespace(blameLines[bi]); bi++) {\n                const spaceSeparatedHeaderValues = blameLines[bi].split(\" \");\n                parseHeaderInto(spaceSeparatedHeaderValues, result, line);\n            }\n            finalizeBlameCommitInfo(result.commits.get(commitHash)!);\n\n            // skip tab prefixed line\n            line += 1;\n        } else if (blameLines[bi] === \"\" && bi === blameLines.length - 1) {\n            // EOF\n        } else {\n            throw Error(\n                `Expected non-whitespace line or EOF, but found: ${blameLines[bi]}`\n            );\n        }\n        bi++;\n    }\n    return result;\n}\n\nfunction parseLineInfoInto(lineInfo: string[], line: number, result: Blame) {\n    const hash = lineInfo[0];\n    result.hashPerLine.push(hash);\n    result.originalFileLineNrPerLine.push(parseInt(lineInfo[1]));\n    result.finalFileLineNrPerLine.push(parseInt(lineInfo[2]));\n    if (lineInfo.length >= 4)\n        result.groupSizePerStartingLine.set(line, parseInt(lineInfo[3]));\n\n    if (parseInt(lineInfo[2]) !== line) {\n        throw Error(\n            `git-blame output is out of order: ${line} vs ${lineInfo[2]}`\n        );\n    }\n\n    return hash;\n}\n\nfunction parseHeaderInto(header: string[], out: Blame, line: number) {\n    const key = header[0];\n    const value = header.slice(1).join(\" \");\n    const commitHash = out.hashPerLine[line];\n    const commit =\n        out.commits.get(commitHash) ||\n        <BlameCommit>{\n            hash: commitHash,\n            author: {},\n            committer: {},\n            previous: {},\n        };\n\n    switch (key) {\n        case \"summary\":\n            commit.summary = value;\n            break;\n\n        case \"author\":\n            commit.author!.name = value;\n            break;\n        case \"author-mail\":\n            commit.author!.email = removeEmailBrackets(value);\n            break;\n        case \"author-time\":\n            commit.author!.epochSeconds = parseInt(value);\n            break;\n        case \"author-tz\":\n            commit.author!.tz = value;\n            break;\n\n        case \"committer\":\n            commit.committer!.name = value;\n            break;\n        case \"committer-mail\":\n            commit.committer!.email = removeEmailBrackets(value);\n            break;\n        case \"committer-time\":\n            commit.committer!.epochSeconds = parseInt(value);\n            break;\n        case \"committer-tz\":\n            commit.committer!.tz = value;\n            break;\n\n        case \"previous\":\n            commit.previous!.commitHash = value;\n            break;\n        case \"filename\":\n            commit.previous!.filename = value;\n            break;\n    }\n    out.commits.set(commitHash, commit);\n}\n\nfunction finalizeBlameCommitInfo(commit: BlameCommit) {\n    if (commit.summary === undefined) {\n        throw Error(`Summary not provided for commit: ${commit.hash}`);\n    }\n\n    if (isUndefinedOrEmptyObject(commit.author)) {\n        commit.author = undefined;\n    }\n    if (isUndefinedOrEmptyObject(commit.committer)) {\n        commit.committer = undefined;\n    }\n    if (isUndefinedOrEmptyObject(commit.previous)) {\n        commit.previous = undefined;\n    }\n\n    commit.isZeroCommit = Boolean(commit.hash.match(/^0*$/));\n}\n\nfunction isUndefinedOrEmptyObject(obj: object | undefined | null): boolean {\n    return !obj || Object.keys(obj).length === 0;\n}\n\nfunction startsWithNonWhitespace(str: string): boolean {\n    return str.length > 0 && str[0].trim() === str[0];\n}\n\nfunction removeEmailBrackets(gitEmail: string) {\n    const prefixCleaned = gitEmail.startsWith(\"<\")\n        ? gitEmail.substring(1)\n        : gitEmail;\n    return prefixCleaned.endsWith(\">\")\n        ? prefixCleaned.substring(0, prefixCleaned.length - 1)\n        : prefixCleaned;\n}\n"
  },
  {
    "path": "src/main.ts",
    "content": "import { Errors } from \"isomorphic-git\";\nimport type { Debouncer, Menu, TAbstractFile, WorkspaceLeaf } from \"obsidian\";\nimport {\n    debounce,\n    FileSystemAdapter,\n    MarkdownView,\n    normalizePath,\n    Notice,\n    Platform,\n    Plugin,\n    TFile,\n    TFolder,\n    moment,\n} from \"obsidian\";\nimport * as path from \"path\";\nimport { pluginRef } from \"src/pluginGlobalRef\";\nimport { PromiseQueue } from \"src/promiseQueue\";\nimport { ObsidianGitSettingsTab } from \"src/setting/settings\";\nimport { StatusBar } from \"src/statusBar\";\nimport { CustomMessageModal } from \"src/ui/modals/customMessageModal\";\nimport AutomaticsManager from \"./automaticsManager\";\nimport { addCommmands } from \"./commands\";\nimport {\n    CONFLICT_OUTPUT_FILE,\n    DEFAULT_SETTINGS,\n    DIFF_VIEW_CONFIG,\n    HISTORY_VIEW_CONFIG,\n    SOURCE_CONTROL_VIEW_CONFIG,\n    SPLIT_DIFF_VIEW_CONFIG,\n} from \"./constants\";\nimport type { GitManager } from \"./gitManager/gitManager\";\nimport { IsomorphicGit } from \"./gitManager/isomorphicGit\";\nimport { SimpleGit } from \"./gitManager/simpleGit\";\nimport { LocalStorageSettings } from \"./setting/localStorageSettings\";\nimport Tools from \"./tools\";\nimport type {\n    FileStatusResult,\n    ObsidianGitSettings,\n    PluginState,\n    Status,\n    UnstagedFile,\n} from \"./types\";\nimport {\n    CurrentGitAction,\n    mergeSettingsByPriority,\n    NoNetworkError,\n} from \"./types\";\nimport DiffView from \"./ui/diff/diffView\";\nimport SplitDiffView from \"./ui/diff/splitDiffView\";\nimport HistoryView from \"./ui/history/historyView\";\nimport { BranchModal } from \"./ui/modals/branchModal\";\nimport { GeneralModal } from \"./ui/modals/generalModal\";\nimport GitView from \"./ui/sourceControl/sourceControl\";\nimport { BranchStatusBar } from \"./ui/statusBar/branchStatusBar\";\nimport {\n    assertNever,\n    convertPathToAbsoluteGitignoreRule,\n    formatRemoteUrl,\n    spawnAsync,\n    splitRemoteBranch,\n} from \"./utils\";\nimport { DiscardModal, type DiscardResult } from \"./ui/modals/discardModal\";\nimport { HunkActions } from \"./editor/signs/hunkActions\";\nimport { EditorIntegration } from \"./editor/editorIntegration\";\n\nexport default class ObsidianGit extends Plugin {\n    gitManager: GitManager;\n    automaticsManager = new AutomaticsManager(this);\n    tools = new Tools(this);\n    localStorage = new LocalStorageSettings(this);\n    settings: ObsidianGitSettings;\n    settingsTab?: ObsidianGitSettingsTab;\n    statusBar?: StatusBar;\n    branchBar?: BranchStatusBar;\n    state: PluginState = {\n        gitAction: CurrentGitAction.idle,\n        offlineMode: false,\n    };\n    lastPulledFiles: FileStatusResult[];\n    gitReady = false;\n    promiseQueue: PromiseQueue = new PromiseQueue(this);\n\n    /**\n     * Debouncer for the auto commit after file changes.\n     */\n    autoCommitDebouncer: Debouncer<[], void> | undefined;\n    cachedStatus: Status | undefined;\n    // Used to store the path of the file that is currently shown in the diff view.\n    lastDiffViewState: Record<string, unknown> | undefined;\n    intervalsToClear: number[] = [];\n    editorIntegration: EditorIntegration = new EditorIntegration(this);\n    hunkActions = new HunkActions(this);\n\n    /**\n     * Debouncer for the refresh of the git status for the source control view after file changes.\n     */\n    debRefresh: Debouncer<[], void>;\n\n    setPluginState(state: Partial<PluginState>): void {\n        this.state = Object.assign(this.state, state);\n        this.statusBar?.display();\n    }\n\n    async updateCachedStatus(): Promise<Status> {\n        this.app.workspace.trigger(\"obsidian-git:loading-status\");\n        this.cachedStatus = await this.gitManager.status();\n        if (this.cachedStatus.conflicted.length > 0) {\n            this.localStorage.setConflict(true);\n            await this.branchBar?.display();\n        } else {\n            this.localStorage.setConflict(false);\n            await this.branchBar?.display();\n        }\n\n        this.app.workspace.trigger(\n            \"obsidian-git:status-changed\",\n            this.cachedStatus\n        );\n        return this.cachedStatus;\n    }\n\n    async refresh() {\n        if (!this.gitReady) return;\n\n        const gitViews = this.app.workspace.getLeavesOfType(\n            SOURCE_CONTROL_VIEW_CONFIG.type\n        );\n        const historyViews = this.app.workspace.getLeavesOfType(\n            HISTORY_VIEW_CONFIG.type\n        );\n\n        if (\n            this.settings.changedFilesInStatusBar ||\n            gitViews.some((leaf) => !(leaf.isDeferred ?? false)) ||\n            historyViews.some((leaf) => !(leaf.isDeferred ?? false))\n        ) {\n            await this.updateCachedStatus().catch((e) => this.displayError(e));\n        }\n\n        this.app.workspace.trigger(\"obsidian-git:refreshed\");\n\n        // We don't put a line authoring refresh here, as it would force a re-loading\n        // of the line authoring feature - which would lead to a jumpy editor-view in the\n        // ui after every rename event.\n    }\n\n    refreshUpdatedHead() {}\n\n    async onload() {\n        console.log(\n            \"loading \" +\n                this.manifest.name +\n                \" plugin: v\" +\n                this.manifest.version\n        );\n\n        pluginRef.plugin = this;\n\n        this.localStorage.migrate();\n        await this.loadSettings();\n        await this.migrateSettings();\n\n        this.settingsTab = new ObsidianGitSettingsTab(this.app, this);\n        this.addSettingTab(this.settingsTab);\n\n        if (!this.localStorage.getPluginDisabled()) {\n            this.registerStuff();\n\n            this.app.workspace.onLayoutReady(() =>\n                this.init({ fromReload: false }).catch((e) =>\n                    this.displayError(e)\n                )\n            );\n        }\n    }\n\n    onExternalSettingsChange() {\n        this.reloadSettings().catch((e) => this.displayError(e));\n    }\n\n    /** Reloads the settings from disk and applies them by unloading the plugin\n     * and initializing it again.\n     */\n    async reloadSettings(): Promise<void> {\n        const previousSettings = JSON.stringify(this.settings);\n\n        await this.loadSettings();\n\n        const newSettings = JSON.stringify(this.settings);\n\n        // Only reload plugin if the settings have actually changed\n        if (previousSettings !== newSettings) {\n            this.log(\"Reloading settings\");\n\n            this.unloadPlugin();\n\n            await this.init({ fromReload: true });\n\n            this.app.workspace\n                .getLeavesOfType(SOURCE_CONTROL_VIEW_CONFIG.type)\n                .forEach((leaf) => {\n                    if (!(leaf.isDeferred ?? false))\n                        return (leaf.view as GitView).reload();\n                });\n\n            this.app.workspace\n                .getLeavesOfType(HISTORY_VIEW_CONFIG.type)\n                .forEach((leaf) => {\n                    if (!(leaf.isDeferred ?? false))\n                        return (leaf.view as HistoryView).reload();\n                });\n        }\n    }\n\n    /** This method only registers events, views, commands and more.\n     *\n     * This only needs to be called once since the registered events are\n     * unregistered when the plugin is unloaded.\n     *\n     * This mustn't depend on the plugin's settings.\n     */\n    registerStuff(): void {\n        this.registerEvent(\n            this.app.workspace.on(\"obsidian-git:refresh\", () => {\n                this.refresh().catch((e) => this.displayError(e));\n            })\n        );\n        this.registerEvent(\n            this.app.workspace.on(\"obsidian-git:head-change\", () => {\n                this.refreshUpdatedHead();\n            })\n        );\n\n        this.registerEvent(\n            this.app.workspace.on(\"file-menu\", (menu, file, source) => {\n                this.handleFileMenu(menu, file, source, \"file-manu\");\n            })\n        );\n\n        this.registerEvent(\n            this.app.workspace.on(\"obsidian-git:menu\", (menu, path, source) => {\n                this.handleFileMenu(menu, path, source, \"obsidian-git:menu\");\n            })\n        );\n\n        this.registerEvent(\n            this.app.workspace.on(\"active-leaf-change\", (leaf) => {\n                this.onActiveLeafChange(leaf);\n            })\n        );\n        this.registerEvent(\n            this.app.vault.on(\"modify\", () => {\n                this.debRefresh();\n                this.autoCommitDebouncer?.();\n            })\n        );\n        this.registerEvent(\n            this.app.vault.on(\"delete\", () => {\n                this.debRefresh();\n                this.autoCommitDebouncer?.();\n            })\n        );\n        this.registerEvent(\n            this.app.vault.on(\"create\", () => {\n                this.debRefresh();\n                this.autoCommitDebouncer?.();\n            })\n        );\n        this.registerEvent(\n            this.app.vault.on(\"rename\", () => {\n                this.debRefresh();\n                this.autoCommitDebouncer?.();\n            })\n        );\n\n        this.registerView(SOURCE_CONTROL_VIEW_CONFIG.type, (leaf) => {\n            return new GitView(leaf, this);\n        });\n\n        this.registerView(HISTORY_VIEW_CONFIG.type, (leaf) => {\n            return new HistoryView(leaf, this);\n        });\n\n        this.registerView(DIFF_VIEW_CONFIG.type, (leaf) => {\n            return new DiffView(leaf, this);\n        });\n\n        this.registerView(SPLIT_DIFF_VIEW_CONFIG.type, (leaf) => {\n            return new SplitDiffView(leaf, this);\n        });\n        this.addRibbonIcon(\n            \"git-pull-request\",\n            \"Open Git source control\",\n            async () => {\n                const leafs = this.app.workspace.getLeavesOfType(\n                    SOURCE_CONTROL_VIEW_CONFIG.type\n                );\n                let leaf: WorkspaceLeaf;\n                if (leafs.length === 0) {\n                    leaf =\n                        this.app.workspace.getRightLeaf(false) ??\n                        this.app.workspace.getLeaf();\n                    await leaf.setViewState({\n                        type: SOURCE_CONTROL_VIEW_CONFIG.type,\n                    });\n                } else {\n                    leaf = leafs.first()!;\n                }\n                await this.app.workspace.revealLeaf(leaf);\n            }\n        );\n\n        this.registerHoverLinkSource(SOURCE_CONTROL_VIEW_CONFIG.type, {\n            display: \"Git View\",\n            defaultMod: true,\n        });\n\n        this.editorIntegration.onLoadPlugin();\n\n        this.setRefreshDebouncer();\n\n        addCommmands(this);\n    }\n\n    setRefreshDebouncer(): void {\n        this.debRefresh?.cancel();\n        this.debRefresh = debounce(\n            () => {\n                if (this.settings.refreshSourceControl) {\n                    this.refresh().catch(console.error);\n                }\n            },\n            this.settings.refreshSourceControlTimer,\n            true\n        );\n    }\n\n    async addFileToGitignore(\n        filePath: string,\n        isFolder?: boolean\n    ): Promise<void> {\n        const gitRelativePath = this.gitManager.getRelativeRepoPath(\n            filePath,\n            true\n        );\n        // Define an absolute rule that can apply only for this item.\n        const gitignoreRule = convertPathToAbsoluteGitignoreRule({\n            isFolder,\n            gitRelativePath,\n        });\n        await this.app.vault.adapter.append(\n            this.gitManager.getRelativeVaultPath(\".gitignore\"),\n            \"\\n\" + gitignoreRule\n        );\n        this.app.workspace.trigger(\"obsidian-git:refresh\");\n    }\n\n    handleFileMenu(\n        menu: Menu,\n        file: TAbstractFile | string,\n        source: string,\n        type: \"file-manu\" | \"obsidian-git:menu\"\n    ): void {\n        if (!this.gitReady) return;\n        if (!this.settings.showFileMenu) return;\n        if (!file) return;\n        let filePath: string;\n        if (typeof file === \"string\") {\n            filePath = file;\n        } else {\n            filePath = file.path;\n        }\n\n        if (source == \"file-explorer-context-menu\") {\n            menu.addItem((item) => {\n                item.setTitle(`Git: Stage`)\n                    .setIcon(\"plus-circle\")\n                    .setSection(\"action\")\n                    .onClick((_) => {\n                        this.promiseQueue.addTask(async () => {\n                            if (file instanceof TFile) {\n                                await this.stageFile(file);\n                            } else {\n                                await this.gitManager.stageAll({\n                                    dir: this.gitManager.getRelativeRepoPath(\n                                        filePath,\n                                        true\n                                    ),\n                                });\n                                this.app.workspace.trigger(\n                                    \"obsidian-git:refresh\"\n                                );\n                            }\n                        });\n                    });\n            });\n            menu.addItem((item) => {\n                item.setTitle(`Git: Unstage`)\n                    .setIcon(\"minus-circle\")\n                    .setSection(\"action\")\n                    .onClick((_) => {\n                        this.promiseQueue.addTask(async () => {\n                            if (file instanceof TFile) {\n                                await this.unstageFile(file);\n                            } else {\n                                await this.gitManager.unstageAll({\n                                    dir: this.gitManager.getRelativeRepoPath(\n                                        filePath,\n                                        true\n                                    ),\n                                });\n\n                                this.app.workspace.trigger(\n                                    \"obsidian-git:refresh\"\n                                );\n                            }\n                        });\n                    });\n            });\n            menu.addItem((item) => {\n                item.setTitle(`Git: Add to .gitignore`)\n                    .setIcon(\"file-x\")\n                    .setSection(\"action\")\n                    .onClick((_) => {\n                        this.addFileToGitignore(\n                            filePath,\n                            file instanceof TFolder\n                        ).catch((e) => this.displayError(e));\n                    });\n            });\n        }\n\n        if (source == \"git-source-control\") {\n            menu.addItem((item) => {\n                item.setTitle(`Git: Add to .gitignore`)\n                    .setIcon(\"file-x\")\n                    .setSection(\"action\")\n                    .onClick((_) => {\n                        this.addFileToGitignore(\n                            filePath,\n                            file instanceof TFolder\n                        ).catch((e) => this.displayError(e));\n                    });\n            });\n            const gitManager = this.app.vault.adapter;\n            if (\n                type === \"obsidian-git:menu\" &&\n                gitManager instanceof FileSystemAdapter\n            ) {\n                menu.addItem((item) => {\n                    item.setTitle(\"Open in default app\")\n                        .setIcon(\"arrow-up-right\")\n                        .setSection(\"action\")\n                        .onClick((_) => {\n                            this.app.openWithDefaultApp(filePath);\n                        });\n                });\n                menu.addItem((item) => {\n                    item.setTitle(\"Show in system explorer\")\n                        .setIcon(\"arrow-up-right\")\n                        .setSection(\"action\")\n                        .onClick((_) => {\n                            // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access\n                            (window as any).electron.shell.showItemInFolder(\n                                path.join(gitManager.getBasePath(), filePath)\n                            );\n                        });\n                });\n            }\n        }\n    }\n\n    async migrateSettings(): Promise<void> {\n        if (this.settings.mergeOnPull != undefined) {\n            this.settings.syncMethod = this.settings.mergeOnPull\n                ? \"merge\"\n                : \"rebase\";\n            this.settings.mergeOnPull = undefined;\n            await this.saveSettings();\n        }\n        if (this.settings.autoCommitMessage === undefined) {\n            this.settings.autoCommitMessage = this.settings.commitMessage;\n            await this.saveSettings();\n        }\n        if (this.settings.gitPath != undefined) {\n            this.localStorage.setGitPath(this.settings.gitPath);\n            this.settings.gitPath = undefined;\n            await this.saveSettings();\n        }\n        if (this.settings.username != undefined) {\n            this.localStorage.setPassword(this.settings.username);\n            this.settings.username = undefined;\n            await this.saveSettings();\n        }\n    }\n\n    unloadPlugin() {\n        this.gitReady = false;\n\n        this.editorIntegration.onUnloadPlugin();\n        this.automaticsManager.unload();\n        this.branchBar?.remove();\n        this.statusBar?.remove();\n        this.statusBar = undefined;\n        this.branchBar = undefined;\n        this.gitManager.unload();\n        this.promiseQueue.clear();\n\n        for (const interval of this.intervalsToClear) {\n            window.clearInterval(interval);\n        }\n        this.intervalsToClear = [];\n\n        this.debRefresh.cancel();\n    }\n\n    onunload() {\n        this.unloadPlugin();\n\n        console.log(\"unloading \" + this.manifest.name + \" plugin\");\n    }\n\n    async loadSettings() {\n        // At first startup, `data` is `null` because data.json does not exist.\n        let data = (await this.loadData()) as ObsidianGitSettings | null;\n        //Check for existing settings\n        if (data == undefined) {\n            data = <ObsidianGitSettings>{ showedMobileNotice: true };\n        }\n        this.settings = mergeSettingsByPriority(DEFAULT_SETTINGS, data);\n    }\n\n    async saveSettings() {\n        this.settingsTab?.beforeSaveSettings();\n        await this.saveData(this.settings);\n    }\n\n    get useSimpleGit(): boolean {\n        return Platform.isDesktopApp;\n    }\n\n    async init({ fromReload = false }): Promise<void> {\n        if (this.settings.showStatusBar && !this.statusBar) {\n            const statusBarEl = this.addStatusBarItem();\n            this.statusBar = new StatusBar(statusBarEl, this);\n            this.intervalsToClear.push(\n                window.setInterval(() => this.statusBar?.display(), 1000)\n            );\n        }\n\n        try {\n            if (this.useSimpleGit) {\n                this.gitManager = new SimpleGit(this);\n                await (this.gitManager as SimpleGit).setGitInstance();\n            } else {\n                this.gitManager = new IsomorphicGit(this);\n            }\n\n            const result = await this.gitManager.checkRequirements();\n            const pausedAutomatics = this.localStorage.getPausedAutomatics();\n            switch (result) {\n                case \"missing-git\":\n                    this.displayError(\n                        `Cannot run git command. Trying to run: '${this.localStorage.getGitPath() || \"git\"}' .`\n                    );\n                    break;\n                case \"missing-repo\":\n                    new Notice(\n                        \"Can't find a valid git repository. Please create one via the given command or clone an existing repo.\",\n                        10000\n                    );\n                    break;\n                case \"valid\":\n                    this.gitReady = true;\n                    this.setPluginState({ gitAction: CurrentGitAction.idle });\n\n                    if (\n                        Platform.isDesktop &&\n                        this.settings.showBranchStatusBar &&\n                        !this.branchBar\n                    ) {\n                        const branchStatusBarEl = this.addStatusBarItem();\n                        this.branchBar = new BranchStatusBar(\n                            branchStatusBarEl,\n                            this\n                        );\n                        this.intervalsToClear.push(\n                            window.setInterval(\n                                () =>\n                                    void this.branchBar\n                                        ?.display()\n                                        .catch(console.error),\n                                60000\n                            )\n                        );\n                    }\n                    await this.branchBar?.display();\n\n                    this.editorIntegration.onReady();\n\n                    this.app.workspace.trigger(\"obsidian-git:refresh\");\n                    /// Among other things, this notifies the history view that git is ready\n                    this.app.workspace.trigger(\"obsidian-git:head-change\");\n\n                    if (\n                        !fromReload &&\n                        this.settings.autoPullOnBoot &&\n                        !pausedAutomatics\n                    ) {\n                        this.promiseQueue.addTask(() =>\n                            this.pullChangesFromRemote()\n                        );\n                    }\n\n                    if (!pausedAutomatics) {\n                        await this.automaticsManager.init();\n                    }\n\n                    if (pausedAutomatics) {\n                        new Notice(\"Automatic routines are currently paused.\");\n                    }\n\n                    break;\n                default:\n                    this.log(\n                        \"Something weird happened. The 'checkRequirements' result is \" +\n                            /* eslint-disable-next-line @typescript-eslint/restrict-plus-operands */\n                            result\n                    );\n            }\n        } catch (error) {\n            this.displayError(error);\n            console.error(error);\n        }\n    }\n\n    async createNewRepo() {\n        try {\n            await this.gitManager.init();\n            new Notice(\"Initialized new repo\");\n            await this.init({ fromReload: true });\n        } catch (e) {\n            this.displayError(e);\n        }\n    }\n\n    async cloneNewRepo() {\n        const modal = new GeneralModal(this, {\n            placeholder: \"Enter remote URL\",\n        });\n        const url = await modal.openAndGetResult();\n        if (url) {\n            const confirmOption = \"Vault Root\";\n            let dir = await new GeneralModal(this, {\n                options:\n                    this.gitManager instanceof IsomorphicGit\n                        ? [confirmOption]\n                        : [],\n                placeholder:\n                    \"Enter directory for clone. It needs to be empty or not existent.\",\n                allowEmpty: this.gitManager instanceof IsomorphicGit,\n            }).openAndGetResult();\n            if (dir == undefined) return;\n            if (dir === confirmOption) {\n                dir = \".\";\n            }\n\n            dir = normalizePath(dir);\n            if (dir === \"/\") {\n                dir = \".\";\n            }\n\n            if (dir === \".\") {\n                const modal = new GeneralModal(this, {\n                    options: [\"NO\", \"YES\"],\n                    placeholder: `Does your remote repo contain a ${this.app.vault.configDir} directory at the root?`,\n                    onlySelection: true,\n                });\n                const containsConflictDir = await modal.openAndGetResult();\n                if (containsConflictDir === undefined) {\n                    new Notice(\"Aborted clone\");\n                    return;\n                } else if (containsConflictDir === \"YES\") {\n                    const confirmOption =\n                        \"DELETE ALL YOUR LOCAL CONFIG AND PLUGINS\";\n                    const modal = new GeneralModal(this, {\n                        options: [\"Abort clone\", confirmOption],\n                        placeholder: `To avoid conflicts, the local ${this.app.vault.configDir} directory needs to be deleted.`,\n                        onlySelection: true,\n                    });\n                    const shouldDelete =\n                        (await modal.openAndGetResult()) === confirmOption;\n                    if (shouldDelete) {\n                        await this.app.vault.adapter.rmdir(\n                            this.app.vault.configDir,\n                            true\n                        );\n                    } else {\n                        new Notice(\"Aborted clone\");\n                        return;\n                    }\n                }\n            }\n            const depth = await new GeneralModal(this, {\n                placeholder:\n                    \"Specify depth of clone. Leave empty for full clone.\",\n                allowEmpty: true,\n            }).openAndGetResult();\n            let depthInt = undefined;\n            if (depth === undefined) {\n                new Notice(\"Aborted clone\");\n                return;\n            }\n\n            if (depth !== \"\") {\n                depthInt = parseInt(depth);\n                if (isNaN(depthInt)) {\n                    new Notice(\"Invalid depth. Aborting clone.\");\n                    return;\n                }\n            }\n            new Notice(`Cloning new repo into \"${dir}\"`);\n            const oldBase = this.settings.basePath;\n            const customDir = dir && dir !== \".\";\n            //Set new base path before clone to ensure proper .git/index file location in isomorphic-git\n            if (customDir) {\n                this.settings.basePath = dir;\n            }\n            try {\n                await this.gitManager.clone(\n                    formatRemoteUrl(url),\n                    dir,\n                    depthInt\n                );\n                new Notice(\"Cloned new repo.\");\n                new Notice(\"Please restart Obsidian\");\n\n                if (customDir) {\n                    await this.saveSettings();\n                }\n            } catch (error) {\n                this.displayError(error);\n                this.settings.basePath = oldBase;\n                await this.saveSettings();\n            }\n        }\n    }\n\n    /**\n     * Retries to call `this.init()` if necessary, otherwise returns directly\n     * @returns true if `this.gitManager` is ready to be used, false if not.\n     */\n    async isAllInitialized(): Promise<boolean> {\n        if (!this.gitReady) {\n            await this.init({ fromReload: true });\n        }\n        return this.gitReady;\n    }\n\n    ///Used for command\n    async pullChangesFromRemote(): Promise<void> {\n        if (!(await this.isAllInitialized())) return;\n\n        const filesUpdated = await this.pull();\n        if (filesUpdated === false) {\n            return;\n        }\n        if (!filesUpdated) {\n            this.displayMessage(\"Pull: Everything is up-to-date\");\n        }\n\n        if (this.gitManager instanceof SimpleGit) {\n            const status = await this.updateCachedStatus();\n            if (status.conflicted.length > 0) {\n                this.displayError(\n                    `You have conflicts in ${status.conflicted.length} ${\n                        status.conflicted.length == 1 ? \"file\" : \"files\"\n                    }`\n                );\n                await this.handleConflict(status.conflicted);\n            }\n        }\n\n        this.app.workspace.trigger(\"obsidian-git:refresh\");\n        this.setPluginState({ gitAction: CurrentGitAction.idle });\n    }\n\n    async commitAndSync({\n        fromAutoBackup,\n        requestCustomMessage = false,\n        commitMessage,\n        onlyStaged = false,\n    }: {\n        fromAutoBackup: boolean;\n        requestCustomMessage?: boolean;\n        commitMessage?: string;\n        onlyStaged?: boolean;\n    }): Promise<void> {\n        if (!(await this.isAllInitialized())) return;\n\n        if (\n            this.settings.syncMethod == \"reset\" &&\n            this.settings.pullBeforePush\n        ) {\n            await this.pull();\n        }\n\n        const commitSuccessful = await this.commit({\n            fromAuto: fromAutoBackup,\n            requestCustomMessage,\n            commitMessage,\n            onlyStaged,\n        });\n        if (!commitSuccessful) {\n            return;\n        }\n\n        if (\n            this.settings.syncMethod != \"reset\" &&\n            this.settings.pullBeforePush\n        ) {\n            await this.pull();\n        }\n\n        if (!this.settings.disablePush) {\n            // Prevent trying to push every time. Only if unpushed commits are present\n            if (\n                (await this.remotesAreSet()) &&\n                (await this.gitManager.canPush())\n            ) {\n                await this.push();\n            } else {\n                this.displayMessage(\"No commits to push\");\n            }\n        }\n        this.setPluginState({ gitAction: CurrentGitAction.idle });\n    }\n\n    // Returns true if commit was successfully\n    async commit({\n        fromAuto,\n        requestCustomMessage = false,\n        onlyStaged = false,\n        commitMessage,\n        amend = false,\n    }: {\n        fromAuto: boolean;\n        requestCustomMessage?: boolean;\n        onlyStaged?: boolean;\n        commitMessage?: string;\n        amend?: boolean;\n    }): Promise<boolean> {\n        if (!(await this.isAllInitialized())) return false;\n        try {\n            let hadConflict = this.localStorage.getConflict();\n\n            let status: Status | undefined;\n            let stagedFiles: { vaultPath: string; path: string }[] = [];\n            let unstagedFiles: (UnstagedFile & { vaultPath: string })[] = [];\n\n            if (this.gitManager instanceof SimpleGit) {\n                await this.mayDeleteConflictFile();\n                status = await this.updateCachedStatus();\n\n                //Should not be necessary, but just in case\n                if (status.conflicted.length == 0) {\n                    hadConflict = false;\n                }\n\n                // check for conflict files on auto backup\n                if (fromAuto && status.conflicted.length > 0) {\n                    this.displayError(\n                        `Did not commit, because you have conflicts in ${\n                            status.conflicted.length\n                        } ${\n                            status.conflicted.length == 1 ? \"file\" : \"files\"\n                        }. Please resolve them and commit per command.`\n                    );\n                    await this.handleConflict(status.conflicted);\n                    return false;\n                }\n                stagedFiles = status.staged;\n\n                // This typecast is only needed to hide the fact that `type` is missing, but that is only needed for isomorphic-git\n                unstagedFiles = status.changed as unknown as (UnstagedFile & {\n                    vaultPath: string;\n                })[];\n            } else {\n                // isomorphic-git section\n\n                if (fromAuto && hadConflict) {\n                    // isomorphic-git doesn't have a way to detect current\n                    // conflicts, they are only detected on commit\n                    //\n                    // Conflicts should only be resolved by manually committing.\n                    this.displayError(\n                        `Did not commit, because you have conflicts. Please resolve them and commit per command.`\n                    );\n                    return false;\n                } else {\n                    if (hadConflict) {\n                        await this.mayDeleteConflictFile();\n                    }\n                    const gitManager = this.gitManager as IsomorphicGit;\n                    if (onlyStaged) {\n                        stagedFiles = await gitManager.getStagedFiles();\n                    } else {\n                        const res = await gitManager.getUnstagedFiles();\n                        unstagedFiles = res.map(({ path, type }) => ({\n                            vaultPath:\n                                this.gitManager.getRelativeVaultPath(path),\n                            path,\n                            type,\n                        }));\n                    }\n                }\n            }\n\n            if (\n                await this.tools.hasTooBigFiles(\n                    onlyStaged\n                        ? stagedFiles\n                        : [...stagedFiles, ...unstagedFiles]\n                )\n            ) {\n                this.setPluginState({ gitAction: CurrentGitAction.idle });\n                return false;\n            }\n\n            if (\n                unstagedFiles.length + stagedFiles.length !== 0 ||\n                hadConflict\n            ) {\n                // The commit message from settings or previously set in the\n                // source control view\n                let cmtMessage = (commitMessage ??= fromAuto\n                    ? this.settings.autoCommitMessage\n                    : this.settings.commitMessage);\n\n                // Optionally ask the user via a modal for a commit message\n                if (\n                    (fromAuto && this.settings.customMessageOnAutoBackup) ||\n                    requestCustomMessage\n                ) {\n                    if (!this.settings.disablePopups && fromAuto) {\n                        new Notice(\n                            \"Auto backup: Please enter a custom commit message. Leave empty to abort\"\n                        );\n                    }\n                    const modalMessage = await new CustomMessageModal(\n                        this\n                    ).openAndGetResult();\n\n                    if (\n                        modalMessage != undefined &&\n                        modalMessage != \"\" &&\n                        modalMessage != \"...\"\n                    ) {\n                        cmtMessage = modalMessage;\n                    } else {\n                        this.setPluginState({\n                            gitAction: CurrentGitAction.idle,\n                        });\n                        return false;\n                    }\n\n                    // On desktop may run a script to get the commit message\n                } else if (\n                    this.gitManager instanceof SimpleGit &&\n                    this.settings.commitMessageScript\n                ) {\n                    const templateScript = this.settings.commitMessageScript;\n                    const hostname = this.localStorage.getHostname() || \"\";\n                    let formattedScript = templateScript.replace(\n                        \"{{hostname}}\",\n                        hostname\n                    );\n\n                    formattedScript = formattedScript.replace(\n                        \"{{date}}\",\n                        moment().format(this.settings.commitDateFormat)\n                    );\n\n                    const res = await spawnAsync(\n                        \"sh\",\n                        [\"-c\", formattedScript],\n                        { cwd: this.gitManager.absoluteRepoPath }\n                    );\n                    if (res.code != 0) {\n                        this.displayError(res.stderr);\n                    } else if (res.stdout.trim().length == 0) {\n                        this.displayMessage(\n                            \"Stdout from commit message script is empty. Using default message.\"\n                        );\n                    } else {\n                        cmtMessage = res.stdout;\n                    }\n                }\n\n                // Check if commit message is empty after all processing\n                if (!cmtMessage || cmtMessage.trim() === \"\") {\n                    new Notice(\"Commit aborted: No commit message provided\");\n                    this.setPluginState({\n                        gitAction: CurrentGitAction.idle,\n                    });\n                    return false;\n                }\n\n                let committedFiles: number | undefined;\n                if (onlyStaged) {\n                    committedFiles = await this.gitManager.commit({\n                        message: cmtMessage,\n                        amend,\n                    });\n                } else {\n                    committedFiles = await this.gitManager.commitAll({\n                        message: cmtMessage,\n                        status,\n                        unstagedFiles,\n                        amend,\n                    });\n                }\n\n                // Handle eventually resolved conflicts\n                if (this.gitManager instanceof SimpleGit) {\n                    await this.updateCachedStatus();\n                }\n\n                let roughly = false;\n                if (committedFiles === undefined) {\n                    roughly = true;\n                    committedFiles =\n                        unstagedFiles.length + stagedFiles.length || 0;\n                }\n                this.displayMessage(\n                    `Committed${roughly ? \" approx.\" : \"\"} ${committedFiles} ${\n                        committedFiles == 1 ? \"file\" : \"files\"\n                    }`\n                );\n            } else {\n                this.displayMessage(\"No changes to commit\");\n            }\n            this.app.workspace.trigger(\"obsidian-git:refresh\");\n\n            return true;\n        } catch (error) {\n            this.displayError(error);\n            return false;\n        }\n    }\n\n    /*\n     * Returns true if push was successful\n     */\n    async push(): Promise<boolean> {\n        if (!(await this.isAllInitialized())) return false;\n        if (!(await this.remotesAreSet())) {\n            return false;\n        }\n        const hadConflict = this.localStorage.getConflict();\n        try {\n            if (this.gitManager instanceof SimpleGit)\n                await this.mayDeleteConflictFile();\n\n            // Refresh because of pull\n            let status: Status;\n            if (\n                this.gitManager instanceof SimpleGit &&\n                (status = await this.updateCachedStatus()).conflicted.length > 0\n            ) {\n                this.displayError(\n                    `Cannot push. You have conflicts in ${\n                        status.conflicted.length\n                    } ${status.conflicted.length == 1 ? \"file\" : \"files\"}`\n                );\n                await this.handleConflict(status.conflicted);\n                return false;\n            } else if (\n                this.gitManager instanceof IsomorphicGit &&\n                hadConflict\n            ) {\n                this.displayError(`Cannot push. You have conflicts`);\n                return false;\n            }\n            this.log(\"Pushing....\");\n            const pushedFiles = await this.gitManager.push();\n\n            if (pushedFiles !== undefined) {\n                if (pushedFiles === null) {\n                    this.displayMessage(`Pushed to remote`);\n                } else if (pushedFiles > 0) {\n                    this.displayMessage(\n                        `Pushed ${pushedFiles} ${\n                            pushedFiles == 1 ? \"file\" : \"files\"\n                        } to remote`\n                    );\n                } else {\n                    this.displayMessage(`No commits to push`);\n                }\n            }\n            this.setPluginState({ offlineMode: false });\n            this.app.workspace.trigger(\"obsidian-git:refresh\");\n            return true;\n        } catch (e) {\n            if (e instanceof NoNetworkError) {\n                this.handleNoNetworkError(e);\n            } else {\n                this.displayError(e);\n            }\n            return false;\n        }\n    }\n\n    /** Used for internals\n     *  Returns whether the pull added a commit or not.\n     *\n     *  See {@link pullChangesFromRemote} for the command version.\n     */\n    async pull(): Promise<false | number> {\n        if (!(await this.remotesAreSet())) {\n            return false;\n        }\n        try {\n            this.log(\"Pulling....\");\n            const pulledFiles = (await this.gitManager.pull()) || [];\n            this.setPluginState({ offlineMode: false });\n\n            if (pulledFiles.length > 0) {\n                this.displayMessage(\n                    `Pulled ${pulledFiles.length} ${\n                        pulledFiles.length == 1 ? \"file\" : \"files\"\n                    } from remote`\n                );\n                this.lastPulledFiles = pulledFiles;\n            }\n            return pulledFiles.length;\n        } catch (e) {\n            this.displayError(e);\n\n            return false;\n        }\n    }\n\n    async fetch(): Promise<void> {\n        if (!(await this.remotesAreSet())) {\n            return;\n        }\n        try {\n            await this.gitManager.fetch();\n\n            this.displayMessage(`Fetched from remote`);\n            this.setPluginState({ offlineMode: false });\n            this.app.workspace.trigger(\"obsidian-git:refresh\");\n        } catch (error) {\n            this.displayError(error);\n        }\n    }\n\n    async mayDeleteConflictFile(): Promise<void> {\n        const file = this.app.vault.getAbstractFileByPath(CONFLICT_OUTPUT_FILE);\n        if (file) {\n            this.app.workspace.iterateAllLeaves((leaf) => {\n                if (\n                    leaf.view instanceof MarkdownView &&\n                    leaf.view.file?.path == file.path\n                ) {\n                    leaf.detach();\n                }\n            });\n            await this.app.vault.delete(file);\n        }\n    }\n\n    async stageFile(file: TFile): Promise<boolean> {\n        if (!(await this.isAllInitialized())) return false;\n\n        await this.gitManager.stage(file.path, true);\n\n        this.app.workspace.trigger(\"obsidian-git:refresh\");\n\n        this.setPluginState({ gitAction: CurrentGitAction.idle });\n        return true;\n    }\n\n    async unstageFile(file: TFile): Promise<boolean> {\n        if (!(await this.isAllInitialized())) return false;\n\n        await this.gitManager.unstage(file.path, true);\n\n        this.app.workspace.trigger(\"obsidian-git:refresh\");\n\n        this.setPluginState({ gitAction: CurrentGitAction.idle });\n        return true;\n    }\n\n    async switchBranch(): Promise<string | undefined> {\n        if (!(await this.isAllInitialized())) return;\n\n        const branchInfo = await this.gitManager.branchInfo();\n        const selectedBranch = await new BranchModal(\n            this,\n            branchInfo.branches\n        ).openAndGetReslt();\n\n        if (selectedBranch != undefined) {\n            await this.gitManager.checkout(selectedBranch);\n            this.displayMessage(`Switched to ${selectedBranch}`);\n            this.app.workspace.trigger(\"obsidian-git:refresh\");\n            await this.branchBar?.display();\n            return selectedBranch;\n        }\n    }\n\n    async switchRemoteBranch(): Promise<string | undefined> {\n        if (!(await this.isAllInitialized())) return;\n\n        const selectedBranch = (await this.selectRemoteBranch()) || \"\";\n\n        const [remote, branch] = splitRemoteBranch(selectedBranch);\n\n        if (branch != undefined && remote != undefined) {\n            await this.gitManager.checkout(branch, remote);\n            this.displayMessage(`Switched to ${selectedBranch}`);\n            await this.branchBar?.display();\n            return selectedBranch;\n        }\n    }\n\n    async createBranch(): Promise<string | undefined> {\n        if (!(await this.isAllInitialized())) return;\n\n        const newBranch = await new GeneralModal(this, {\n            placeholder: \"Create new branch\",\n        }).openAndGetResult();\n        if (newBranch != undefined) {\n            await this.gitManager.createBranch(newBranch);\n            this.displayMessage(`Created new branch ${newBranch}`);\n            await this.branchBar?.display();\n            return newBranch;\n        }\n    }\n\n    async deleteBranch(): Promise<string | undefined> {\n        if (!(await this.isAllInitialized())) return;\n\n        const branchInfo = await this.gitManager.branchInfo();\n        if (branchInfo.current) branchInfo.branches.remove(branchInfo.current);\n        const branch = await new GeneralModal(this, {\n            options: branchInfo.branches,\n            placeholder: \"Delete branch\",\n            onlySelection: true,\n        }).openAndGetResult();\n        if (branch != undefined) {\n            let force = false;\n            const merged = await this.gitManager.branchIsMerged(branch);\n            // Using await inside IF throws exception\n            if (!merged) {\n                const forceAnswer = await new GeneralModal(this, {\n                    options: [\"YES\", \"NO\"],\n                    placeholder:\n                        \"This branch isn't merged into HEAD. Force delete?\",\n                    onlySelection: true,\n                }).openAndGetResult();\n                if (forceAnswer !== \"YES\") {\n                    return;\n                }\n                force = forceAnswer === \"YES\";\n            }\n            await this.gitManager.deleteBranch(branch, force);\n            this.displayMessage(`Deleted branch ${branch}`);\n            await this.branchBar?.display();\n            return branch;\n        }\n    }\n\n    /** Ensures that the upstream branch is set.\n     * If not, it will prompt the user to set it.\n     *\n     * An exception is when the user has submodules enabled.\n     * In this case, the upstream branch is not required,\n     * to allow pulling/pushing only the submodules and not the outer repo.\n     */\n    async remotesAreSet(): Promise<boolean> {\n        if (this.settings.updateSubmodules) {\n            return true;\n        }\n        if (\n            this.gitManager instanceof SimpleGit &&\n            (await this.gitManager.getConfig(\"push.autoSetupRemote\", \"all\")) ==\n                \"true\"\n        ) {\n            return true;\n        }\n        if (!(await this.gitManager.branchInfo()).tracking) {\n            new Notice(\"No upstream branch is set. Please select one.\");\n            return await this.setUpstreamBranch();\n        }\n        return true;\n    }\n\n    async setUpstreamBranch(): Promise<boolean> {\n        const remoteBranch = await this.selectRemoteBranch();\n\n        if (remoteBranch == undefined) {\n            this.displayError(\"Aborted. No upstream-branch is set!\", 10000);\n            this.setPluginState({ gitAction: CurrentGitAction.idle });\n            return false;\n        } else {\n            await this.gitManager.updateUpstreamBranch(remoteBranch);\n            this.displayMessage(`Set upstream branch to ${remoteBranch}`);\n            this.setPluginState({ gitAction: CurrentGitAction.idle });\n            return true;\n        }\n    }\n\n    async discardAll(path?: string): Promise<DiscardResult> {\n        if (!(await this.isAllInitialized())) return false;\n\n        const status = await this.gitManager.status({ path });\n\n        let filesToDeleteCount = 0;\n        let filesToDiscardCount = 0;\n        for (const file of status.changed) {\n            if (file.workingDir == \"U\") {\n                filesToDeleteCount++;\n            } else {\n                filesToDiscardCount++;\n            }\n        }\n        if (filesToDeleteCount + filesToDiscardCount == 0) {\n            return false;\n        }\n\n        const result = await new DiscardModal({\n            app: this.app,\n            filesToDeleteCount,\n            filesToDiscardCount,\n            path: path ?? \"\",\n        }).openAndGetResult();\n\n        switch (result) {\n            case false:\n                return result;\n            case \"discard\":\n                await this.gitManager.discardAll({\n                    dir: path,\n                    status: this.cachedStatus,\n                });\n                break;\n            case \"delete\": {\n                await this.gitManager.discardAll({\n                    dir: path,\n                    status: this.cachedStatus,\n                });\n                const untrackedPaths = await this.gitManager.getUntrackedPaths({\n                    path,\n                    status: this.cachedStatus,\n                });\n                for (const file of untrackedPaths) {\n                    const vaultPath =\n                        this.gitManager.getRelativeVaultPath(file);\n                    const tFile =\n                        this.app.vault.getAbstractFileByPath(vaultPath);\n\n                    if (tFile) {\n                        await this.app.fileManager.trashFile(tFile);\n                    } else {\n                        if (file.endsWith(\"/\")) {\n                            await this.app.vault.adapter.rmdir(vaultPath, true);\n                        } else {\n                            await this.app.vault.adapter.remove(vaultPath);\n                        }\n                    }\n                }\n                break;\n            }\n            default:\n                assertNever(result);\n        }\n        this.app.workspace.trigger(\"obsidian-git:refresh\");\n        return result;\n    }\n\n    async handleConflict(conflicted?: string[]): Promise<void> {\n        this.localStorage.setConflict(true);\n        let lines: string[] | undefined;\n        if (conflicted !== undefined) {\n            lines = [\n                \"# Conflicts\",\n                \"Please resolve them and commit them using the commands `Git: Commit all changes` followed by `Git: Push`\",\n                \"(This file will automatically be deleted before commit)\",\n                \"[[#Additional Instructions]] available below file list\",\n                \"\",\n                ...conflicted.map((e) => {\n                    const file = this.app.vault.getAbstractFileByPath(e);\n                    if (file instanceof TFile) {\n                        const link = this.app.metadataCache.fileToLinktext(\n                            file,\n                            \"/\"\n                        );\n                        return `- [[${link}]]`;\n                    } else {\n                        return `- Not a file: ${e}`;\n                    }\n                }),\n                `\n# Additional Instructions\nI strongly recommend to use \"Source mode\" for viewing the conflicted files. For simple conflicts, in each file listed above replace every occurrence of the following text blocks with the desired text.\n\n\\`\\`\\`diff\n<<<<<<< HEAD\n    File changes in local repository\n=======\n    File changes in remote repository\n>>>>>>> origin/main\n\\`\\`\\``,\n            ];\n        }\n        await this.tools.writeAndOpenFile(lines?.join(\"\\n\"));\n    }\n\n    async editRemotes(): Promise<string | undefined> {\n        if (!(await this.isAllInitialized())) return;\n\n        const remotes = await this.gitManager.getRemotes();\n\n        const nameModal = new GeneralModal(this, {\n            options: remotes,\n            placeholder:\n                \"Select or create a new remote by typing its name and selecting it\",\n        });\n        const remoteName = await nameModal.openAndGetResult();\n\n        if (remoteName) {\n            const oldUrl = await this.gitManager.getRemoteUrl(remoteName);\n\n            const urlModal = new GeneralModal(this, {\n                initialValue: oldUrl,\n                placeholder: \"Enter remote URL\",\n            });\n            // urlModal.inputEl.setText(oldUrl ?? \"\");\n            const remoteURL = await urlModal.openAndGetResult();\n            if (remoteURL) {\n                await this.gitManager.setRemote(\n                    remoteName,\n                    formatRemoteUrl(remoteURL)\n                );\n                return remoteName;\n            }\n        }\n    }\n\n    async selectRemoteBranch(): Promise<string | undefined> {\n        let remotes = await this.gitManager.getRemotes();\n        let selectedRemote: string | undefined;\n        if (remotes.length === 0) {\n            selectedRemote = await this.editRemotes();\n            if (selectedRemote == undefined) {\n                remotes = await this.gitManager.getRemotes();\n            }\n        }\n\n        const nameModal = new GeneralModal(this, {\n            options: remotes,\n            placeholder:\n                \"Select or create a new remote by typing its name and selecting it\",\n        });\n        const remoteName =\n            selectedRemote ?? (await nameModal.openAndGetResult());\n\n        if (remoteName) {\n            this.displayMessage(\"Fetching remote branches\");\n            await this.gitManager.fetch(remoteName);\n            const branches =\n                await this.gitManager.getRemoteBranches(remoteName);\n            const branchModal = new GeneralModal(this, {\n                options: branches,\n                placeholder:\n                    \"Select or create a new remote branch by typing its name and selecting it\",\n            });\n            const branch = await branchModal.openAndGetResult();\n            if (branch == undefined) return;\n            if (!branch.startsWith(remoteName + \"/\")) {\n                // If the branch does not start with the remote name, prepend it\n                return `${remoteName}/${branch}`;\n            }\n            return branch; // Already in the correct format\n        }\n    }\n\n    async removeRemote() {\n        if (!(await this.isAllInitialized())) return;\n\n        const remotes = await this.gitManager.getRemotes();\n\n        const nameModal = new GeneralModal(this, {\n            options: remotes,\n            placeholder: \"Select a remote\",\n        });\n        const remoteName = await nameModal.openAndGetResult();\n\n        if (remoteName) {\n            await this.gitManager.removeRemote(remoteName);\n        }\n    }\n\n    onActiveLeafChange(leaf: WorkspaceLeaf | null): void {\n        const view = leaf?.view;\n        // Prevent removing focus when switching to other panes than file panes like search or GitView\n        if (\n            !view?.getState().file &&\n            !(view instanceof DiffView || view instanceof SplitDiffView)\n        )\n            return;\n\n        const sourceControlLeaf = this.app.workspace\n            .getLeavesOfType(SOURCE_CONTROL_VIEW_CONFIG.type)\n            .first();\n        const historyLeaf = this.app.workspace\n            .getLeavesOfType(HISTORY_VIEW_CONFIG.type)\n            .first();\n\n        // Clear existing active state\n        sourceControlLeaf?.view.containerEl\n            .querySelector(`div.tree-item-self.is-active`)\n            ?.removeClass(\"is-active\");\n        historyLeaf?.view.containerEl\n            .querySelector(`div.tree-item-self.is-active`)\n            ?.removeClass(\"is-active\");\n\n        if (\n            leaf?.view instanceof DiffView ||\n            leaf?.view instanceof SplitDiffView\n        ) {\n            const path = leaf.view.state.bFile;\n            const escapedPath = path.replace(/[\"\\\\]/g, \"\\\\$&\");\n            this.lastDiffViewState = leaf.view.getState();\n            let el: Element | undefined | null;\n            if (sourceControlLeaf && leaf.view.state.aRef == \"HEAD\") {\n                el = sourceControlLeaf.view.containerEl.querySelector(\n                    `div.staged div.tree-item-self[data-path=\"${escapedPath}\"]`\n                );\n            } else if (sourceControlLeaf && leaf.view.state.aRef == \"\") {\n                el = sourceControlLeaf.view.containerEl.querySelector(\n                    `div.changes div.tree-item-self[data-path=\"${escapedPath}\"]`\n                );\n            } else if (historyLeaf) {\n                el = historyLeaf.view.containerEl.querySelector(\n                    `div.tree-item-self[data-path='${escapedPath}']`\n                );\n            }\n            el?.addClass(\"is-active\");\n        } else {\n            this.lastDiffViewState = undefined;\n        }\n    }\n\n    handleNoNetworkError(_: NoNetworkError): void {\n        if (!this.state.offlineMode) {\n            this.displayError(\n                \"Git: Going into offline mode. Future network errors will no longer be displayed.\",\n                2000\n            );\n        } else {\n            this.log(\"Encountered network error, but already in offline mode\");\n        }\n        this.setPluginState({\n            gitAction: CurrentGitAction.idle,\n            offlineMode: true,\n        });\n    }\n\n    // region: displaying / formatting messages\n    displayMessage(message: string, timeout: number = 4 * 1000): void {\n        this.statusBar?.displayMessage(message.toLowerCase(), timeout);\n\n        if (!this.settings.disablePopups) {\n            if (\n                !this.settings.disablePopupsForNoChanges ||\n                !message.startsWith(\"No changes\")\n            ) {\n                new Notice(message, 5 * 1000);\n            }\n        }\n\n        this.log(message);\n    }\n\n    displayError(data: unknown, timeout: number = 10 * 1000): void {\n        if (data instanceof Errors.UserCanceledError) {\n            new Notice(\"Aborted\");\n            return;\n        }\n        let error: Error;\n        if (data instanceof Error) {\n            error = data;\n        } else {\n            error = new Error(String(data));\n        }\n\n        this.setPluginState({ gitAction: CurrentGitAction.idle });\n        if (this.settings.showErrorNotices) {\n            new Notice(error.message, timeout);\n        }\n        console.error(`${this.manifest.id}:`, error.stack);\n        this.statusBar?.displayMessage(error.message.toLowerCase(), timeout);\n    }\n\n    log(...data: unknown[]) {\n        console.log(`${this.manifest.id}:`, ...data);\n    }\n}\n"
  },
  {
    "path": "src/openInGitHub.ts",
    "content": "import type { Editor, TFile } from \"obsidian\";\nimport { Notice } from \"obsidian\";\nimport type { GitManager } from \"./gitManager/gitManager\";\nimport { SimpleGit } from \"./gitManager/simpleGit\";\n\nexport async function openLineInGitHub(\n    editor: Editor,\n    file: TFile,\n    manager: GitManager\n) {\n    const data = await getData(file, manager);\n\n    if (data.result === \"failure\") {\n        new Notice(data.reason);\n        return;\n    }\n\n    const { isGitHub, branch, repo, user, filePath } = data;\n    if (isGitHub) {\n        const from = editor.getCursor(\"from\").line + 1;\n        const to = editor.getCursor(\"to\").line + 1;\n        if (from === to) {\n            window.open(\n                `https://github.com/${user}/${repo}/blob/${branch}/${filePath}?plain=1#L${from}`\n            );\n        } else {\n            window.open(\n                `https://github.com/${user}/${repo}/blob/${branch}/${filePath}?plain=1#L${from}-L${to}`\n            );\n        }\n    } else {\n        new Notice(\"It seems like you are not using GitHub\");\n    }\n}\n\nexport async function openHistoryInGitHub(file: TFile, manager: GitManager) {\n    const data = await getData(file, manager);\n\n    if (data.result === \"failure\") {\n        new Notice(data.reason);\n        return;\n    }\n\n    const { isGitHub, branch, repo, user, filePath } = data;\n\n    if (isGitHub) {\n        window.open(\n            `https://github.com/${user}/${repo}/commits/${branch}/${filePath}`\n        );\n    } else {\n        new Notice(\"It seems like you are not using GitHub\");\n    }\n}\n\nasync function getData(\n    file: TFile,\n    manager: GitManager\n): Promise<\n    | {\n          result: \"success\";\n          isGitHub: boolean;\n          user: string;\n          repo: string;\n          branch: string;\n          filePath: string;\n      }\n    | { result: \"failure\"; reason: string }\n> {\n    const branchInfo = await manager.branchInfo();\n    let remoteBranch = branchInfo.tracking;\n    let branch = branchInfo.current;\n    let remoteUrl: string | undefined = undefined;\n    let filePath = manager.getRelativeRepoPath(file.path);\n\n    if (manager instanceof SimpleGit) {\n        const submodule = await manager.getSubmoduleOfFile(\n            manager.getRelativeRepoPath(file.path)\n        );\n        if (submodule) {\n            filePath = submodule.relativeFilepath;\n            const status = await manager.git\n                .cwd({\n                    path: submodule.submodule,\n                    root: false,\n                })\n                .status();\n\n            remoteBranch = status.tracking || undefined;\n            branch = status.current || undefined;\n            if (remoteBranch) {\n                const remote = remoteBranch.substring(\n                    0,\n                    remoteBranch.indexOf(\"/\")\n                );\n\n                const config = await manager.git\n                    .cwd({\n                        path: submodule.submodule,\n                        root: false,\n                    })\n                    .getConfig(`remote.${remote}.url`, \"local\");\n\n                if (config.value != null) {\n                    remoteUrl = config.value;\n                } else {\n                    return {\n                        result: \"failure\",\n                        reason: \"Failed to get remote url of submodule\",\n                    };\n                }\n            }\n        }\n    }\n\n    if (remoteBranch == null) {\n        return {\n            result: \"failure\",\n            reason: \"Remote branch is not configured\",\n        };\n    }\n\n    if (branch == null) {\n        return {\n            result: \"failure\",\n            reason: \"Failed to get current branch name\",\n        };\n    }\n\n    if (remoteUrl == null) {\n        const remote = remoteBranch.substring(0, remoteBranch.indexOf(\"/\"));\n        remoteUrl = await manager.getConfig(`remote.${remote}.url`);\n        if (remoteUrl == null) {\n            return {\n                result: \"failure\",\n                reason: \"Failed to get remote url\",\n            };\n        }\n    }\n    const res = remoteUrl.match(\n        /(?:^https:\\/\\/github\\.com\\/(.+)\\/(.+?)(?:\\.git)?$)|(?:^[a-zA-Z]+@github\\.com:(.+)\\/(.+?)(?:\\.git)?$)/\n    );\n    if (res == null) {\n        return {\n            result: \"failure\",\n            reason: \"Could not parse remote url\",\n        };\n    } else {\n        const [isGitHub, httpsUser, httpsRepo, sshUser, sshRepo] = res;\n        return {\n            result: \"success\",\n            isGitHub: !!isGitHub,\n            repo: httpsRepo || sshRepo,\n            user: httpsUser || sshUser,\n            branch: branch,\n            filePath: filePath,\n        };\n    }\n}\n"
  },
  {
    "path": "src/pluginGlobalRef.ts",
    "content": "import type ObsidianGit from \"src/main\";\n\n/**\n * Store the reference to the {@link ObsidianGit} plugin globally, so that\n * the line author gutter context menu can access it for quick configuration.\n */\nexport const pluginRef: { plugin?: ObsidianGit } = {};\n"
  },
  {
    "path": "src/promiseQueue.ts",
    "content": "import type ObsidianGit from \"./main\";\n\nexport class PromiseQueue {\n    private tasks: {\n        task: () => Promise<unknown>;\n        onFinished: (res: unknown) => void;\n    }[] = [];\n\n    constructor(private readonly plugin: ObsidianGit) {}\n\n    /**\n     * Add a task to the queue.\n     *\n     * @param task The task to add.\n     * @param onFinished A callback that is called when the task is finished. Both on success and on error.\n     */\n    addTask<T>(\n        task: () => Promise<T>,\n        onFinished?: (res: T | undefined) => void\n    ): void {\n        this.tasks.push({ task, onFinished: onFinished ?? (() => {}) });\n        if (this.tasks.length === 1) {\n            this.handleTask();\n        }\n    }\n\n    private handleTask(): void {\n        if (this.tasks.length > 0) {\n            const item = this.tasks[0];\n            item.task().then(\n                (res) => {\n                    item.onFinished(res);\n                    this.tasks.shift();\n                    this.handleTask();\n                },\n                (e) => {\n                    this.plugin.displayError(e);\n                    item.onFinished(undefined);\n                    this.tasks.shift();\n                    this.handleTask();\n                }\n            );\n        }\n    }\n\n    clear(): void {\n        this.tasks = [];\n    }\n}\n"
  },
  {
    "path": "src/setting/localStorageSettings.ts",
    "content": "import type { App } from \"obsidian\";\nimport type ObsidianGit from \"../main\";\nexport class LocalStorageSettings {\n    private prefix: string;\n    private app: App;\n    constructor(private readonly plugin: ObsidianGit) {\n        this.prefix = this.plugin.manifest.id + \":\";\n        this.app = plugin.app;\n    }\n\n    migrate(): void {\n        const keys = [\n            \"password\",\n            \"hostname\",\n            \"conflict\",\n            \"lastAutoPull\",\n            \"lastAutoBackup\",\n            \"lastAutoPush\",\n            \"gitPath\",\n            \"pluginDisabled\",\n        ];\n        for (const key of keys) {\n            const old = localStorage.getItem(this.prefix + key);\n            if (\n                this.app.loadLocalStorage(this.prefix + key) == null &&\n                old != null\n            ) {\n                if (old != null) {\n                    this.app.saveLocalStorage(this.prefix + key, old);\n                    localStorage.removeItem(this.prefix + key);\n                }\n            }\n        }\n    }\n\n    getPassword(): string | null {\n        return this.app.loadLocalStorage(this.prefix + \"password\");\n    }\n\n    setPassword(value: string): void {\n        return this.app.saveLocalStorage(this.prefix + \"password\", value);\n    }\n\n    getUsername(): string | null {\n        return this.app.loadLocalStorage(this.prefix + \"username\");\n    }\n\n    setUsername(value: string): void {\n        return this.app.saveLocalStorage(this.prefix + \"username\", value);\n    }\n\n    getHostname(): string | null {\n        return this.app.loadLocalStorage(this.prefix + \"hostname\");\n    }\n\n    setHostname(value: string): void {\n        return this.app.saveLocalStorage(this.prefix + \"hostname\", value);\n    }\n\n    getConflict(): boolean {\n        return this.app.loadLocalStorage(this.prefix + \"conflict\") == \"true\";\n    }\n\n    setConflict(value: boolean): void {\n        return this.app.saveLocalStorage(this.prefix + \"conflict\", `${value}`);\n    }\n\n    getLastAutoPull(): string | null {\n        return this.app.loadLocalStorage(this.prefix + \"lastAutoPull\");\n    }\n\n    setLastAutoPull(value: string): void {\n        return this.app.saveLocalStorage(this.prefix + \"lastAutoPull\", value);\n    }\n\n    getLastAutoBackup(): string | null {\n        return this.app.loadLocalStorage(this.prefix + \"lastAutoBackup\");\n    }\n\n    setLastAutoBackup(value: string): void {\n        return this.app.saveLocalStorage(this.prefix + \"lastAutoBackup\", value);\n    }\n\n    getLastAutoPush(): string | null {\n        return this.app.loadLocalStorage(this.prefix + \"lastAutoPush\");\n    }\n\n    setLastAutoPush(value: string): void {\n        return this.app.saveLocalStorage(this.prefix + \"lastAutoPush\", value);\n    }\n\n    getGitPath(): string | null {\n        return this.app.loadLocalStorage(this.prefix + \"gitPath\");\n    }\n\n    setGitPath(value: string): void {\n        return this.app.saveLocalStorage(this.prefix + \"gitPath\", value);\n    }\n\n    getPATHPaths(): string[] {\n        return (\n            this.app.loadLocalStorage(this.prefix + \"PATHPaths\")?.split(\":\") ??\n            []\n        );\n    }\n\n    setPATHPaths(value: string[]): void {\n        return this.app.saveLocalStorage(\n            this.prefix + \"PATHPaths\",\n            value.join(\":\")\n        );\n    }\n\n    getEnvVars(): string[] {\n        return JSON.parse(\n            this.app.loadLocalStorage(this.prefix + \"envVars\") ?? \"[]\"\n        ) as string[];\n    }\n\n    setEnvVars(value: string[]): void {\n        return this.app.saveLocalStorage(\n            this.prefix + \"envVars\",\n            JSON.stringify(value)\n        );\n    }\n\n    getPluginDisabled(): boolean {\n        return (\n            this.app.loadLocalStorage(this.prefix + \"pluginDisabled\") == \"true\"\n        );\n    }\n\n    setPluginDisabled(value: boolean): void {\n        return this.app.saveLocalStorage(\n            this.prefix + \"pluginDisabled\",\n            `${value}`\n        );\n    }\n\n    /**\n     * Whether automatic routines are currently paused.\n     * New timers should not be started when this is true.\n     */\n    getPausedAutomatics(): boolean {\n        return (\n            this.app.loadLocalStorage(this.prefix + \"pausedAutomatics\") ==\n            \"true\"\n        );\n    }\n\n    setPausedAutomatics(value: boolean): void {\n        return this.app.saveLocalStorage(\n            this.prefix + \"pausedAutomatics\",\n            `${value}`\n        );\n    }\n}\n"
  },
  {
    "path": "src/setting/settings.ts",
    "content": "import type { App, RGB, TextComponent } from \"obsidian\";\nimport {\n    moment,\n    Notice,\n    Platform,\n    PluginSettingTab,\n    Setting,\n    TextAreaComponent,\n} from \"obsidian\";\nimport {\n    DATE_TIME_FORMAT_SECONDS,\n    DEFAULT_SETTINGS,\n    GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH,\n} from \"src/constants\";\nimport { IsomorphicGit } from \"src/gitManager/isomorphicGit\";\nimport { SimpleGit } from \"src/gitManager/simpleGit\";\nimport { previewColor } from \"src/editor/lineAuthor/lineAuthorProvider\";\nimport type {\n    LineAuthorDateTimeFormatOptions,\n    LineAuthorDisplay,\n    LineAuthorFollowMovement,\n    LineAuthorSettings,\n    LineAuthorTimezoneOption,\n} from \"src/editor/lineAuthor/model\";\nimport type ObsidianGit from \"src/main\";\nimport type {\n    ObsidianGitSettings,\n    MergeStrategy,\n    ShowAuthorInHistoryView,\n    SyncMethod,\n} from \"src/types\";\nimport { convertToRgb, formatMinutes, rgbToString } from \"src/utils\";\n\nconst FORMAT_STRING_REFERENCE_URL =\n    \"https://momentjs.com/docs/#/parsing/string-format/\";\nconst LINE_AUTHOR_FEATURE_WIKI_LINK =\n    \"https://publish.obsidian.md/git-doc/Line+Authoring\";\n\nexport class ObsidianGitSettingsTab extends PluginSettingTab {\n    lineAuthorColorSettings: Map<\"oldest\" | \"newest\", Setting> = new Map();\n    constructor(\n        app: App,\n        private plugin: ObsidianGit\n    ) {\n        super(app, plugin);\n    }\n\n    icon = \"git-pull-request\";\n\n    private get settings() {\n        return this.plugin.settings;\n    }\n\n    display(): void {\n        const { containerEl } = this;\n        const plugin: ObsidianGit = this.plugin;\n\n        let commitOrSync: string;\n        if (plugin.settings.differentIntervalCommitAndPush) {\n            commitOrSync = \"commit\";\n        } else {\n            commitOrSync = \"commit-and-sync\";\n        }\n\n        const gitReady = plugin.gitReady;\n\n        containerEl.empty();\n        if (!gitReady) {\n            containerEl.createEl(\"p\", {\n                text: \"Git is not ready. When all settings are correct you can configure commit-sync, etc.\",\n            });\n            containerEl.createEl(\"br\");\n        }\n\n        let setting: Setting;\n        if (gitReady) {\n            new Setting(containerEl).setName(\"Automatic\").setHeading();\n            new Setting(containerEl)\n                .setName(\"Split timers for automatic commit and sync\")\n                .setDesc(\n                    \"Enable to use one interval for commit and another for sync.\"\n                )\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(\n                            plugin.settings.differentIntervalCommitAndPush\n                        )\n                        .onChange(async (value) => {\n                            plugin.settings.differentIntervalCommitAndPush =\n                                value;\n                            await plugin.saveSettings();\n                            plugin.automaticsManager.reload(\"commit\", \"push\");\n                            this.refreshDisplayWithDelay();\n                        })\n                );\n\n            new Setting(containerEl)\n                .setName(`Auto ${commitOrSync} interval (minutes)`)\n                .setDesc(\n                    `${\n                        plugin.settings.differentIntervalCommitAndPush\n                            ? \"Commit\"\n                            : \"Commit and sync\"\n                    } changes every X minutes. Set to 0 (default) to disable. (See below setting for further configuration!)`\n                )\n                .addText((text) => {\n                    text.inputEl.type = \"number\";\n                    this.setNonDefaultValue({\n                        text,\n                        settingsProperty: \"autoSaveInterval\",\n                    });\n                    text.setPlaceholder(\n                        String(DEFAULT_SETTINGS.autoSaveInterval)\n                    );\n                    text.onChange(async (value) => {\n                        if (value !== \"\") {\n                            plugin.settings.autoSaveInterval = Number(value);\n                        } else {\n                            plugin.settings.autoSaveInterval =\n                                DEFAULT_SETTINGS.autoSaveInterval;\n                        }\n                        await plugin.saveSettings();\n                        plugin.automaticsManager.reload(\"commit\");\n                    });\n                });\n\n            setting = new Setting(containerEl)\n                .setName(`Auto ${commitOrSync} after stopping file edits`)\n                .setDesc(\n                    `Requires the ${commitOrSync} interval not to be 0.\n                        If turned on, do auto ${commitOrSync} every ${formatMinutes(\n                            plugin.settings.autoSaveInterval\n                        )} after stopping file edits.\n                        This also prevents auto ${commitOrSync} while editing a file. If turned off, it's independent from the last file edit.`\n                )\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.autoBackupAfterFileChange)\n                        .onChange(async (value) => {\n                            plugin.settings.autoBackupAfterFileChange = value;\n                            this.refreshDisplayWithDelay();\n\n                            await plugin.saveSettings();\n                            plugin.automaticsManager.reload(\"commit\");\n                        })\n                );\n            this.mayDisableSetting(\n                setting,\n                plugin.settings.setLastSaveToLastCommit\n            );\n\n            setting = new Setting(containerEl)\n                .setName(`Auto ${commitOrSync} after latest commit`)\n                .setDesc(\n                    `If turned on, sets last auto ${commitOrSync} timestamp to the latest commit timestamp. This reduces the frequency of auto ${commitOrSync} when doing manual commits.`\n                )\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.setLastSaveToLastCommit)\n                        .onChange(async (value) => {\n                            plugin.settings.setLastSaveToLastCommit = value;\n                            await plugin.saveSettings();\n                            plugin.automaticsManager.reload(\"commit\");\n                            this.refreshDisplayWithDelay();\n                        })\n                );\n            this.mayDisableSetting(\n                setting,\n                plugin.settings.autoBackupAfterFileChange\n            );\n\n            setting = new Setting(containerEl)\n                .setName(`Auto push interval (minutes)`)\n                .setDesc(\n                    \"Push commits every X minutes. Set to 0 (default) to disable.\"\n                )\n                .addText((text) => {\n                    text.inputEl.type = \"number\";\n                    this.setNonDefaultValue({\n                        text,\n                        settingsProperty: \"autoPushInterval\",\n                    });\n                    text.setPlaceholder(\n                        String(DEFAULT_SETTINGS.autoPushInterval)\n                    );\n                    text.onChange(async (value) => {\n                        if (value !== \"\") {\n                            plugin.settings.autoPushInterval = Number(value);\n                        } else {\n                            plugin.settings.autoPushInterval =\n                                DEFAULT_SETTINGS.autoPushInterval;\n                        }\n                        await plugin.saveSettings();\n                        plugin.automaticsManager.reload(\"push\");\n                    });\n                });\n            this.mayDisableSetting(\n                setting,\n                !plugin.settings.differentIntervalCommitAndPush\n            );\n\n            new Setting(containerEl)\n                .setName(\"Auto pull interval (minutes)\")\n                .setDesc(\n                    \"Pull changes every X minutes. Set to 0 (default) to disable.\"\n                )\n                .addText((text) => {\n                    text.inputEl.type = \"number\";\n                    this.setNonDefaultValue({\n                        text,\n                        settingsProperty: \"autoPullInterval\",\n                    });\n                    text.setPlaceholder(\n                        String(DEFAULT_SETTINGS.autoPullInterval)\n                    );\n                    text.onChange(async (value) => {\n                        if (value !== \"\") {\n                            plugin.settings.autoPullInterval = Number(value);\n                        } else {\n                            plugin.settings.autoPullInterval =\n                                DEFAULT_SETTINGS.autoPullInterval;\n                        }\n                        await plugin.saveSettings();\n                        plugin.automaticsManager.reload(\"pull\");\n                    });\n                });\n\n            new Setting(containerEl)\n                .setName(`Auto ${commitOrSync} only staged files`)\n                .setDesc(\n                    `If turned on, only staged files are committed on ${commitOrSync}. If turned off, all changed files are committed.`\n                )\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.autoCommitOnlyStaged)\n                        .onChange(async (value) => {\n                            plugin.settings.autoCommitOnlyStaged = value;\n                            await plugin.saveSettings();\n                        })\n                );\n\n            new Setting(containerEl)\n                .setName(\n                    `Specify custom commit message on auto ${commitOrSync}`\n                )\n                .setDesc(\"You will get a pop up to specify your message.\")\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.customMessageOnAutoBackup)\n                        .onChange(async (value) => {\n                            plugin.settings.customMessageOnAutoBackup = value;\n                            await plugin.saveSettings();\n                            this.refreshDisplayWithDelay();\n                        })\n                );\n\n            setting = new Setting(containerEl)\n                .setName(`Commit message on auto ${commitOrSync}`)\n                .setDesc(\n                    \"Available placeholders: {{date}}\" +\n                        \" (see below), {{hostname}} (see below), {{numFiles}} (number of changed files in the commit) and {{files}} (changed files in commit message).\"\n                )\n                .addTextArea((text) => {\n                    text.setPlaceholder(\n                        DEFAULT_SETTINGS.autoCommitMessage\n                    ).onChange(async (value) => {\n                        if (value === \"\") {\n                            plugin.settings.autoCommitMessage =\n                                DEFAULT_SETTINGS.autoCommitMessage;\n                        } else {\n                            plugin.settings.autoCommitMessage = value;\n                        }\n                        await plugin.saveSettings();\n                    });\n                    this.setNonDefaultValue({\n                        text,\n                        settingsProperty: \"autoCommitMessage\",\n                    });\n                });\n            this.mayDisableSetting(\n                setting,\n                plugin.settings.customMessageOnAutoBackup\n            );\n\n            new Setting(containerEl).setName(\"Commit message\").setHeading();\n\n            const manualCommitMessageSetting = new Setting(containerEl)\n                .setName(\"Commit message on manual commit\")\n                .setDesc(\n                    \"Available placeholders: {{date}}\" +\n                        \" (see below), {{hostname}} (see below), {{numFiles}} (number of changed files in the commit) and {{files}} (changed files in commit message). Leave empty to require manual input on each commit.\"\n                );\n            manualCommitMessageSetting.addTextArea((text) => {\n                manualCommitMessageSetting.addButton((button) => {\n                    button\n                        .setIcon(\"reset\")\n                        .setTooltip(\n                            `Set to default: \"${DEFAULT_SETTINGS.commitMessage}\"`\n                        )\n                        .onClick(() => {\n                            text.setValue(DEFAULT_SETTINGS.commitMessage);\n                            text.onChanged();\n                        });\n                });\n                text.setValue(plugin.settings.commitMessage);\n                text.onChange(async (value) => {\n                    plugin.settings.commitMessage = value;\n                    await plugin.saveSettings();\n                });\n            });\n\n            new Setting(containerEl)\n                .setName(\"Commit message script\")\n                .setDesc(\n                    \"A script that is run using 'sh -c' to generate the commit message. May be used to generate commit messages using AI tools. Available placeholders: {{hostname}}, {{date}}.\"\n                )\n                .addText((text) => {\n                    text.onChange(async (value) => {\n                        if (value === \"\") {\n                            plugin.settings.commitMessageScript =\n                                DEFAULT_SETTINGS.commitMessageScript;\n                        } else {\n                            plugin.settings.commitMessageScript = value;\n                        }\n                        await plugin.saveSettings();\n                    });\n                    this.setNonDefaultValue({\n                        text,\n                        settingsProperty: \"commitMessageScript\",\n                    });\n                });\n\n            const datePlaceholderSetting = new Setting(containerEl)\n                .setName(\"{{date}} placeholder format\")\n                .addMomentFormat((text) =>\n                    text\n                        .setDefaultFormat(plugin.settings.commitDateFormat)\n                        .setValue(plugin.settings.commitDateFormat)\n                        .onChange(async (value) => {\n                            plugin.settings.commitDateFormat = value;\n                            await plugin.saveSettings();\n                        })\n                );\n            datePlaceholderSetting.descEl.innerHTML = `\n            Specify custom date format. E.g. \"${DATE_TIME_FORMAT_SECONDS}. See <a href=\"https://momentjs.com\">Moment.js</a> for more formats.`;\n\n            new Setting(containerEl)\n                .setName(\"{{hostname}} placeholder replacement\")\n                .setDesc(\n                    \"Specify custom hostname for every device. Defaults to the OS hostname if not set on desktop.\"\n                )\n                .addText((text) =>\n                    text\n                        .setValue(plugin.localStorage.getHostname() ?? \"\")\n                        .onChange((value) => {\n                            plugin.localStorage.setHostname(value);\n                        })\n                );\n\n            new Setting(containerEl)\n                .setName(\"Preview commit message\")\n                .addButton((button) =>\n                    button.setButtonText(\"Preview\").onClick(async () => {\n                        const commitMessagePreview =\n                            await plugin.gitManager.formatCommitMessage(\n                                plugin.settings.commitMessage\n                            );\n                        new Notice(`${commitMessagePreview}`);\n                    })\n                );\n\n            new Setting(containerEl)\n                .setName(\"List filenames affected by commit in the commit body\")\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.listChangedFilesInMessageBody)\n                        .onChange(async (value) => {\n                            plugin.settings.listChangedFilesInMessageBody =\n                                value;\n                            await plugin.saveSettings();\n                        })\n                );\n\n            new Setting(containerEl).setName(\"Pull\").setHeading();\n\n            if (plugin.gitManager instanceof SimpleGit)\n                new Setting(containerEl)\n                    .setName(\"Merge strategy\")\n                    .setDesc(\n                        \"Decide how to integrate commits from your remote branch into your local branch.\"\n                    )\n                    .addDropdown((dropdown) => {\n                        const options: Record<SyncMethod, string> = {\n                            merge: \"Merge\",\n                            rebase: \"Rebase\",\n                            reset: \"Other sync service (Only updates the HEAD without touching the working directory)\",\n                        };\n                        dropdown.addOptions(options);\n                        dropdown.setValue(plugin.settings.syncMethod);\n\n                        dropdown.onChange(async (option: SyncMethod) => {\n                            plugin.settings.syncMethod = option;\n                            await plugin.saveSettings();\n                        });\n                    });\n\n            new Setting(containerEl)\n                .setName(\"Merge strategy on conflicts\")\n                .setDesc(\n                    \"Decide how to solve conflicts when pulling remote changes. This can be used to favor your local changes or the remote changes automatically.\"\n                )\n                .addDropdown((dropdown) => {\n                    const options: Record<MergeStrategy, string> = {\n                        none: \"None (git default)\",\n                        ours: \"Our changes\",\n                        theirs: \"Their changes\",\n                    };\n                    dropdown.addOptions(options);\n                    dropdown.setValue(plugin.settings.mergeStrategy);\n\n                    dropdown.onChange(async (option: MergeStrategy) => {\n                        plugin.settings.mergeStrategy = option;\n                        await plugin.saveSettings();\n                    });\n                });\n\n            new Setting(containerEl)\n                .setName(\"Pull on startup\")\n                .setDesc(\"Automatically pull commits when Obsidian starts.\")\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.autoPullOnBoot)\n                        .onChange(async (value) => {\n                            plugin.settings.autoPullOnBoot = value;\n                            await plugin.saveSettings();\n                        })\n                );\n\n            new Setting(containerEl)\n                .setName(\"Commit-and-sync\")\n                .setDesc(\n                    \"Commit-and-sync with default settings means staging everything -> committing -> pulling -> pushing. Ideally this is a single action that you do regularly to keep your local and remote repository in sync.\"\n                )\n                .setHeading();\n\n            setting = new Setting(containerEl)\n                .setName(\"Push on commit-and-sync\")\n                .setDesc(\n                    `Most of the time you want to push after committing. Turning this off turns a commit-and-sync action into commit ${plugin.settings.pullBeforePush ? \"and pull \" : \"\"}only. It will still be called commit-and-sync.`\n                )\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(!plugin.settings.disablePush)\n                        .onChange(async (value) => {\n                            plugin.settings.disablePush = !value;\n                            this.refreshDisplayWithDelay();\n                            await plugin.saveSettings();\n                        })\n                );\n\n            new Setting(containerEl)\n                .setName(\"Pull on commit-and-sync\")\n                .setDesc(\n                    `On commit-and-sync, pull commits as well. Turning this off turns a commit-and-sync action into commit ${plugin.settings.disablePush ? \"\" : \"and push \"}only.`\n                )\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.pullBeforePush)\n                        .onChange(async (value) => {\n                            plugin.settings.pullBeforePush = value;\n                            this.refreshDisplayWithDelay();\n                            await plugin.saveSettings();\n                        })\n                );\n\n            if (plugin.gitManager instanceof SimpleGit) {\n                new Setting(containerEl)\n                    .setName(\"Hunk management\")\n                    .setDesc(\n                        \"Hunks are sections of grouped line changes right in your editor.\"\n                    )\n                    .setHeading();\n\n                new Setting(containerEl)\n                    .setName(\"Signs\")\n                    .setDesc(\n                        \"This allows you to see your changes right in your editor via colored markers and stage/reset/preview individual hunks.\"\n                    )\n                    .addToggle((toggle) =>\n                        toggle\n                            .setValue(plugin.settings.hunks.showSigns)\n                            .onChange(async (value) => {\n                                plugin.settings.hunks.showSigns = value;\n                                await plugin.saveSettings();\n                                plugin.editorIntegration.refreshSignsSettings();\n                            })\n                    );\n\n                new Setting(containerEl)\n                    .setName(\"Hunk commands\")\n                    .setDesc(\n                        \"Adds commands to stage/reset individual Git diff hunks and navigate between them via 'Go to next/prev hunk' commands.\"\n                    )\n                    .addToggle((toggle) =>\n                        toggle\n                            .setValue(plugin.settings.hunks.hunkCommands)\n                            .onChange(async (value) => {\n                                plugin.settings.hunks.hunkCommands = value;\n                                await plugin.saveSettings();\n\n                                plugin.editorIntegration.refreshSignsSettings();\n                            })\n                    );\n\n                new Setting(containerEl)\n                    .setName(\"Status bar with summary of line changes\")\n                    .addDropdown((toggle) =>\n                        toggle\n                            .addOptions({\n                                disabled: \"Disabled\",\n                                colored: \"Colored\",\n                                monochrome: \"Monochrome\",\n                            })\n                            .setValue(plugin.settings.hunks.statusBar)\n                            .onChange(\n                                async (\n                                    option: ObsidianGitSettings[\"hunks\"][\"statusBar\"]\n                                ) => {\n                                    plugin.settings.hunks.statusBar = option;\n                                    await plugin.saveSettings();\n                                    plugin.editorIntegration.refreshSignsSettings();\n                                }\n                            )\n                    );\n\n                new Setting(containerEl)\n                    .setName(\"Line author information\")\n                    .setHeading();\n\n                this.addLineAuthorInfoSettings();\n            }\n        }\n\n        new Setting(containerEl).setName(\"History view\").setHeading();\n\n        new Setting(containerEl)\n            .setName(\"Show Author\")\n            .setDesc(\"Show the author of the commit in the history view.\")\n            .addDropdown((dropdown) => {\n                const options: Record<ShowAuthorInHistoryView, string> = {\n                    hide: \"Hide\",\n                    full: \"Full\",\n                    initials: \"Initials\",\n                };\n                dropdown.addOptions(options);\n                dropdown.setValue(plugin.settings.authorInHistoryView);\n                dropdown.onChange(async (option: ShowAuthorInHistoryView) => {\n                    plugin.settings.authorInHistoryView = option;\n                    await plugin.saveSettings();\n                    await plugin.refresh();\n                });\n            });\n\n        new Setting(containerEl)\n            .setName(\"Show Date\")\n            .setDesc(\n                \"Show the date of the commit in the history view. The {{date}} placeholder format is used to display the date.\"\n            )\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(plugin.settings.dateInHistoryView)\n                    .onChange(async (value) => {\n                        plugin.settings.dateInHistoryView = value;\n                        await plugin.saveSettings();\n                        await plugin.refresh();\n                    })\n            );\n\n        new Setting(containerEl).setName(\"Source control view\").setHeading();\n\n        new Setting(containerEl)\n            .setName(\n                \"Automatically refresh source control view on file changes\"\n            )\n            .setDesc(\n                \"On slower machines this may cause lags. If so, just disable this option.\"\n            )\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(plugin.settings.refreshSourceControl)\n                    .onChange(async (value) => {\n                        plugin.settings.refreshSourceControl = value;\n                        await plugin.saveSettings();\n                    })\n            );\n\n        new Setting(containerEl)\n            .setName(\"Source control view refresh interval\")\n            .setDesc(\n                \"Milliseconds to wait after file change before refreshing the Source Control View.\"\n            )\n            .addText((text) => {\n                const MIN_SOURCE_CONTROL_REFRESH_INTERVAL = 500;\n                text.inputEl.type = \"number\";\n                this.setNonDefaultValue({\n                    text,\n                    settingsProperty: \"refreshSourceControlTimer\",\n                });\n                text.setPlaceholder(\n                    String(DEFAULT_SETTINGS.refreshSourceControlTimer)\n                );\n                text.onChange(async (value) => {\n                    // Without this check, if the textbox is empty or the input is invalid, MIN_SOURCE_CONTROL_REFRESH_INTERVAL would be saved instead of saving the default value.\n                    if (value !== \"\" && Number.isInteger(Number(value))) {\n                        plugin.settings.refreshSourceControlTimer = Math.max(\n                            Number(value),\n                            MIN_SOURCE_CONTROL_REFRESH_INTERVAL\n                        );\n                    } else {\n                        plugin.settings.refreshSourceControlTimer =\n                            DEFAULT_SETTINGS.refreshSourceControlTimer;\n                    }\n                    await plugin.saveSettings();\n                    plugin.setRefreshDebouncer();\n                });\n            });\n        new Setting(containerEl).setName(\"Miscellaneous\").setHeading();\n\n        if (plugin.gitManager instanceof SimpleGit) {\n            new Setting(containerEl)\n                .setName(\"Diff view style\")\n                .setDesc(\n                    'Set the style for the diff view. Note that the actual diff in \"Split\" mode is not generated by Git, but the editor itself instead so it may differ from the diff generated by Git. One advantage of this is that you can edit the text in that view.'\n                )\n                .addDropdown((dropdown) => {\n                    const options: Record<\n                        ObsidianGitSettings[\"diffStyle\"],\n                        string\n                    > = {\n                        split: \"Split\",\n                        git_unified: \"Unified\",\n                    };\n                    dropdown.addOptions(options);\n                    dropdown.setValue(plugin.settings.diffStyle);\n                    dropdown.onChange(\n                        async (option: ObsidianGitSettings[\"diffStyle\"]) => {\n                            plugin.settings.diffStyle = option;\n                            await plugin.saveSettings();\n                        }\n                    );\n                });\n        }\n\n        new Setting(containerEl)\n            .setName(\"Disable informative notifications\")\n            .setDesc(\n                \"Disable informative notifications for git operations to minimize distraction (refer to status bar for updates).\"\n            )\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(plugin.settings.disablePopups)\n                    .onChange(async (value) => {\n                        plugin.settings.disablePopups = value;\n                        this.refreshDisplayWithDelay();\n                        await plugin.saveSettings();\n                    })\n            );\n\n        new Setting(containerEl)\n            .setName(\"Disable error notifications\")\n            .setDesc(\n                \"Disable error notifications of any kind to minimize distraction (refer to status bar for updates).\"\n            )\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(!plugin.settings.showErrorNotices)\n                    .onChange(async (value) => {\n                        plugin.settings.showErrorNotices = !value;\n                        await plugin.saveSettings();\n                    })\n            );\n\n        if (!plugin.settings.disablePopups)\n            new Setting(containerEl)\n                .setName(\"Hide notifications for no changes\")\n                .setDesc(\n                    \"Don't show notifications when there are no changes to commit or push.\"\n                )\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.disablePopupsForNoChanges)\n                        .onChange(async (value) => {\n                            plugin.settings.disablePopupsForNoChanges = value;\n                            await plugin.saveSettings();\n                        })\n                );\n\n        new Setting(containerEl)\n            .setName(\"Show status bar\")\n            .setDesc(\n                \"Obsidian must be restarted for the changes to take affect.\"\n            )\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(plugin.settings.showStatusBar)\n                    .onChange(async (value) => {\n                        plugin.settings.showStatusBar = value;\n                        await plugin.saveSettings();\n                    })\n            );\n\n        new Setting(containerEl)\n            .setName(\"File menu integration\")\n            .setDesc(\n                `Add \"Stage\", \"Unstage\" and \"Add to .gitignore\" actions to the file menu.`\n            )\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(plugin.settings.showFileMenu)\n                    .onChange(async (value) => {\n                        plugin.settings.showFileMenu = value;\n                        await plugin.saveSettings();\n                    })\n            );\n\n        new Setting(containerEl)\n            .setName(\"Show branch status bar\")\n            .setDesc(\n                \"Obsidian must be restarted for the changes to take affect.\"\n            )\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(plugin.settings.showBranchStatusBar)\n                    .onChange(async (value) => {\n                        plugin.settings.showBranchStatusBar = value;\n                        await plugin.saveSettings();\n                    })\n            );\n\n        new Setting(containerEl)\n            .setName(\"Show the count of modified files in the status bar\")\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(plugin.settings.changedFilesInStatusBar)\n                    .onChange(async (value) => {\n                        plugin.settings.changedFilesInStatusBar = value;\n                        await plugin.saveSettings();\n                    })\n            );\n\n        if (plugin.gitManager instanceof IsomorphicGit) {\n            new Setting(containerEl)\n                .setName(\"Authentication/commit author\")\n                .setHeading();\n        } else {\n            new Setting(containerEl).setName(\"Commit author\").setHeading();\n        }\n\n        if (plugin.gitManager instanceof IsomorphicGit)\n            new Setting(containerEl)\n                .setName(\n                    \"Username on your git server. E.g. your username on GitHub\"\n                )\n                .addText((cb) => {\n                    cb.setValue(plugin.localStorage.getUsername() ?? \"\");\n                    cb.onChange((value) => {\n                        plugin.localStorage.setUsername(value);\n                    });\n                });\n\n        if (plugin.gitManager instanceof IsomorphicGit)\n            new Setting(containerEl)\n                .setName(\"Password/Personal access token\")\n                .setDesc(\n                    \"Type in your password. You won't be able to see it again.\"\n                )\n                .addText((cb) => {\n                    cb.inputEl.autocapitalize = \"off\";\n                    cb.inputEl.autocomplete = \"off\";\n                    cb.inputEl.spellcheck = false;\n                    cb.onChange((value) => {\n                        plugin.localStorage.setPassword(value);\n                    });\n                });\n\n        if (plugin.gitReady)\n            new Setting(containerEl)\n                .setName(\"Author name for commit\")\n                .addText(async (cb) => {\n                    cb.setValue(\n                        (await plugin.gitManager.getConfig(\"user.name\")) ?? \"\"\n                    );\n                    cb.onChange(async (value) => {\n                        await plugin.gitManager.setConfig(\n                            \"user.name\",\n                            value == \"\" ? undefined : value\n                        );\n                    });\n                });\n\n        if (plugin.gitReady)\n            new Setting(containerEl)\n                .setName(\"Author email for commit\")\n                .addText(async (cb) => {\n                    cb.setValue(\n                        (await plugin.gitManager.getConfig(\"user.email\")) ?? \"\"\n                    );\n                    cb.onChange(async (value) => {\n                        await plugin.gitManager.setConfig(\n                            \"user.email\",\n                            value == \"\" ? undefined : value\n                        );\n                    });\n                });\n\n        new Setting(containerEl)\n            .setName(\"Advanced\")\n            .setDesc(\n                \"These settings usually don't need to be changed, but may be required for special setups.\"\n            )\n            .setHeading();\n\n        if (plugin.gitManager instanceof SimpleGit) {\n            new Setting(containerEl)\n                .setName(\"Update submodules\")\n                .setDesc(\n                    '\"Commit-and-sync\" and \"pull\" takes care of submodules. Missing features: Conflicted files, count of pulled/pushed/committed files. Tracking branch needs to be set for each submodule.'\n                )\n                .addToggle((toggle) =>\n                    toggle\n                        .setValue(plugin.settings.updateSubmodules)\n                        .onChange(async (value) => {\n                            plugin.settings.updateSubmodules = value;\n                            await plugin.saveSettings();\n                        })\n                );\n            if (plugin.settings.updateSubmodules) {\n                new Setting(containerEl)\n                    .setName(\"Submodule recurse checkout/switch\")\n                    .setDesc(\n                        \"Whenever a checkout happens on the root repository, recurse the checkout on the submodules (if the branches exist).\"\n                    )\n                    .addToggle((toggle) =>\n                        toggle\n                            .setValue(plugin.settings.submoduleRecurseCheckout)\n                            .onChange(async (value) => {\n                                plugin.settings.submoduleRecurseCheckout =\n                                    value;\n                                await plugin.saveSettings();\n                            })\n                    );\n            }\n        }\n\n        if (plugin.gitManager instanceof SimpleGit)\n            new Setting(containerEl)\n                .setName(\"Custom Git binary path\")\n                .setDesc(\n                    \"Specify the path to the Git binary/executable. Git should already be in your PATH. Should only be necessary for a custom Git installation.\"\n                )\n                .addText((cb) => {\n                    cb.setValue(plugin.localStorage.getGitPath() ?? \"\");\n                    cb.setPlaceholder(\"git\");\n                    cb.onChange((value) => {\n                        plugin.localStorage.setGitPath(value);\n                        plugin.gitManager\n                            .updateGitPath(value || \"git\")\n                            .catch((e) => plugin.displayError(e));\n                    });\n                });\n\n        if (plugin.gitManager instanceof SimpleGit)\n            new Setting(containerEl)\n                .setName(\"Additional environment variables\")\n                .setDesc(\n                    \"Use each line for a new environment variable in the format KEY=VALUE .\"\n                )\n                .addTextArea((cb) => {\n                    cb.setPlaceholder(\"GIT_DIR=/path/to/git/dir\");\n                    cb.setValue(plugin.localStorage.getEnvVars().join(\"\\n\"));\n                    cb.onChange((value) => {\n                        plugin.localStorage.setEnvVars(value.split(\"\\n\"));\n                    });\n                });\n\n        if (plugin.gitManager instanceof SimpleGit)\n            new Setting(containerEl)\n                .setName(\"Additional PATH environment variable paths\")\n                .setDesc(\"Use each line for one path\")\n                .addTextArea((cb) => {\n                    cb.setValue(plugin.localStorage.getPATHPaths().join(\"\\n\"));\n                    cb.onChange((value) => {\n                        plugin.localStorage.setPATHPaths(value.split(\"\\n\"));\n                    });\n                });\n        if (plugin.gitManager instanceof SimpleGit)\n            new Setting(containerEl)\n                .setName(\"Reload with new environment variables\")\n                .setDesc(\n                    \"Removing previously added environment variables will not take effect until Obsidian is restarted.\"\n                )\n                .addButton((cb) => {\n                    cb.setButtonText(\"Reload\");\n                    cb.setCta();\n                    cb.onClick(async () => {\n                        await (plugin.gitManager as SimpleGit).setGitInstance();\n                    });\n                });\n\n        new Setting(containerEl)\n            .setName(\"Custom base path (Git repository path)\")\n            .setDesc(\n                `\n            Sets the relative path to the vault from which the Git binary should be executed.\n             Mostly used to set the path to the Git repository, which is only required if the Git repository is below the vault root directory. Use \"\\\\\" instead of \"/\" on Windows.\n            `\n            )\n            .addText((cb) => {\n                cb.setValue(plugin.settings.basePath);\n                cb.setPlaceholder(\"directory/directory-with-git-repo\");\n                cb.onChange(async (value) => {\n                    plugin.settings.basePath = value;\n                    await plugin.saveSettings();\n                    plugin.gitManager\n                        .updateBasePath(value || \"\")\n                        .catch((e) => plugin.displayError(e));\n                });\n            });\n\n        new Setting(containerEl)\n            .setName(\"Custom Git directory path (Instead of '.git')\")\n            .setDesc(\n                `Corresponds to the GIT_DIR environment variable. Requires restart of Obsidian to take effect. Use \"\\\\\" instead of \"/\" on Windows.`\n            )\n            .addText((cb) => {\n                cb.setValue(plugin.settings.gitDir);\n                cb.setPlaceholder(\".git\");\n                cb.onChange(async (value) => {\n                    plugin.settings.gitDir = value;\n                    await plugin.saveSettings();\n                });\n            });\n\n        new Setting(containerEl)\n            .setName(\"Disable on this device\")\n            .setDesc(\n                \"Disables the plugin on this device. This setting is not synced.\"\n            )\n            .addToggle((toggle) =>\n                toggle\n                    .setValue(plugin.localStorage.getPluginDisabled())\n                    .onChange((value) => {\n                        plugin.localStorage.setPluginDisabled(value);\n                        if (value) {\n                            plugin.unloadPlugin();\n                        } else {\n                            plugin\n                                .init({ fromReload: true })\n                                .catch((e) => plugin.displayError(e));\n                        }\n                        new Notice(\n                            \"Obsidian must be restarted for the changes to take affect.\"\n                        );\n                    })\n            );\n\n        new Setting(containerEl).setName(\"Support\").setHeading();\n        new Setting(containerEl)\n            .setName(\"Donate\")\n            .setDesc(\n                \"If you like this Plugin, consider donating to support continued development.\"\n            )\n            .addButton((bt) => {\n                bt.buttonEl.outerHTML =\n                    \"<a href='https://ko-fi.com/F1F195IQ5' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi3.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>\";\n            });\n\n        const debugDiv = containerEl.createDiv();\n        debugDiv.setAttr(\"align\", \"center\");\n        debugDiv.setAttr(\"style\", \"margin: var(--size-4-2)\");\n\n        const debugButton = debugDiv.createEl(\"button\");\n        debugButton.setText(\"Copy Debug Information\");\n        debugButton.onclick = async () => {\n            await window.navigator.clipboard.writeText(\n                JSON.stringify(\n                    {\n                        settings: this.plugin.settings,\n                        pluginVersion: this.plugin.manifest.version,\n                    },\n                    null,\n                    4\n                )\n            );\n            new Notice(\n                \"Debug information copied to clipboard. May contain sensitive information!\"\n            );\n        };\n\n        if (Platform.isDesktopApp) {\n            const info = containerEl.createDiv();\n            info.setAttr(\"align\", \"center\");\n            info.setText(\n                \"Debugging and logging:\\nYou can always see the logs of this and every other plugin by opening the console with\"\n            );\n            const keys = containerEl.createDiv();\n            keys.setAttr(\"align\", \"center\");\n            keys.addClass(\"obsidian-git-shortcuts\");\n            if (Platform.isMacOS === true) {\n                keys.createEl(\"kbd\", { text: \"CMD (⌘) + OPTION (⌥) + I\" });\n            } else {\n                keys.createEl(\"kbd\", { text: \"CTRL + SHIFT + I\" });\n            }\n        }\n    }\n\n    mayDisableSetting(setting: Setting, disable: boolean) {\n        if (disable) {\n            setting.setDisabled(disable);\n            setting.setClass(\"obsidian-git-disabled\");\n        }\n    }\n\n    public configureLineAuthorShowStatus(show: boolean) {\n        this.settings.lineAuthor.show = show;\n        void this.plugin.saveSettings();\n\n        if (show) this.plugin.editorIntegration.activateLineAuthoring();\n        else this.plugin.editorIntegration.deactiveLineAuthoring();\n    }\n\n    /**\n     * Persists the setting {@link key} with value {@link value} and\n     * refreshes the line author info views.\n     */\n    public async lineAuthorSettingHandler<\n        K extends keyof ObsidianGitSettings[\"lineAuthor\"],\n    >(key: K, value: ObsidianGitSettings[\"lineAuthor\"][K]): Promise<void> {\n        this.settings.lineAuthor[key] = value;\n        await this.plugin.saveSettings();\n        this.plugin.editorIntegration.lineAuthoringFeature.refreshLineAuthorViews();\n    }\n\n    /**\n     * Ensure, that certain last shown values are persistent in the settings.\n     *\n     * Necessary for the line author info gutter context menus.\n     */\n    public beforeSaveSettings() {\n        const laSettings = this.settings.lineAuthor;\n        if (laSettings.authorDisplay !== \"hide\") {\n            laSettings.lastShownAuthorDisplay = laSettings.authorDisplay;\n        }\n        if (laSettings.dateTimeFormatOptions !== \"hide\") {\n            laSettings.lastShownDateTimeFormatOptions =\n                laSettings.dateTimeFormatOptions;\n        }\n    }\n\n    private addLineAuthorInfoSettings() {\n        const baseLineAuthorInfoSetting = new Setting(this.containerEl).setName(\n            \"Show commit authoring information next to each line\"\n        );\n\n        if (\n            !this.plugin.editorIntegration.lineAuthoringFeature.isAvailableOnCurrentPlatform()\n        ) {\n            baseLineAuthorInfoSetting\n                .setDesc(\"Only available on desktop currently.\")\n                .setDisabled(true);\n        }\n\n        baseLineAuthorInfoSetting.descEl.innerHTML = `\n            <a href=\"${LINE_AUTHOR_FEATURE_WIKI_LINK}\">Feature guide and quick examples</a></br>\n            The commit hash, author name and authoring date can all be individually toggled.</br>Hide everything, to only show the age-colored sidebar.`;\n\n        baseLineAuthorInfoSetting.addToggle((toggle) =>\n            toggle.setValue(this.settings.lineAuthor.show).onChange((value) => {\n                this.configureLineAuthorShowStatus(value);\n                this.refreshDisplayWithDelay();\n            })\n        );\n\n        if (this.settings.lineAuthor.show) {\n            const trackMovement = new Setting(this.containerEl)\n                .setName(\"Follow movement and copies across files and commits\")\n                .setDesc(\"\")\n                .addDropdown((dropdown) => {\n                    dropdown.addOptions(<\n                        Record<LineAuthorFollowMovement, string>\n                    >{\n                        inactive: \"Do not follow (default)\",\n                        \"same-commit\": \"Follow within same commit\",\n                        \"all-commits\": \"Follow within all commits (maybe slow)\",\n                    });\n                    dropdown.setValue(this.settings.lineAuthor.followMovement);\n                    dropdown.onChange((value: LineAuthorFollowMovement) =>\n                        this.lineAuthorSettingHandler(\"followMovement\", value)\n                    );\n                });\n            trackMovement.descEl.innerHTML = `\n                By default (deactivated), each line only shows the newest commit where it was changed.\n                <br/>\n                With <i>same commit</i>, cut-copy-paste-ing of text is followed within the same commit and the original commit of authoring will be shown.\n                <br/>\n                With <i>all commits</i>, cut-copy-paste-ing text inbetween multiple commits will be detected.\n                <br/>\n                It uses <a href=\"https://git-scm.com/docs/git-blame\">git-blame</a> and\n                for matches (at least ${GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH} characters) within the same (or all) commit(s), <em>the originating</em> commit's information is shown.`;\n\n            new Setting(this.containerEl)\n                .setName(\"Show commit hash\")\n                .addToggle((tgl) => {\n                    tgl.setValue(this.settings.lineAuthor.showCommitHash);\n                    tgl.onChange((value: boolean) =>\n                        this.lineAuthorSettingHandler(\"showCommitHash\", value)\n                    );\n                });\n\n            new Setting(this.containerEl)\n                .setName(\"Author name display\")\n                .setDesc(\"If and how the author is displayed\")\n                .addDropdown((dropdown) => {\n                    const options: Record<LineAuthorDisplay, string> = {\n                        hide: \"Hide\",\n                        initials: \"Initials (default)\",\n                        \"first name\": \"First name\",\n                        \"last name\": \"Last name\",\n                        full: \"Full name\",\n                    };\n                    dropdown.addOptions(options);\n                    dropdown.setValue(this.settings.lineAuthor.authorDisplay);\n\n                    dropdown.onChange(async (value: LineAuthorDisplay) =>\n                        this.lineAuthorSettingHandler(\"authorDisplay\", value)\n                    );\n                });\n\n            new Setting(this.containerEl)\n                .setName(\"Authoring date display\")\n                .setDesc(\n                    \"If and how the date and time of authoring the line is displayed\"\n                )\n                .addDropdown((dropdown) => {\n                    const options: Record<\n                        LineAuthorDateTimeFormatOptions,\n                        string\n                    > = {\n                        hide: \"Hide\",\n                        date: \"Date (default)\",\n                        datetime: \"Date and time\",\n                        \"natural language\": \"Natural language\",\n                        custom: \"Custom\",\n                    };\n                    dropdown.addOptions(options);\n                    dropdown.setValue(\n                        this.settings.lineAuthor.dateTimeFormatOptions\n                    );\n\n                    dropdown.onChange(\n                        async (value: LineAuthorDateTimeFormatOptions) => {\n                            await this.lineAuthorSettingHandler(\n                                \"dateTimeFormatOptions\",\n                                value\n                            );\n                            this.refreshDisplayWithDelay();\n                        }\n                    );\n                });\n\n            if (this.settings.lineAuthor.dateTimeFormatOptions === \"custom\") {\n                const dateTimeFormatCustomStringSetting = new Setting(\n                    this.containerEl\n                );\n\n                dateTimeFormatCustomStringSetting\n                    .setName(\"Custom authoring date format\")\n                    .addText((cb) => {\n                        cb.setValue(\n                            this.settings.lineAuthor.dateTimeFormatCustomString\n                        );\n                        cb.setPlaceholder(\"YYYY-MM-DD HH:mm\");\n\n                        cb.onChange(async (value) => {\n                            await this.lineAuthorSettingHandler(\n                                \"dateTimeFormatCustomString\",\n                                value\n                            );\n                            dateTimeFormatCustomStringSetting.descEl.innerHTML =\n                                this.previewCustomDateTimeDescriptionHtml(\n                                    value\n                                );\n                        });\n                    });\n\n                dateTimeFormatCustomStringSetting.descEl.innerHTML =\n                    this.previewCustomDateTimeDescriptionHtml(\n                        this.settings.lineAuthor.dateTimeFormatCustomString\n                    );\n            }\n\n            new Setting(this.containerEl)\n                .setName(\"Authoring date display timezone\")\n                .addDropdown((dropdown) => {\n                    const options: Record<LineAuthorTimezoneOption, string> = {\n                        \"viewer-local\": \"My local (default)\",\n                        \"author-local\": \"Author's local\",\n                        utc0000: \"UTC+0000/Z\",\n                    };\n                    dropdown.addOptions(options);\n                    dropdown.setValue(\n                        this.settings.lineAuthor.dateTimeTimezone\n                    );\n\n                    dropdown.onChange(async (value: LineAuthorTimezoneOption) =>\n                        this.lineAuthorSettingHandler(\"dateTimeTimezone\", value)\n                    );\n                }).descEl.innerHTML = `\n                    The time-zone in which the authoring date should be shown.\n                    Either your local time-zone (default),\n                    the author's time-zone during commit creation or\n                    <a href=\"https://en.wikipedia.org/wiki/UTC%C2%B100:00\">UTC±00:00</a>.\n            `;\n\n            const oldestAgeSetting = new Setting(this.containerEl).setName(\n                \"Oldest age in coloring\"\n            );\n\n            oldestAgeSetting.descEl.innerHTML =\n                this.previewOldestAgeDescriptionHtml(\n                    this.settings.lineAuthor.coloringMaxAge\n                )[0];\n\n            oldestAgeSetting.addText((text) => {\n                text.setPlaceholder(\"1y\");\n                text.setValue(this.settings.lineAuthor.coloringMaxAge);\n                text.onChange(async (value) => {\n                    const [preview, valid] =\n                        this.previewOldestAgeDescriptionHtml(value);\n                    oldestAgeSetting.descEl.innerHTML = preview;\n                    if (valid) {\n                        await this.lineAuthorSettingHandler(\n                            \"coloringMaxAge\",\n                            value\n                        );\n                        this.refreshColorSettingsName(\"oldest\");\n                    }\n                });\n            });\n\n            this.createColorSetting(\"newest\");\n            this.createColorSetting(\"oldest\");\n\n            new Setting(this.containerEl)\n                .setName(\"Text color\")\n                .addText((field) => {\n                    field.setValue(this.settings.lineAuthor.textColorCss);\n                    field.onChange(async (value) => {\n                        await this.lineAuthorSettingHandler(\n                            \"textColorCss\",\n                            value\n                        );\n                    });\n                }).descEl.innerHTML = `\n                    The CSS color of the gutter text.<br/>\n\n                    It is highly recommended to use\n                    <a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties\">\n                    CSS variables</a>\n                    defined by themes\n                    (e.g. <pre style=\"display:inline\">var(--text-muted)</pre> or\n                    <pre style=\"display:inline\">var(--text-on-accent)</pre>,\n                    because they automatically adapt to theme changes.<br/>\n\n                    See: <a href=\"https://github.com/obsidian-community/obsidian-theme-template/blob/main/obsidian.css\">\n                    List of available CSS variables in Obsidian\n                    <a/>\n                `;\n\n            new Setting(this.containerEl)\n                .setName(\"Ignore whitespace and newlines in changes\")\n                .addToggle((tgl) => {\n                    tgl.setValue(this.settings.lineAuthor.ignoreWhitespace);\n                    tgl.onChange((value) =>\n                        this.lineAuthorSettingHandler(\"ignoreWhitespace\", value)\n                    );\n                }).descEl.innerHTML = `\n                    Whitespace and newlines are interpreted as\n                    part of the document and in changes\n                    by default (hence not ignored).\n                    This makes the last line being shown as 'changed'\n                    when a new subsequent line is added,\n                    even if the previously last line's text is the same.\n                    <br>\n                    If you don't care about purely-whitespace changes\n                    (e.g. list nesting / quote indentation changes),\n                    then activating this will provide more meaningful change detection.\n                `;\n        }\n    }\n\n    private createColorSetting(which: \"oldest\" | \"newest\") {\n        const setting = new Setting(this.containerEl)\n            .setName(\"\")\n            .addText((text) => {\n                const color = pickColor(which, this.settings.lineAuthor);\n                const defaultColor = pickColor(\n                    which,\n                    DEFAULT_SETTINGS.lineAuthor\n                );\n                text.setPlaceholder(rgbToString(defaultColor));\n                text.setValue(rgbToString(color));\n                text.onChange(async (colorNew) => {\n                    const rgb = convertToRgb(colorNew);\n                    if (rgb !== undefined) {\n                        const key =\n                            which === \"newest\" ? \"colorNew\" : \"colorOld\";\n                        await this.lineAuthorSettingHandler(key, rgb);\n                    }\n                    this.refreshColorSettingsDesc(which, rgb);\n                });\n            });\n        this.lineAuthorColorSettings.set(which, setting);\n\n        this.refreshColorSettingsName(which);\n        this.refreshColorSettingsDesc(\n            which,\n            pickColor(which, this.settings.lineAuthor)\n        );\n    }\n\n    private refreshColorSettingsName(which: \"oldest\" | \"newest\") {\n        const settingsDom = this.lineAuthorColorSettings.get(which);\n        if (settingsDom) {\n            const whichDescriber =\n                which === \"oldest\"\n                    ? `oldest (${this.settings.lineAuthor.coloringMaxAge} or older)`\n                    : \"newest\";\n            settingsDom.nameEl.innerText = `Color for ${whichDescriber} commits`;\n        }\n    }\n\n    private refreshColorSettingsDesc(which: \"oldest\" | \"newest\", rgb?: RGB) {\n        const settingsDom = this.lineAuthorColorSettings.get(which);\n        if (settingsDom) {\n            settingsDom.descEl.innerHTML = this.colorSettingPreviewDescHtml(\n                which,\n                this.settings.lineAuthor,\n                rgb !== undefined\n            );\n        }\n    }\n\n    private colorSettingPreviewDescHtml(\n        which: \"oldest\" | \"newest\",\n        laSettings: LineAuthorSettings,\n        colorIsValid: boolean\n    ): string {\n        const rgbStr = colorIsValid\n            ? previewColor(which, laSettings)\n            : `rgba(127,127,127,0.3)`;\n        const today = moment.unix(moment.now() / 1000).format(\"YYYY-MM-DD\");\n        const text = colorIsValid\n            ? `abcdef Author Name ${today}`\n            : \"invalid color\";\n        const preview = `<div\n            class=\"line-author-settings-preview\"\n            style=\"background-color: ${rgbStr}; width: 30ch;\"\n            >${text}</div>`;\n\n        return `Supports 'rgb(r,g,b)', 'hsl(h,s,l)', hex (#) and\n            named colors (e.g. 'black', 'purple'). Color preview: ${preview}`;\n    }\n\n    private previewCustomDateTimeDescriptionHtml(\n        dateTimeFormatCustomString: string\n    ) {\n        const formattedDateTime = moment().format(dateTimeFormatCustomString);\n        return `<a href=\"${FORMAT_STRING_REFERENCE_URL}\">Format string</a> to display the authoring date.</br>Currently: ${formattedDateTime}`;\n    }\n\n    private previewOldestAgeDescriptionHtml(coloringMaxAge: string) {\n        const duration = parseColoringMaxAgeDuration(coloringMaxAge);\n        const durationString =\n            duration !== undefined ? `${duration.asDays()} days` : \"invalid!\";\n        return [\n            `The oldest age in the line author coloring. Everything older will have the same color.\n            </br>Smallest valid age is \"1d\". Currently: ${durationString}`,\n            duration,\n        ] as const;\n    }\n\n    /**\n     * Sets the value in the textbox for a given setting only if the saved value differs from the default value.\n     * If the saved value is the default value, it probably wasn't defined by the user, so it's better to display it as a placeholder.\n     */\n    private setNonDefaultValue({\n        settingsProperty,\n        text,\n    }: {\n        settingsProperty: keyof ObsidianGitSettings;\n        text: TextComponent | TextAreaComponent;\n    }): void {\n        const storedValue = this.plugin.settings[settingsProperty];\n        const defaultValue = DEFAULT_SETTINGS[settingsProperty];\n\n        if (defaultValue !== storedValue) {\n            // Doesn't add \"\" to saved strings\n            if (\n                typeof storedValue === \"string\" ||\n                typeof storedValue === \"number\" ||\n                typeof storedValue === \"boolean\"\n            ) {\n                text.setValue(String(storedValue));\n            } else {\n                text.setValue(JSON.stringify(storedValue));\n            }\n        }\n    }\n\n    /**\n     * Delays the update of the settings UI.\n     * Used when the user toggles one of the settings that control enabled states of other settings. Delaying the update\n     * allows most of the toggle animation to run, instead of abruptly jumping between enabled/disabled states.\n     */\n    private refreshDisplayWithDelay(timeout = 80): void {\n        setTimeout(() => this.display(), timeout);\n    }\n}\n\nexport function pickColor(\n    which: \"oldest\" | \"newest\",\n    las: LineAuthorSettings\n): RGB {\n    return which === \"oldest\" ? las.colorOld : las.colorNew;\n}\n\nexport function parseColoringMaxAgeDuration(\n    durationString: string\n): moment.Duration | undefined {\n    // https://momentjs.com/docs/#/durations/creating/\n    const duration = moment.duration(\"P\" + durationString.toUpperCase());\n    return duration.isValid() && duration.asDays() && duration.asDays() >= 1\n        ? duration\n        : undefined;\n}\n"
  },
  {
    "path": "src/statusBar.ts",
    "content": "import { setIcon, moment } from \"obsidian\";\nimport type ObsidianGit from \"./main\";\nimport { CurrentGitAction } from \"./types\";\n\ninterface StatusBarMessage {\n    message: string;\n    timeout: number;\n}\n\nexport class StatusBar {\n    private messages: StatusBarMessage[] = [];\n    private currentMessage: StatusBarMessage | null;\n    private lastCommitTimestamp?: Date;\n    private unPushedCommits?: number;\n    public lastMessageTimestamp: number | null;\n    private base = \"obsidian-git-statusbar-\";\n    private iconEl: HTMLElement;\n    private conflictEl: HTMLElement;\n    private pausedEl: HTMLElement;\n    private textEl: HTMLElement;\n\n    constructor(\n        private statusBarEl: HTMLElement,\n        private readonly plugin: ObsidianGit\n    ) {\n        this.statusBarEl.setAttribute(\"data-tooltip-position\", \"top\");\n\n        plugin.registerEvent(\n            plugin.app.workspace.on(\"obsidian-git:refreshed\", () => {\n                this.refreshCommitTimestamp().catch(console.error);\n            })\n        );\n    }\n\n    public displayMessage(message: string, timeout: number) {\n        this.messages.push({\n            message: `Git: ${message.slice(0, 100)}`,\n            timeout: timeout,\n        });\n        this.display();\n    }\n\n    public display() {\n        if (this.messages.length > 0 && !this.currentMessage) {\n            this.currentMessage = this.messages.shift() as StatusBarMessage;\n            this.statusBarEl.addClass(this.base + \"message\");\n            this.statusBarEl.ariaLabel = \"\";\n            this.statusBarEl.setText(this.currentMessage.message);\n            this.lastMessageTimestamp = Date.now();\n        } else if (this.currentMessage) {\n            const messageAge =\n                Date.now() - (this.lastMessageTimestamp as number);\n            if (messageAge >= this.currentMessage.timeout) {\n                this.currentMessage = null;\n                this.lastMessageTimestamp = null;\n            }\n        } else {\n            this.displayState();\n        }\n    }\n\n    private displayState() {\n        //Messages have to be removed before the state is set\n        if (\n            this.statusBarEl.getText().length > 3 ||\n            !this.statusBarEl.hasChildNodes()\n        ) {\n            this.statusBarEl.empty();\n\n            this.conflictEl = this.statusBarEl.createDiv();\n            this.conflictEl.setAttribute(\"data-tooltip-position\", \"top\");\n            this.conflictEl.style.float = \"left\";\n\n            this.pausedEl = this.statusBarEl.createDiv();\n            this.pausedEl.setAttribute(\"data-tooltip-position\", \"top\");\n            this.pausedEl.style.float = \"left\";\n\n            this.iconEl = this.statusBarEl.createDiv();\n            this.iconEl.style.float = \"left\";\n\n            this.textEl = this.statusBarEl.createDiv();\n            this.textEl.style.float = \"right\";\n            this.textEl.style.marginLeft = \"5px\";\n        }\n\n        if (this.plugin.localStorage.getConflict()) {\n            setIcon(this.conflictEl, \"alert-circle\");\n            this.conflictEl.ariaLabel =\n                \"You have merge conflicts. Resolve them and commit afterwards.\";\n            this.conflictEl.style.marginRight = \"5px\";\n            this.conflictEl.addClass(this.base + \"conflict\");\n        } else {\n            this.conflictEl.empty();\n            this.conflictEl.style.marginRight = \"\";\n        }\n\n        if (this.plugin.localStorage.getPausedAutomatics()) {\n            setIcon(this.pausedEl, \"pause-circle\");\n            this.pausedEl.ariaLabel =\n                \"Automatic routines are currently paused.\";\n            this.pausedEl.style.marginRight = \"5px\";\n            this.pausedEl.addClass(this.base + \"paused\");\n        } else {\n            this.pausedEl.empty();\n            this.pausedEl.style.marginRight = \"\";\n        }\n\n        switch (this.plugin.state.gitAction) {\n            case CurrentGitAction.idle:\n                this.displayFromNow();\n                break;\n            case CurrentGitAction.status:\n                this.statusBarEl.ariaLabel = \"Checking repository status...\";\n                setIcon(this.iconEl, \"refresh-cw\");\n                this.statusBarEl.addClass(this.base + \"status\");\n                break;\n            case CurrentGitAction.add:\n                this.statusBarEl.ariaLabel = \"Adding files...\";\n                setIcon(this.iconEl, \"archive\");\n                this.statusBarEl.addClass(this.base + \"add\");\n                break;\n            case CurrentGitAction.commit:\n                this.statusBarEl.ariaLabel = \"Committing changes...\";\n                setIcon(this.iconEl, \"git-commit\");\n                this.statusBarEl.addClass(this.base + \"commit\");\n                break;\n            case CurrentGitAction.push:\n                this.statusBarEl.ariaLabel = \"Pushing changes...\";\n                setIcon(this.iconEl, \"upload\");\n                this.statusBarEl.addClass(this.base + \"push\");\n                break;\n            case CurrentGitAction.pull:\n                this.statusBarEl.ariaLabel = \"Pulling changes...\";\n                setIcon(this.iconEl, \"download\");\n                this.statusBarEl.addClass(this.base + \"pull\");\n                break;\n            default:\n                this.statusBarEl.ariaLabel = \"Failed on initialization!\";\n                setIcon(this.iconEl, \"alert-triangle\");\n                this.statusBarEl.addClass(this.base + \"failed-init\");\n                break;\n        }\n    }\n\n    private displayFromNow(): void {\n        const timestamp = this.lastCommitTimestamp;\n        const offlineMode = this.plugin.state.offlineMode;\n        if (timestamp) {\n            const fromNow = moment(timestamp).fromNow();\n            this.statusBarEl.ariaLabel = `${\n                offlineMode ? \"Offline: \" : \"\"\n            }Last Commit: ${fromNow}`;\n\n            if (this.unPushedCommits ?? 0 > 0) {\n                this.statusBarEl.ariaLabel += `\\n(${this.unPushedCommits} unpushed commits)`;\n            }\n        } else {\n            this.statusBarEl.ariaLabel = offlineMode\n                ? \"Git is offline\"\n                : \"Git is ready\";\n        }\n\n        if (offlineMode) {\n            setIcon(this.iconEl, \"globe\");\n        } else {\n            setIcon(this.iconEl, \"check\");\n        }\n        if (\n            this.plugin.settings.changedFilesInStatusBar &&\n            this.plugin.cachedStatus\n        ) {\n            this.textEl.setText(\n                this.plugin.cachedStatus.changed.length.toString()\n            );\n        }\n        this.statusBarEl.addClass(this.base + \"idle\");\n    }\n\n    private async refreshCommitTimestamp() {\n        this.lastCommitTimestamp =\n            await this.plugin.gitManager.getLastCommitTime();\n        this.unPushedCommits =\n            await this.plugin.gitManager.getUnpushedCommits();\n    }\n\n    public remove() {\n        this.statusBarEl.remove();\n    }\n}\n"
  },
  {
    "path": "src/tools.ts",
    "content": "import { Notice, Platform, TFile } from \"obsidian\";\nimport {\n    CONFLICT_OUTPUT_FILE,\n    DIFF_VIEW_CONFIG,\n    SPLIT_DIFF_VIEW_CONFIG,\n} from \"./constants\";\nimport type ObsidianGit from \"./main\";\nimport { SimpleGit } from \"./gitManager/simpleGit\";\nimport { getNewLeaf, splitRemoteBranch } from \"./utils\";\nimport { GeneralModal } from \"./ui/modals/generalModal\";\nimport type { DiffViewState } from \"./types\";\n\nexport default class Tools {\n    constructor(private readonly plugin: ObsidianGit) {}\n\n    async hasTooBigFiles(\n        files: { vaultPath: string; path: string }[]\n    ): Promise<boolean> {\n        const branchInfo = await this.plugin.gitManager.branchInfo();\n        const remote = branchInfo.tracking\n            ? splitRemoteBranch(branchInfo.tracking)[0]\n            : null;\n\n        if (!remote) return false;\n\n        const remoteUrl = await this.plugin.gitManager.getRemoteUrl(remote);\n\n        //Check for files >100mb on GitHub remote\n        if (remoteUrl?.includes(\"github.com\")) {\n            const tooBigFiles = [];\n\n            const gitManager = this.plugin.gitManager;\n            for (const f of files) {\n                const file = this.plugin.app.vault.getAbstractFileByPath(\n                    f.vaultPath\n                );\n                let over100mb = false;\n\n                if (file instanceof TFile) {\n                    // Prefer the cached file size if available\n                    if (file.stat.size >= 100000000) {\n                        over100mb = true;\n                    }\n                } else {\n                    const statRes = await this.plugin.app.vault.adapter.stat(\n                        f.vaultPath\n                    );\n                    if (statRes && statRes.size >= 100000000) {\n                        over100mb = true;\n                    }\n                }\n                if (over100mb) {\n                    let isFileTrackedByLfs = false;\n                    if (gitManager instanceof SimpleGit) {\n                        isFileTrackedByLfs =\n                            await gitManager.isFileTrackedByLFS(f.path);\n                    }\n                    if (!isFileTrackedByLfs) {\n                        tooBigFiles.push(f);\n                    }\n                }\n            }\n\n            if (tooBigFiles.length > 0) {\n                this.plugin.displayError(\n                    `Aborted commit, because the following files are too big:\\n- ${tooBigFiles\n                        .map((e) => e.vaultPath)\n                        .join(\n                            \"\\n- \"\n                        )}\\nPlease remove them or add to .gitignore.`\n                );\n\n                return true;\n            }\n        }\n        return false;\n    }\n    async writeAndOpenFile(text?: string) {\n        if (text !== undefined) {\n            await this.plugin.app.vault.adapter.write(\n                CONFLICT_OUTPUT_FILE,\n                text\n            );\n        }\n        let fileIsAlreadyOpened = false;\n        this.plugin.app.workspace.iterateAllLeaves((leaf) => {\n            if (\n                leaf.getDisplayText() != \"\" &&\n                CONFLICT_OUTPUT_FILE.startsWith(leaf.getDisplayText())\n            ) {\n                fileIsAlreadyOpened = true;\n            }\n        });\n        if (!fileIsAlreadyOpened) {\n            await this.plugin.app.workspace.openLinkText(\n                CONFLICT_OUTPUT_FILE,\n                \"/\",\n                true\n            );\n        }\n    }\n\n    openDiff({\n        aFile,\n        bFile,\n        aRef,\n        bRef,\n        event,\n    }: {\n        aFile: string;\n        bFile?: string;\n        aRef: string;\n        bRef?: string;\n        event?: MouseEvent;\n    }) {\n        let diffStyle = this.plugin.settings.diffStyle;\n        if (Platform.isMobileApp) {\n            diffStyle = \"git_unified\";\n        }\n\n        const state: DiffViewState = {\n            aFile: aFile,\n            bFile: bFile ?? aFile,\n            aRef: aRef,\n            bRef: bRef,\n        };\n\n        if (diffStyle == \"split\") {\n            void getNewLeaf(this.plugin.app, event)?.setViewState({\n                type: SPLIT_DIFF_VIEW_CONFIG.type,\n                active: true,\n                state: state,\n            });\n        } else if (diffStyle == \"git_unified\") {\n            void getNewLeaf(this.plugin.app, event)?.setViewState({\n                type: DIFF_VIEW_CONFIG.type,\n                active: true,\n                state: state,\n            });\n        }\n    }\n\n    async runRawCommand() {\n        const gitManager = this.plugin.gitManager;\n        if (!(gitManager instanceof SimpleGit)) {\n            return;\n        }\n        const modal = new GeneralModal(this.plugin, {\n            placeholder: \"push origin master\",\n            allowEmpty: false,\n        });\n        const command = await modal.openAndGetResult();\n        if (command === undefined) return;\n\n        this.plugin.promiseQueue.addTask(async () => {\n            const notice = new Notice(`Running '${command}'...`, 999_999);\n\n            try {\n                const res = await gitManager.rawCommand(command);\n                if (res) {\n                    notice.setMessage(res);\n                    window.setTimeout(() => notice.hide(), 5000);\n                } else {\n                    notice.hide();\n                }\n            } catch (e) {\n                notice.hide();\n                throw e;\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "src/types.ts",
    "content": "import type { LineAuthorSettings } from \"src/editor/lineAuthor/model\";\n\nexport interface ObsidianGitSettings {\n    commitMessage: string;\n    autoCommitMessage: string;\n    commitMessageScript: string;\n    commitDateFormat: string;\n    /**\n     * Interval to either automatically commit-and-sync or just commit\n     */\n    autoSaveInterval: number;\n    autoPushInterval: number;\n    autoPullInterval: number;\n    autoPullOnBoot: boolean;\n    autoCommitOnlyStaged: boolean;\n    syncMethod: SyncMethod;\n    mergeStrategy: MergeStrategy;\n    /**\n     * Whether to push on commit-and-sync\n     */\n    disablePush: boolean;\n    /**\n     * Whether to pull on commit-and-sync\n     */\n    pullBeforePush: boolean;\n    /**\n     * Whether messages from {@link ObsidianGit.displayMessage} should be shown\n     */\n    disablePopups: boolean;\n    /**\n     * Whether messages from {@link ObsidianGit.displayError} should be shown\n     */\n    showErrorNotices: boolean;\n    disablePopupsForNoChanges: boolean;\n    listChangedFilesInMessageBody: boolean;\n    showStatusBar: boolean;\n    updateSubmodules: boolean;\n    submoduleRecurseCheckout: boolean;\n    /**\n     * @deprecated Using `localstorage` instead\n     */\n    gitPath?: string;\n    customMessageOnAutoBackup: boolean;\n    autoBackupAfterFileChange: boolean;\n    treeStructure: boolean;\n    /**\n     * @deprecated Using `localstorage` instead\n     */\n    username?: string;\n    differentIntervalCommitAndPush: boolean;\n    changedFilesInStatusBar: boolean;\n\n    /**\n     * @deprecated Migrated to `syncMethod = 'merge'`\n     */\n    mergeOnPull?: boolean;\n    refreshSourceControl: boolean;\n    basePath: string;\n    showedMobileNotice: boolean;\n    refreshSourceControlTimer: number;\n    showBranchStatusBar: boolean;\n    lineAuthor: LineAuthorSettings;\n    setLastSaveToLastCommit: boolean;\n    gitDir: string;\n    showFileMenu: boolean;\n    authorInHistoryView: ShowAuthorInHistoryView;\n    dateInHistoryView: boolean;\n    diffStyle: \"git_unified\" | \"split\";\n    hunks: {\n        hunkCommands: boolean;\n        showSigns: boolean;\n        statusBar: \"disabled\" | \"colored\" | \"monochrome\";\n    };\n}\n\n/**\n * Ensures, that nested values objects are correctly merged.\n */\nexport function mergeSettingsByPriority(\n    low: Omit<ObsidianGitSettings, \"autoCommitMessage\">,\n    high: ObsidianGitSettings\n): ObsidianGitSettings {\n    const lineAuthor = Object.assign({}, low.lineAuthor, high.lineAuthor);\n    return Object.assign({}, low, high, { lineAuthor });\n}\n\nexport type SyncMethod = \"rebase\" | \"merge\" | \"reset\";\n\nexport type MergeStrategy = \"none\" | \"ours\" | \"theirs\";\n\nexport type ShowAuthorInHistoryView = \"full\" | \"initials\" | \"hide\";\n\nexport interface Author {\n    name: string;\n    email: string;\n}\n\nexport interface Status {\n    all: FileStatusResult[];\n    changed: FileStatusResult[];\n    staged: FileStatusResult[];\n\n    /*\n     * Only available for `SimpleGit` gitManager\n     */\n    conflicted: string[];\n}\n\nexport interface GitTimestamp {\n    /**\n     * The number of unix seconds since epoch time (UTC).\n     */\n    epochSeconds: number;\n    /**\n     * The time zone, in which the commit was originally created.\n     * This can be used to reconstruct the local time during creating time.\n     */\n    tz: string;\n}\n\nexport interface UserEmail {\n    name: string;\n    email: string;\n}\n\nexport interface BlameCommit {\n    hash: string;\n    author?: UserEmail & GitTimestamp;\n    committer?: UserEmail & GitTimestamp;\n    previous?: { commitHash?: string; filename: string };\n    filename?: string;\n    summary: string;\n    isZeroCommit: boolean; // true, if hash is 000...000\n}\n\n/**\n * See https://git-scm.com/docs/git-blame#_the_porcelain_format\n */\nexport interface Blame {\n    commits: Map<string, BlameCommit>;\n    /**\n     * hashPerLine[i] is the commit hash where line i originates from\n     *\n     * The first element is always `undefined`, since line-numbers are 1-based.\n     */\n    hashPerLine: string[];\n    /**\n     * originalFileLineNrPerLine[i] contains the original files' line number from where line i\n     *\n     * The first element is always `undefined`, since line-numbers are 1-based.originated\n     */\n    originalFileLineNrPerLine: number[];\n    /**\n     * finalFileLineNrPerLine[i] contains the final files' line number from where line i originated\n     *\n     * The first element is always `undefined`, since line-numbers are 1-based.\n     */\n    finalFileLineNrPerLine: number[];\n    /**\n     * For each line i, which originates from a different commit than it's previous line,\n     * groupSizePerStartingLine[i] contains the number of lines until either the next\n     * group of lines or EOF is reached.\n     */\n    groupSizePerStartingLine: Map<number, number>;\n}\n\n/**\n * `index` and `working_dir` are each one-character codes, based off the git\n * status short format: git status --short\n * The following is from: https://www.git-scm.com/docs/git-status#_short_format\n *\n * The possible values are:\n * - ' ': unmodified\n * - M  : modified\n * - T  : file type changed\n * - A  : added\n * - D  : deleted\n * - R  : renamed\n * - C  : copied\n * - U  : updated but unmerged\n *\n *  index            working_dir            Meaning\n * ------------------------------------------------------------------------\n *                    [AMD]                 not updated\n *    M               [ MTD]                updated in index\n *    T               [ MTD]                type changed in index\n *    A               [ MTD]                added to index\n *    D                                     deleted from index\n *    R               [ MTD]                renamed in index\n *    C               [ MTD]                copied in index\n * [MTARC]                                  index and work tree match\n * [ MTARC]              M                  work tree changed since index\n * [ MTARC]              T                  type changed in work tree since index\n * [ MTARC]              D                  deleted in work tree\n *                       R                  renamed in work tree\n *                       C                  copied in work tree\n *    D                  D                  unmerged, both deleted\n *    A                  U                  unmerged, added by us\n *    U                  D                  unmerged, deleted by them\n *    U                  A                  unmerged, added by them\n *    D                  U                  unmerged, deleted by us\n *    A                  A                  unmerged, both added\n *    U                  U                  unmerged, both modified\n *    ?                  ?                  untracked\n *    !                  !                  ignored\n *\n *\n * FileStatusResult is based off simple-git's FileStatusResult:\n * https://github.com/steveukx/git-js/blob/a569868d800a0d872e8fb1534bb0dceccff47a4f/typings/response.d.ts#L267\n */\nexport interface FileStatusResult {\n    path: string;\n    vaultPath: string;\n    from?: string;\n\n    // First digit of the status code of the file, e.g. 'M' = modified.\n    // Represents the status of the index if no merge conflicts, otherwise represents\n    // status of one side of the merge.\n    index: string;\n    // Second digit of the status code of the file. Represents status of the working directory\n    // if no merge conflicts, otherwise represents status of other side of a merge.\n    workingDir: string;\n}\n\nexport interface PluginState {\n    offlineMode: boolean;\n    gitAction: CurrentGitAction;\n}\n\nexport enum CurrentGitAction {\n    idle,\n    status,\n    pull,\n    add,\n    commit,\n    push,\n}\n\nexport interface LogEntry {\n    hash: string;\n    date: string;\n    message: string;\n    refs: string[];\n    body: string;\n    diff: DiffEntry;\n    author: {\n        name: string;\n        email: string;\n    };\n}\n\nexport interface DiffEntry {\n    changed: number;\n    files: DiffFile[];\n}\n\nexport interface DiffFile {\n    path: string;\n    vaultPath: string;\n    fromPath?: string;\n    fromVaultPath?: string;\n    hash: string;\n    status: string;\n    binary?: boolean;\n}\n\nexport interface WalkDifference {\n    path: string;\n    type: \"M\" | \"A\" | \"D\";\n}\n\nexport type UnstagedFile = WalkDifference;\n\nexport interface BranchInfo {\n    current?: string;\n    tracking?: string;\n    branches: string[];\n}\n\nexport interface TreeItem<T = DiffFile | FileStatusResult> {\n    title: string;\n    path: string;\n    vaultPath: string;\n    data?: T;\n    children?: TreeItem<T>[];\n}\n\nexport type RootTreeItem<T> = TreeItem<T> & { children: TreeItem<T>[] };\n\nexport type StatusRootTreeItem = RootTreeItem<FileStatusResult>;\n\nexport type HistoryRootTreeItem = RootTreeItem<DiffFile>;\n\nexport type DiffViewState = {\n    /**\n     * The repo relative file path for a.\n     * For diffing a renamed file, this is the old path.\n     */\n    aFile: string;\n\n    /**\n     * The git ref to specify which state of that file should be shown.\n     * An empty string refers to the index version of a file, so you have to specifically check against undefined.\n     */\n    aRef: string;\n\n    /**\n     * The repo relative file path for b.\n     */\n    bFile: string;\n\n    /**\n     * The git ref to specify which state of that file should be shown.\n     * An empty string refers to the index version of a file, so you have to specifically check against undefined.\n     * `undefined` stands for the working tree version.\n     */\n    bRef?: string;\n};\n\nexport enum FileType {\n    staged,\n    changed,\n    pulled,\n}\n\nexport class NoNetworkError extends Error {\n    constructor(public readonly originalError: string) {\n        super(\"No network connection available\");\n    }\n}\n\ndeclare module \"obsidian\" {\n    interface App {\n        loadLocalStorage(key: string): string | null;\n        saveLocalStorage(key: string, value: string | undefined): void;\n        openWithDefaultApp(path: string): void;\n        getTheme(): \"obsidian\" | \"moonstone\";\n        viewRegistry: ViewRegistry;\n    }\n    interface View {\n        titleEl: HTMLElement;\n        inlineTitleEl: HTMLElement;\n    }\n    interface ViewRegistry {\n        /**\n         * PRIVATE API\n         *\n         * Returns the view type for the given extension if available.\n         */\n        getTypeByExtension(extension: string): string;\n    }\n    interface Workspace {\n        /**\n         * Emitted when some git action has been completed and plugin has been refreshed\n         */\n        on(\n            name: \"obsidian-git:refreshed\",\n            callback: () => void,\n            ctx?: unknown\n        ): EventRef;\n        /**\n         * Emitted when some git action has been completed and the plugin should refresh\n         */\n        on(\n            name: \"obsidian-git:refresh\",\n            callback: () => void,\n            ctx?: unknown\n        ): EventRef;\n        /**\n         * Emitted when the plugin is currently loading a new cached status.\n         */\n        on(\n            name: \"obsidian-git:loading-status\",\n            callback: () => void,\n            ctx?: unknown\n        ): EventRef;\n        /**\n         * Emitted when the HEAD changed.\n         */\n        on(\n            name: \"obsidian-git:head-change\",\n            callback: () => void,\n            ctx?: unknown\n        ): EventRef;\n        /**\n         * Emitted when a new cached status is available.\n         */\n        on(\n            name: \"obsidian-git:status-changed\",\n            callback: (status: Status) => void,\n            ctx?: unknown\n        ): EventRef;\n\n        on(\n            name: \"obsidian-git:menu\",\n            callback: (\n                menu: Menu,\n                path: string,\n                source: string,\n                leaf?: WorkspaceLeaf\n            ) => unknown,\n            ctx?: unknown\n        ): EventRef;\n        trigger(name: string, ...data: unknown[]): void;\n        trigger(name: \"obsidian-git:refreshed\"): void;\n        trigger(name: \"obsidian-git:refresh\"): void;\n        trigger(name: \"obsidian-git:loading-status\"): void;\n        trigger(name: \"obsidian-git:head-change\"): void;\n        trigger(name: \"obsidian-git:status-changed\", status: Status): void;\n        trigger(\n            name: \"obsidian-git:menu\",\n            menu: Menu,\n            path: string,\n            source: string,\n            leaf?: WorkspaceLeaf\n        ): void;\n    }\n}\n"
  },
  {
    "path": "src/ui/diff/diffView.ts",
    "content": "import { html } from \"diff2html\";\nimport type { EventRef, ViewStateResult, WorkspaceLeaf } from \"obsidian\";\nimport { ItemView, Platform } from \"obsidian\";\nimport { DIFF_VIEW_CONFIG } from \"src/constants\";\nimport { SimpleGit } from \"src/gitManager/simpleGit\";\nimport type ObsidianGit from \"src/main\";\nimport type { DiffViewState } from \"src/types\";\n\nexport default class DiffView extends ItemView {\n    parser: DOMParser;\n    gettingDiff = false;\n    state: DiffViewState;\n    gitRefreshRef: EventRef;\n    gitViewRefreshRef: EventRef;\n\n    constructor(\n        leaf: WorkspaceLeaf,\n        private plugin: ObsidianGit\n    ) {\n        super(leaf);\n        this.parser = new DOMParser();\n        this.navigation = true;\n        this.contentEl.addClass(\"git-diff\");\n        this.gitRefreshRef = this.app.workspace.on(\n            \"obsidian-git:status-changed\",\n            () => {\n                this.refresh().catch(console.error);\n            }\n        );\n    }\n\n    getViewType(): string {\n        return DIFF_VIEW_CONFIG.type;\n    }\n\n    getDisplayText(): string {\n        if (this.state?.bFile != null) {\n            let fileName = this.state.bFile.split(\"/\").last();\n            if (fileName?.endsWith(\".md\")) fileName = fileName.slice(0, -3);\n\n            return `Diff: ${fileName}`;\n        }\n        return DIFF_VIEW_CONFIG.name;\n    }\n\n    getIcon(): string {\n        return DIFF_VIEW_CONFIG.icon;\n    }\n\n    async setState(state: DiffViewState, _: ViewStateResult): Promise<void> {\n        this.state = state;\n\n        if (Platform.isMobile) {\n            //Update view title on mobile only to show the file name of the diff\n            this.leaf.view.titleEl.textContent = this.getDisplayText();\n        }\n\n        await this.refresh();\n    }\n\n    getState(): Record<string, unknown> {\n        return this.state as unknown as Record<string, unknown>;\n    }\n\n    onClose(): Promise<void> {\n        this.app.workspace.offref(this.gitRefreshRef);\n        this.app.workspace.offref(this.gitViewRefreshRef);\n        return super.onClose();\n    }\n\n    async onOpen(): Promise<void> {\n        await this.refresh();\n        return super.onOpen();\n    }\n\n    async refresh(): Promise<void> {\n        if (this.state?.bFile && !this.gettingDiff && this.plugin.gitManager) {\n            this.gettingDiff = true;\n            try {\n                let diff = await this.plugin.gitManager.getDiffString(\n                    this.state.bFile,\n                    this.state.aRef == \"HEAD\",\n                    this.state.bRef\n                );\n                this.contentEl.empty();\n\n                const vaultPath = this.plugin.gitManager.getRelativeVaultPath(\n                    this.state.bFile\n                );\n                if (!diff) {\n                    if (\n                        this.plugin.gitManager instanceof SimpleGit &&\n                        (await this.plugin.gitManager.isTracked(\n                            this.state.bFile\n                        ))\n                    ) {\n                        // File is tracked but no changes\n                        diff = [\n                            `--- ${this.state.aFile}`,\n                            `+++ ${this.state.bFile}`,\n                            \"\",\n                        ].join(\"\\n\");\n                    } else if (await this.app.vault.adapter.exists(vaultPath)) {\n                        const content =\n                            await this.app.vault.adapter.read(vaultPath);\n                        const header = `--- /dev/null\n+++ ${this.state.bFile}\n@@ -0,0 +1,${content.split(\"\\n\").length} @@`;\n\n                        diff = [\n                            ...header.split(\"\\n\"),\n                            ...content.split(\"\\n\").map((line) => `+${line}`),\n                        ].join(\"\\n\");\n                    }\n                }\n\n                if (diff) {\n                    const diffEl = this.parser\n                        .parseFromString(html(diff), \"text/html\")\n                        .querySelector(\".d2h-file-diff\");\n                    this.contentEl.append(diffEl!);\n                } else {\n                    const div = this.contentEl.createDiv({\n                        cls: \"obsidian-git-center\",\n                    });\n                    div.createSpan({\n                        text: \"⚠️\",\n                        attr: { style: \"font-size: 2em\" },\n                    });\n                    div.createEl(\"br\");\n                    div.createSpan({\n                        text: \"File not found: \" + this.state.bFile,\n                    });\n                }\n            } finally {\n                this.gettingDiff = false;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/ui/diff/splitDiffView.ts",
    "content": "import type { Debouncer, ViewStateResult, WorkspaceLeaf } from \"obsidian\";\nimport { debounce, ItemView, Platform, setIcon } from \"obsidian\";\nimport { SPLIT_DIFF_VIEW_CONFIG } from \"src/constants\";\nimport { SimpleGit } from \"src/gitManager/simpleGit\";\nimport type ObsidianGit from \"src/main\";\nimport type { DiffViewState } from \"src/types\";\n\nimport { history, indentWithTab, standardKeymap } from \"@codemirror/commands\";\nimport { getChunks, MergeView } from \"@codemirror/merge\";\nimport { highlightSelectionMatches, search } from \"@codemirror/search\";\nimport { EditorState, Transaction } from \"@codemirror/state\";\nimport {\n    drawSelection,\n    EditorView,\n    keymap,\n    lineNumbers,\n    ViewPlugin,\n} from \"@codemirror/view\";\nimport { GitError } from \"simple-git\";\nimport { Hunks } from \"src/editor/signs/hunks\";\nimport { rawHunkFromChunk, rawHunksToHunks } from \"src/editor/signs/diff\";\n\n// This class is not extending `FileView', because it needs a `TFile`, which is not possible for dot files like `.gitignore`, which this editor should support as well.`\nexport default class SplitDiffView extends ItemView {\n    refreshing = false;\n    state: DiffViewState;\n    intervalRef: number;\n    mergeView: MergeView | undefined;\n    fileSaveDebouncer: Debouncer<[string], void>;\n    bIsEditable: boolean;\n\n    /**\n     * Prevent to load text from file if the modification event was caused by this instance\n     */\n    ignoreNextModification = false;\n\n    constructor(\n        leaf: WorkspaceLeaf,\n        private plugin: ObsidianGit\n    ) {\n        super(leaf);\n        this.navigation = true;\n        this.registerEvent(\n            this.app.workspace.on(\"obsidian-git:status-changed\", () => {\n                if (!this.mergeView) {\n                    this.createMergeView().catch(console.error);\n                } else {\n                    this.updateRefEditors().catch(console.error);\n                }\n            })\n        );\n        this.intervalRef = window.setInterval(() => {\n            if (this.mergeView) {\n                this.updateRefEditors().catch(console.error);\n            }\n        }, 30 * 1000);\n\n        this.registerEvent(\n            this.app.vault.on(\"modify\", (file) => {\n                if (\n                    this.state.bRef == undefined &&\n                    file.path === this.state.bFile\n                ) {\n                    if (this.ignoreNextModification) {\n                        this.ignoreNextModification = false;\n                    } else {\n                        this.updateModifiableEditor().catch(console.error);\n                    }\n                }\n            })\n        );\n        this.registerEvent(\n            this.app.vault.on(\"delete\", (file) => {\n                if (\n                    this.state.bRef == undefined &&\n                    file.path === this.state.bFile\n                ) {\n                    // If the file got deleted, we need to recreate the view to make the editor read-only\n                    this.createMergeView().catch(console.error);\n                }\n            })\n        );\n        this.registerEvent(\n            this.app.vault.on(\"create\", (file) => {\n                if (\n                    this.state.bRef == undefined &&\n                    file.path === this.state.bFile\n                ) {\n                    // If the file got created, we need to recreate the view to make the editor editable\n                    this.createMergeView().catch(console.error);\n                }\n            })\n        );\n        this.registerEvent(\n            this.app.vault.on(\"rename\", (file, oldPath) => {\n                if (\n                    this.state.bRef == undefined &&\n                    (file.path === this.state.bFile ||\n                        oldPath === this.state.bFile)\n                ) {\n                    // If the file got created, we need to recreate the view to make the editor editable\n                    this.createMergeView().catch(console.error);\n                }\n            })\n        );\n\n        this.fileSaveDebouncer = debounce(\n            (data: string) => {\n                const file = this.state.bFile;\n                if (file) {\n                    this.ignoreNextModification = true;\n                    this.plugin.app.vault.adapter\n                        .write(\n                            this.plugin.gitManager.getRelativeVaultPath(file),\n                            data\n                        )\n                        .catch((e) => this.plugin.displayError(e));\n                }\n            },\n            1000,\n            false\n        );\n    }\n\n    getViewType(): string {\n        return SPLIT_DIFF_VIEW_CONFIG.type;\n    }\n\n    getDisplayText(): string {\n        if (this.state?.bFile != null) {\n            let fileName = this.state.bFile.split(\"/\").last();\n            if (fileName?.endsWith(\".md\")) fileName = fileName.slice(0, -3);\n            let suffix: string;\n            if (this.state.bRef == undefined) {\n                suffix = \" (Working Tree)\";\n            } else if (this.state.bRef == \"\") {\n                suffix = \" (Index)\";\n            } else {\n                suffix = \"(\" + this.state.bRef.substring(0, 7) + \")\";\n            }\n\n            return `Diff: ${fileName} ${suffix}`;\n        }\n        return SPLIT_DIFF_VIEW_CONFIG.name;\n    }\n\n    getIcon(): string {\n        return SPLIT_DIFF_VIEW_CONFIG.icon;\n    }\n\n    async setState(state: DiffViewState, _: ViewStateResult): Promise<void> {\n        this.state = state;\n\n        if (Platform.isMobile) {\n            //Update view title on mobile only to show the file name of the diff\n            this.leaf.view.titleEl.textContent = this.getDisplayText();\n        }\n        await super.setState(state, _);\n\n        await this.createMergeView();\n    }\n\n    getState(): Record<string, unknown> {\n        return this.state as unknown as Record<string, unknown>;\n    }\n\n    onClose(): Promise<void> {\n        window.clearInterval(this.intervalRef);\n        return super.onClose();\n    }\n\n    async onOpen(): Promise<void> {\n        await this.createMergeView();\n        return super.onOpen();\n    }\n\n    async gitShow(commitHash: string, file: string): Promise<string> {\n        try {\n            return await (this.plugin.gitManager as SimpleGit).show(\n                commitHash,\n                file,\n                false\n            );\n        } catch (error) {\n            if (error instanceof GitError) {\n                if (\n                    error.message.includes(\"does not exist\") ||\n                    error.message.includes(\"unknown revision or path\") ||\n                    error.message.includes(\"exists on disk, but not in\") ||\n                    error.message.includes(\"fatal: bad object\")\n                ) {\n                    // Occurs when trying to run diff with an object that's actually a nested respository\n                    if (error.message.includes(\"fatal: bad object\")) {\n                        this.plugin.displayError(error.message);\n                    }\n                    // If the file does not exist in the commit, return an empty string\n                    return \"\";\n                }\n            }\n            throw error;\n        }\n    }\n\n    async bShouldBeEditable(): Promise<boolean> {\n        if (this.state.bRef != undefined) {\n            return false;\n        }\n        const bVaultPath = this.plugin.gitManager.getRelativeVaultPath(\n            this.state.bFile\n        );\n        return await this.app.vault.adapter.exists(bVaultPath);\n    }\n\n    async updateModifiableEditor() {\n        if (!this.mergeView || this.refreshing) return;\n        const bEditor = this.mergeView.b;\n\n        this.refreshing = true;\n        const newContent = await this.app.vault.adapter.read(this.state.bFile);\n        if (newContent != bEditor.state.doc.toString()) {\n            const transaction = bEditor.state.update({\n                changes: {\n                    from: 0,\n                    to: bEditor.state.doc.length,\n                    insert: newContent,\n                },\n                // The remote annotation is used to mark that change as external\n                // so the new state is not written back to the file, because it\n                // just came from the file system\n                annotations: [Transaction.remote.of(true)],\n            });\n            bEditor.dispatch(transaction);\n        }\n        this.refreshing = false;\n    }\n\n    /**\n     * Only update the editors which show a file state of some git ref ike HEAD or index and not the current working tree.\n     * So only the non editable editors.\n     */\n    async updateRefEditors() {\n        if (!this.mergeView || this.refreshing) return;\n        const aEditor = this.mergeView.a;\n        const bEditor = this.mergeView.b;\n\n        this.refreshing = true;\n\n        const aText = await this.gitShow(this.state.aRef, this.state.aFile);\n\n        let bText: string | undefined;\n        if (this.state.bRef != undefined) {\n            bText = await this.gitShow(this.state.bRef, this.state.bFile);\n        }\n        if (aText != aEditor.state.doc.toString()) {\n            const aTransaction = aEditor.state.update({\n                changes: {\n                    from: 0,\n                    to: aEditor.state.doc.length,\n                    insert: aText,\n                },\n            });\n            aEditor.dispatch(aTransaction);\n        }\n\n        if (bText != undefined && bText != bEditor.state.doc.toString()) {\n            const bTransaction = bEditor.state.update({\n                changes: {\n                    from: 0,\n                    to: bEditor.state.doc.length,\n                    insert: bText,\n                },\n            });\n            bEditor.dispatch(bTransaction);\n        }\n        this.refreshing = false;\n    }\n\n    renderButtons(): HTMLElement {\n        const contentEl = document.createElement(\"div\");\n\n        const stageButton = contentEl.createDiv();\n        stageButton.addClass(\"clickable-icon\");\n        stageButton.setAttr(\n            \"aria-label\",\n            this.state.bRef == undefined ? \"Stage hunk\" : \"Unstage hunk\"\n        );\n        setIcon(stageButton, this.state.bRef == undefined ? \"plus\" : \"minus\");\n\n        stageButton.onmousedown = async (_) => {\n            const bEditor = this.mergeView!.b;\n            const aEditor = this.mergeView!.a;\n            const chunks = getChunks(bEditor.state)!;\n            const index = contentEl.parentElement?.indexOf(contentEl);\n\n            const chunk = chunks.chunks[index!];\n\n            const rawHunk = rawHunkFromChunk(\n                chunk,\n                aEditor.state.doc,\n                bEditor.state.doc\n            );\n            const hunk = rawHunksToHunks(\n                this.mergeView!.a.state.doc.toString(),\n                this.mergeView!.b.state.doc.toString(),\n                [rawHunk]\n            )[0];\n\n            const patch =\n                Hunks.createPatch(\n                    this.state.bFile,\n                    [hunk],\n                    \"100644\",\n                    this.state.bRef != undefined\n                ).join(\"\\n\") + \"\\n\";\n            await (this.plugin.gitManager as SimpleGit).applyPatch(patch);\n\n            this.plugin.app.workspace.trigger(\"obsidian-git:refresh\");\n        };\n\n        if (this.state.bRef == undefined) {\n            const resetButton = contentEl.createDiv();\n            resetButton.addClass(\"clickable-icon\");\n            resetButton.setAttr(\"aria-label\", \"Reset hunk\");\n            setIcon(resetButton, \"undo\");\n            resetButton.onmousedown = (_) => {\n                const source = this.mergeView!.a;\n                const dest = this.mergeView!.b;\n                const chunks = getChunks(dest.state)!;\n                const index = contentEl.parentElement?.indexOf(contentEl);\n\n                const chunk = chunks.chunks[index!];\n\n                if (chunk) {\n                    const srcFrom = chunk.fromA;\n                    const srcTo = chunk.toA;\n                    const destFrom = chunk.fromB;\n                    const destTo = chunk.toB;\n                    let insert = source.state.sliceDoc(\n                        srcFrom,\n                        Math.max(srcFrom, srcTo - 1)\n                    );\n                    if (srcFrom != srcTo && destTo <= dest.state.doc.length)\n                        insert += source.state.lineBreak;\n                    dest.dispatch({\n                        changes: {\n                            from: destFrom,\n                            to: Math.min(dest.state.doc.length, destTo),\n                            insert,\n                        },\n                        userEvent: \"revert\",\n                    });\n                }\n            };\n        }\n\n        // Prevent the default revert behavior by codemirror to apply\n        contentEl.onmousedown = (event) => {\n            event.preventDefault();\n            event.stopPropagation();\n        };\n        return contentEl;\n    }\n\n    async createMergeView() {\n        if (\n            this.state?.aFile &&\n            this.state?.bFile &&\n            !this.refreshing &&\n            this.plugin.gitManager\n        ) {\n            this.refreshing = true;\n\n            // cleanup\n            this.mergeView?.destroy();\n            const container = this.containerEl.children[1];\n            container.empty();\n\n            // new\n\n            this.contentEl.addClass(\"git-split-diff-view\", \"git-diff\");\n            this.bIsEditable = await this.bShouldBeEditable();\n\n            const aText = await this.gitShow(this.state.aRef, this.state.aFile);\n\n            let bText: string;\n            if (this.state.bRef != undefined) {\n                bText = await this.gitShow(this.state.bRef, this.state.bFile);\n            } else {\n                const bVaultPath = this.plugin.gitManager.getRelativeVaultPath(\n                    this.state.bFile\n                );\n                if (await this.app.vault.adapter.exists(bVaultPath)) {\n                    bText = await this.app.vault.adapter.read(bVaultPath);\n                } else {\n                    bText = \"\";\n                }\n            }\n\n            const basicExtensions = [\n                lineNumbers(),\n                highlightSelectionMatches(),\n                drawSelection(),\n                keymap.of([...standardKeymap, indentWithTab]),\n                history(),\n                search(),\n                EditorView.lineWrapping,\n            ];\n\n            // eslint-disable-next-line @typescript-eslint/no-this-alias\n            const myView = this;\n            const autoSavePlugin = ViewPlugin.define((view) => ({\n                update(update) {\n                    if (\n                        update.docChanged &&\n                        !update.transactions.some((tr) =>\n                            tr.annotation(Transaction.remote)\n                        )\n                    ) {\n                        const lhsContent = view.state.doc.toString();\n                        myView.fileSaveDebouncer(lhsContent);\n                    }\n                },\n            }));\n\n            const aState = {\n                doc: aText,\n                extensions: [\n                    ...basicExtensions,\n                    EditorView.editable.of(false),\n                    EditorState.readOnly.of(true),\n                ],\n            };\n\n            const bExtensions = [...basicExtensions];\n\n            // Only make the editor modifiable when viewing the working tree version\n            if (!this.bIsEditable) {\n                bExtensions.push(\n                    EditorView.editable.of(false),\n                    EditorState.readOnly.of(true)\n                );\n            } else {\n                bExtensions.push(autoSavePlugin);\n            }\n\n            const bState = {\n                doc: bText,\n                extensions: bExtensions,\n            };\n\n            container.addClasses([\n                \"cm-s-obsidian\",\n                \"mod-cm6\",\n                \"markdown-source-view\",\n                \"cm-content\",\n            ]);\n\n            const showButtons =\n                this.plugin.gitManager instanceof SimpleGit &&\n                (this.state.bRef === undefined || this.state.bRef === \"\");\n\n            this.mergeView = new MergeView({\n                b: bState,\n                a: aState,\n                collapseUnchanged: {\n                    minSize: 6,\n                    margin: 4,\n                },\n                renderRevertControl: showButtons\n                    ? () => this.renderButtons()\n                    : undefined,\n                revertControls: showButtons ? \"a-to-b\" : undefined,\n                diffConfig: {\n                    scanLimit: this.bIsEditable ? 1000 : 10000, // default is 500\n                },\n                parent: container,\n            });\n\n            this.refreshing = false;\n        }\n    }\n}\n"
  },
  {
    "path": "src/ui/history/components/logComponent.svelte",
    "content": "<script lang=\"ts\">\n    import { moment } from \"obsidian\";\n    import type ObsidianGit from \"src/main\";\n    import type { LogEntry } from \"src/types\";\n    import { slide } from \"svelte/transition\";\n    import type HistoryView from \"../historyView\";\n    import LogFileComponent from \"./logFileComponent.svelte\";\n    import LogTreeComponent from \"./logTreeComponent.svelte\";\n\n    interface Props {\n        log: LogEntry;\n        view: HistoryView;\n        showTree: boolean;\n        plugin: ObsidianGit;\n    }\n\n    let { log, view, showTree, plugin }: Props = $props();\n    let logsHierarchy = $derived({\n        title: \"\",\n        path: \"\",\n        vaultPath: \"\",\n        children: plugin.gitManager.getTreeStructure(log.diff.files),\n    });\n\n    let side = $derived(\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access\n        (view.leaf.getRoot() as any).side == \"left\" ? \"right\" : \"left\"\n    );\n    let isCollapsed = $state(true);\n    let closed = $state<Record<string, boolean>>({});\n\n    function authorToString(log: LogEntry) {\n        const name = log.author.name;\n        if (plugin.settings.authorInHistoryView == \"full\") {\n            return name;\n        } else if (plugin.settings.authorInHistoryView == \"initials\") {\n            const words = name.split(\" \").filter((word) => word.length > 0);\n\n            return words.map((word) => word[0].toUpperCase()).join(\"\");\n        }\n    }\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<main>\n    <div class=\"tree-item nav-folder\" class:is-collapsed={isCollapsed}>\n        <div\n            class=\"tree-item-self is-clickable nav-folder-title\"\n            aria-label={`${log.refs.length > 0 ? log.refs.join(\", \") + \"\\n\" : \"\"}${log.author?.name}\n${moment(log.date).format(plugin.settings.commitDateFormat)}\n${log.message}`}\n            data-tooltip-position={side}\n            onclick={() => (isCollapsed = !isCollapsed)}\n        >\n            <div\n                class=\"tree-item-icon nav-folder-collapse-indicator collapse-icon\"\n                class:is-collapsed={isCollapsed}\n            >\n                <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    width=\"24\"\n                    height=\"24\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"2\"\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    class=\"svg-icon right-triangle\"\n                    ><path d=\"M3 8L12 17L21 8\" /></svg\n                >\n            </div>\n            <div>\n                {#if log.refs.length > 0}\n                    <div class=\"git-ref\">\n                        {log.refs.join(\", \")}\n                    </div>\n                {/if}\n                {#if plugin.settings.authorInHistoryView != \"hide\" && log.author?.name}\n                    <div class=\"git-author\">\n                        {authorToString(log)}\n                    </div>\n                {/if}\n                {#if plugin.settings.dateInHistoryView}\n                    <div class=\"git-date\">\n                        {moment(log.date).format(\n                            plugin.settings.commitDateFormat\n                        )}\n                    </div>\n                {/if}\n\n                <div class=\"tree-item-inner nav-folder-title-content\">\n                    {log.message}\n                </div>\n            </div>\n        </div>\n        {#if !isCollapsed}\n            <div\n                class=\"tree-item-children nav-folder-children\"\n                transition:slide|local={{ duration: 150 }}\n            >\n                {#if showTree}\n                    <LogTreeComponent\n                        hierarchy={logsHierarchy}\n                        {plugin}\n                        {view}\n                        topLevel={true}\n                        bind:closed\n                    />\n                {:else}\n                    {#each log.diff.files as file}\n                        <LogFileComponent {view} diff={file} />\n                    {/each}\n                {/if}\n            </div>\n        {/if}\n    </div>\n</main>\n\n<style lang=\"scss\">\n</style>\n"
  },
  {
    "path": "src/ui/history/components/logFileComponent.svelte",
    "content": "<script lang=\"ts\">\n    import { setIcon, TFile } from \"obsidian\";\n    import type { DiffFile } from \"src/types\";\n    import {\n        fileIsBinary,\n        fileOpenableInObsidian,\n        getDisplayPath,\n        getNewLeaf,\n        mayTriggerFileMenu,\n    } from \"src/utils\";\n    import type HistoryView from \"../historyView\";\n\n    interface Props {\n        diff: DiffFile;\n        view: HistoryView;\n    }\n\n    let { diff, view }: Props = $props();\n    let buttons: HTMLElement[] = $state([]);\n\n    let side = $derived(\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access\n        (view.leaf.getRoot() as any).side == \"left\" ? \"right\" : \"left\"\n    );\n\n    $effect(() => {\n        for (const b of buttons) if (b) setIcon(b, b.getAttr(\"data-icon\")!);\n    });\n\n    function mainClick(event: MouseEvent) {\n        event.stopPropagation();\n        if (fileIsBinary(diff.path)) {\n            open(event);\n        } else {\n            showDiff(event);\n        }\n    }\n\n    function open(event: MouseEvent) {\n        event.stopPropagation();\n        const file = view.app.vault.getAbstractFileByPath(diff.vaultPath);\n\n        if (file instanceof TFile) {\n            getNewLeaf(view.app, event)\n                ?.openFile(file)\n                .catch((e) => view.plugin.displayError(e));\n        }\n    }\n\n    function showDiff(event: MouseEvent) {\n        view.plugin.tools.openDiff({\n            event,\n            aFile: diff.fromPath ?? diff.path,\n            aRef: `${diff.hash}^`,\n            bFile: diff.path,\n            bRef: diff.hash,\n        });\n    }\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->\n<main\n    onclick={mainClick}\n    onauxclick={(event) => {\n        event.stopPropagation();\n        if (event.button == 2)\n            mayTriggerFileMenu(\n                view.app,\n                event,\n                diff.vaultPath,\n                view.leaf,\n                \"git-history\"\n            );\n        else mainClick(event);\n    }}\n    class=\"tree-item nav-file\"\n>\n    <div\n        class=\"tree-item-self is-clickable nav-file-title\"\n        data-path={diff.vaultPath}\n        data-tooltip-position={side}\n        aria-label={diff.vaultPath}\n    >\n        <div class=\"tree-item-inner nav-file-title-content\">\n            {getDisplayPath(diff.vaultPath)}\n        </div>\n        <div class=\"git-tools\">\n            <div class=\"buttons\">\n                {#if fileOpenableInObsidian(diff.vaultPath, view.app)}\n                    <div\n                        data-icon=\"go-to-file\"\n                        aria-label=\"Open File\"\n                        bind:this={buttons[0]}\n                        onauxclick={open}\n                        onclick={open}\n                        class=\"clickable-icon\"\n                    ></div>\n                {/if}\n            </div>\n            <span class=\"type\" data-type={diff.status}>{diff.status}</span>\n        </div>\n    </div>\n</main>\n\n<style lang=\"scss\">\n    main {\n        .nav-file-title {\n            align-items: center;\n        }\n    }\n</style>\n"
  },
  {
    "path": "src/ui/history/components/logTreeComponent.svelte",
    "content": "<!-- tslint:disable ts(2345)  -->\n<script lang=\"ts\">\n    import LogTreeComponent from \"./logTreeComponent.svelte\";\n    import type ObsidianGit from \"src/main\";\n    import type { HistoryRootTreeItem, TreeItem } from \"src/types\";\n    import { slide } from \"svelte/transition\";\n    import type HistoryView from \"../historyView\";\n    import LogFileComponent from \"./logFileComponent.svelte\";\n\n    interface Props {\n        hierarchy: HistoryRootTreeItem;\n        plugin: ObsidianGit;\n        view: HistoryView;\n        topLevel?: boolean;\n        closed: Record<string, boolean>;\n    }\n\n    let {\n        hierarchy,\n        plugin,\n        view,\n        topLevel = false,\n        closed = $bindable(),\n    }: Props = $props();\n\n    let side = $derived(\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access\n        (view.leaf.getRoot() as any).side == \"left\" ? \"right\" : \"left\"\n    );\n\n    function fold(event: MouseEvent, item: TreeItem) {\n        event.stopPropagation();\n        closed[item.path] = !closed[item.path];\n    }\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<main class:topLevel>\n    {#each hierarchy.children as entity}\n        {#if entity.data}\n            <div>\n                <LogFileComponent diff={entity.data} {view} />\n            </div>\n        {:else}\n            <div\n                class=\"tree-item nav-folder\"\n                class:is-collapsed={closed[entity.path]}\n            >\n                <div\n                    class=\"tree-item-self is-clickable nav-folder-title\"\n                    data-tooltip-position={side}\n                    aria-label={entity.vaultPath}\n                    onclick={(event) => fold(event, entity)}\n                >\n                    <div\n                        data-icon=\"folder\"\n                        style=\"padding-right: 5px; display: flex; \"\n                    ></div>\n                    <div\n                        class=\"tree-item-icon nav-folder-collapse-indicator collapse-icon\"\n                        class:is-collapsed={closed[entity.path]}\n                    >\n                        <svg\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                            width=\"24\"\n                            height=\"24\"\n                            viewBox=\"0 0 24 24\"\n                            fill=\"none\"\n                            stroke=\"currentColor\"\n                            stroke-width=\"2\"\n                            stroke-linecap=\"round\"\n                            stroke-linejoin=\"round\"\n                            class=\"svg-icon right-triangle\"\n                            ><path d=\"M3 8L12 17L21 8\" /></svg\n                        >\n                    </div>\n                    <div class=\"tree-item-inner nav-folder-title-content\">\n                        {entity.title}\n                    </div>\n                </div>\n\n                {#if !closed[entity.path]}\n                    <div\n                        class=\"tree-item-children nav-folder-children\"\n                        transition:slide|local={{ duration: 150 }}\n                    >\n                        <LogTreeComponent\n                            hierarchy={entity as HistoryRootTreeItem}\n                            {plugin}\n                            {view}\n                            bind:closed\n                        />\n                    </div>\n                {/if}\n            </div>\n        {/if}\n    {/each}\n</main>\n\n<style lang=\"scss\">\n    main {\n        .nav-folder-title-content {\n            display: flex;\n            align-items: center;\n        }\n    }\n</style>\n"
  },
  {
    "path": "src/ui/history/historyView.svelte",
    "content": "<script lang=\"ts\">\n    import { setIcon } from \"obsidian\";\n    import { SimpleGit } from \"src/gitManager/simpleGit\";\n    import type ObsidianGit from \"src/main\";\n    import type { LogEntry } from \"src/types\";\n    import { onMount } from \"svelte\";\n    import LogComponent from \"./components/logComponent.svelte\";\n    import type HistoryView from \"./historyView\";\n\n    interface Props {\n        plugin: ObsidianGit;\n        view: HistoryView;\n    }\n\n    let { plugin = $bindable(), view }: Props = $props();\n    let loading: boolean = $state(false);\n    let buttons: HTMLElement[] = $state([]);\n    let logs: LogEntry[] | undefined = $state();\n    let showTree: boolean = $state(plugin.settings.treeStructure);\n\n    let layoutBtn: HTMLElement | undefined = $state();\n\n    $effect(() => {\n        if (layoutBtn) {\n            layoutBtn.empty();\n        }\n    });\n\n    onMount(() => {\n        view.registerEvent(\n            view.app.workspace.on(\n                \"obsidian-git:head-change\",\n                () => void refresh().catch(console.error)\n            )\n        );\n    });\n\n    $effect(() => {\n        buttons.forEach((btn) => setIcon(btn, btn.getAttr(\"data-icon\")!));\n    });\n\n    onMount(() => {\n        const observer = new IntersectionObserver((entries) => {\n            if (entries[0].isIntersecting && !loading) {\n                appendLogs().catch(console.error);\n            }\n        });\n        const sentinel = document.querySelector(\"#sentinel\");\n        if (sentinel) {\n            observer.observe(sentinel);\n        }\n\n        return () => {\n            observer.disconnect();\n        };\n    });\n\n    refresh().catch(console.error);\n\n    function triggerRefresh() {\n        refresh().catch(console.error);\n    }\n\n    async function refresh() {\n        if (!plugin.gitReady) {\n            logs = undefined;\n            return;\n        }\n        loading = true;\n        const isSimpleGit = plugin.gitManager instanceof SimpleGit;\n        let limit;\n        if ((logs?.length ?? 0) == 0) {\n            limit = isSimpleGit ? 50 : 10;\n        } else {\n            limit = logs!.length;\n        }\n        logs = await plugin.gitManager.log(undefined, false, limit);\n        loading = false;\n    }\n\n    async function appendLogs() {\n        if (!plugin.gitReady || logs === undefined) {\n            return;\n        }\n        loading = true;\n        const isSimpleGit = plugin.gitManager instanceof SimpleGit;\n        const limit = isSimpleGit ? 50 : 10;\n        const newLogs = await plugin.gitManager.log(\n            undefined,\n            false,\n            limit,\n            logs.last()?.hash\n        );\n        // Remove the first element of the new logs, as it is the same as the last element of the current logs.\n        // And don't use hash^ as it fails for the first commit.\n        logs.push(...newLogs.slice(1));\n        loading = false;\n    }\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<main class=\"git-view\">\n    <div class=\"nav-header\">\n        <div class=\"nav-buttons-container\">\n            <div\n                id=\"layoutChange\"\n                class=\"clickable-icon nav-action-button\"\n                data-icon={showTree ? \"list\" : \"folder\"}\n                aria-label=\"Change Layout\"\n                bind:this={buttons[0]}\n                onclick={() => {\n                    showTree = !showTree;\n                    setIcon(buttons[0], showTree ? \"list\" : \"folder\");\n                    plugin.settings.treeStructure = showTree;\n                    void plugin.saveSettings();\n                }}\n            ></div>\n            <div\n                id=\"refresh\"\n                class=\"clickable-icon nav-action-button\"\n                class:loading\n                data-icon=\"refresh-cw\"\n                aria-label=\"Refresh\"\n                bind:this={buttons[1]}\n                onclick={triggerRefresh}\n            ></div>\n        </div>\n    </div>\n\n    <div class=\"nav-files-container\" style=\"position: relative;\">\n        {#if logs}\n            <div class=\"tree-item nav-folder mod-root\">\n                {#each logs as log}\n                    <LogComponent {view} {showTree} {log} {plugin} />\n                {/each}\n            </div>\n        {/if}\n        <div id=\"sentinel\"></div>\n        <!-- Ensure that the sentinel item is reachable with the overlaying status bar and indicate that the end of the list is reached  -->\n        <div style=\"margin-bottom:40px\"></div>\n    </div>\n</main>\n\n<style lang=\"scss\">\n</style>\n"
  },
  {
    "path": "src/ui/history/historyView.ts",
    "content": "import type { HoverParent, HoverPopover, WorkspaceLeaf } from \"obsidian\";\nimport { ItemView } from \"obsidian\";\nimport { HISTORY_VIEW_CONFIG } from \"src/constants\";\nimport type ObsidianGit from \"src/main\";\nimport HistoryViewComponent from \"./historyView.svelte\";\nimport { mount, unmount } from \"svelte\";\n\nexport default class HistoryView extends ItemView implements HoverParent {\n    plugin: ObsidianGit;\n    private _view: Record<string, unknown> | undefined;\n    hoverPopover: HoverPopover | null;\n\n    constructor(leaf: WorkspaceLeaf, plugin: ObsidianGit) {\n        super(leaf);\n        this.plugin = plugin;\n        this.hoverPopover = null;\n    }\n\n    getViewType(): string {\n        return HISTORY_VIEW_CONFIG.type;\n    }\n\n    getDisplayText(): string {\n        return HISTORY_VIEW_CONFIG.name;\n    }\n\n    getIcon(): string {\n        return HISTORY_VIEW_CONFIG.icon;\n    }\n\n    onClose(): Promise<void> {\n        if (this._view) {\n            // eslint-disable-next-line @typescript-eslint/no-floating-promises\n            unmount(this._view);\n        }\n        return super.onClose();\n    }\n\n    reload(): void {\n        if (this._view) {\n            // eslint-disable-next-line @typescript-eslint/no-floating-promises\n            unmount(this._view);\n        }\n        this._view = mount(HistoryViewComponent, {\n            target: this.contentEl,\n            props: {\n                plugin: this.plugin,\n                view: this,\n            },\n        });\n    }\n\n    onOpen(): Promise<void> {\n        this.reload();\n        return super.onOpen();\n    }\n}\n"
  },
  {
    "path": "src/ui/modals/branchModal.ts",
    "content": "import { FuzzySuggestModal } from \"obsidian\";\nimport type ObsidianGit from \"src/main\";\n\nexport class BranchModal extends FuzzySuggestModal<string> {\n    resolve: (\n        value: string | undefined | PromiseLike<string | undefined>\n    ) => void;\n\n    constructor(\n        plugin: ObsidianGit,\n        private readonly branches: string[]\n    ) {\n        super(plugin.app);\n        this.setPlaceholder(\"Select branch to checkout\");\n    }\n\n    getItems(): string[] {\n        return this.branches;\n    }\n    getItemText(item: string): string {\n        return item;\n    }\n    onChooseItem(item: string, _: MouseEvent | KeyboardEvent): void {\n        this.resolve(item);\n    }\n\n    openAndGetReslt(): Promise<string> {\n        return new Promise((resolve) => {\n            this.resolve = resolve;\n            this.open();\n        });\n    }\n\n    onClose() {\n        //onClose gets called before onChooseItem\n        void new Promise((resolve) => setTimeout(resolve, 10)).then(() => {\n            if (this.resolve) this.resolve(undefined);\n        });\n    }\n}\n"
  },
  {
    "path": "src/ui/modals/changedFilesModal.ts",
    "content": "import { FuzzySuggestModal } from \"obsidian\";\nimport type ObsidianGit from \"src/main\";\nimport type { FileStatusResult } from \"src/types\";\n\nexport class ChangedFilesModal extends FuzzySuggestModal<FileStatusResult> {\n    plugin: ObsidianGit;\n    changedFiles: FileStatusResult[];\n\n    constructor(plugin: ObsidianGit, changedFiles: FileStatusResult[]) {\n        super(plugin.app);\n        this.plugin = plugin;\n        this.changedFiles = changedFiles;\n        this.setPlaceholder(\n            \"Not supported files will be opened by default app!\"\n        );\n    }\n\n    getItems(): FileStatusResult[] {\n        return this.changedFiles;\n    }\n\n    getItemText(item: FileStatusResult): string {\n        if (item.index == \"U\" && item.workingDir == \"U\") {\n            return `Untracked | ${item.vaultPath}`;\n        }\n\n        let workingDir = \"\";\n        let index = \"\";\n\n        if (item.workingDir != \" \")\n            workingDir = `Working Dir: ${item.workingDir} `;\n        if (item.index != \" \") index = `Index: ${item.index}`;\n\n        return `${workingDir}${index} | ${item.vaultPath}`;\n    }\n\n    onChooseItem(item: FileStatusResult, _: MouseEvent | KeyboardEvent): void {\n        if (\n            this.plugin.app.metadataCache.getFirstLinkpathDest(\n                item.vaultPath,\n                \"\"\n            ) == null\n        ) {\n            this.app.openWithDefaultApp(item.vaultPath);\n        } else {\n            void this.plugin.app.workspace.openLinkText(item.vaultPath, \"/\");\n        }\n    }\n}\n"
  },
  {
    "path": "src/ui/modals/customMessageModal.ts",
    "content": "import { moment, SuggestModal } from \"obsidian\";\nimport type ObsidianGit from \"src/main\";\n\nexport class CustomMessageModal extends SuggestModal<string> {\n    resolve:\n        | ((value: string | PromiseLike<string> | undefined) => void)\n        | null = null;\n    constructor(private readonly plugin: ObsidianGit) {\n        super(plugin.app);\n        this.setPlaceholder(\n            \"Type your message and select optional the version with the added date.\"\n        );\n    }\n\n    openAndGetResult(): Promise<string> {\n        return new Promise((resolve) => {\n            this.resolve = resolve;\n            this.open();\n        });\n    }\n\n    onClose() {\n        // onClose gets called before onChooseItem\n        void new Promise((resolve) => setTimeout(resolve, 10)).then(() => {\n            if (this.resolve) this.resolve(undefined);\n        });\n    }\n\n    getSuggestions(query: string): string[] {\n        const date = moment().format(this.plugin.settings.commitDateFormat);\n        if (query == \"\") query = \"...\";\n        return [query, `${date}: ${query}`, `${query}: ${date}`];\n    }\n\n    renderSuggestion(value: string, el: HTMLElement): void {\n        el.innerText = value;\n    }\n\n    onChooseSuggestion(value: string, __: MouseEvent | KeyboardEvent) {\n        if (this.resolve) this.resolve(value);\n    }\n}\n"
  },
  {
    "path": "src/ui/modals/discardModal.ts",
    "content": "import type { App } from \"obsidian\";\nimport { Modal } from \"obsidian\";\nimport { plural } from \"src/utils\";\n\nexport type DiscardResult = false | \"delete\" | \"discard\";\n\nexport class DiscardModal extends Modal {\n    path: string;\n    deleteCount: number;\n    discardCount: number;\n    constructor({\n        app,\n        path,\n        filesToDeleteCount,\n        filesToDiscardCount,\n    }: {\n        app: App;\n        path: string;\n        filesToDeleteCount: number;\n        filesToDiscardCount: number;\n    }) {\n        super(app);\n        this.path = path;\n        this.deleteCount = filesToDeleteCount;\n        this.discardCount = filesToDiscardCount;\n    }\n    resolve: ((value: DiscardResult) => void) | null = null;\n\n    /**\n     * @returns the result of the modal, whcih can be:\n     *   - `false` if the user canceled the modal\n     *   - `\"delete\"` if the user chose to delete all files. In case there are also tracked files, they will be discarded as well.\n     *   - `\"discard\"` if the user chose to discard all tracked files. Untracked files will not be deleted.\n     */\n    openAndGetResult(): Promise<DiscardResult> {\n        this.open();\n        return new Promise<DiscardResult>((resolve) => {\n            this.resolve = resolve;\n        });\n    }\n\n    onOpen() {\n        const sum = this.deleteCount + this.discardCount;\n        const { contentEl, titleEl } = this;\n        let titlePart = \"\";\n        if (this.path != \"\") {\n            if (sum > 1) {\n                titlePart = `files in \"${this.path}\"`;\n            } else {\n                titlePart = `\"${this.path}\"`;\n            }\n        }\n        titleEl.setText(\n            `${this.discardCount == 0 ? \"Delete\" : \"Discard\"} ${titlePart}`\n        );\n        if (this.deleteCount > 0) {\n            contentEl\n                .createEl(\"p\")\n                .setText(\n                    `Are you sure you want to DELETE the ${plural(this.deleteCount, \"untracked file\")}? They are deleted according to your Obsidian trash settting.`\n                );\n        }\n        if (this.discardCount > 0) {\n            contentEl\n                .createEl(\"p\")\n                .setText(\n                    `Are you sure you want to discard ALL changes in ${plural(this.discardCount, \"tracked file\")}?`\n                );\n        }\n        const div = contentEl.createDiv({ cls: \"modal-button-container\" });\n\n        if (this.deleteCount > 0) {\n            const discardAndDelete = div.createEl(\"button\", {\n                cls: \"mod-warning\",\n                text: `${this.discardCount > 0 ? \"Discard\" : \"Delete\"} all ${plural(sum, \"file\")}`,\n            });\n            discardAndDelete.addEventListener(\"click\", () => {\n                if (this.resolve) this.resolve(\"delete\");\n                this.close();\n            });\n            discardAndDelete.addEventListener(\"keypress\", () => {\n                if (this.resolve) this.resolve(\"delete\");\n                this.close();\n            });\n        }\n\n        if (this.discardCount > 0) {\n            const discard = div.createEl(\"button\", {\n                cls: \"mod-warning\",\n                text: `Discard all ${plural(this.discardCount, \"tracked file\")}`,\n            });\n            discard.addEventListener(\"click\", () => {\n                if (this.resolve) this.resolve(\"discard\");\n                this.close();\n            });\n            discard.addEventListener(\"keypress\", () => {\n                if (this.resolve) this.resolve(\"discard\");\n                this.close();\n            });\n        }\n\n        const close = div.createEl(\"button\", {\n            text: \"Cancel\",\n        });\n        close.addEventListener(\"click\", () => {\n            if (this.resolve) this.resolve(false);\n            return this.close();\n        });\n        close.addEventListener(\"keypress\", () => {\n            if (this.resolve) this.resolve(false);\n            return this.close();\n        });\n    }\n\n    onClose() {\n        const { contentEl } = this;\n        contentEl.empty();\n    }\n}\n"
  },
  {
    "path": "src/ui/modals/generalModal.ts",
    "content": "import { SuggestModal } from \"obsidian\";\nimport type ObsidianGit from \"src/main\";\n\nexport interface OptionalGeneralModalConfig {\n    options?: string[];\n    placeholder?: string;\n    allowEmpty?: boolean;\n    onlySelection?: boolean;\n    initialValue?: string;\n    obscure?: boolean;\n}\ninterface GeneralModalConfig {\n    options: string[];\n    placeholder: string;\n    allowEmpty: boolean;\n    onlySelection: boolean;\n    initialValue?: string;\n    obscure: boolean;\n}\n\nconst generalModalConfigDefaults: GeneralModalConfig = {\n    options: [],\n    placeholder: \"\",\n    allowEmpty: false,\n    onlySelection: false,\n    initialValue: undefined,\n    obscure: false,\n};\n\nexport class GeneralModal extends SuggestModal<string> {\n    resolve: (\n        value: string | undefined | PromiseLike<string | undefined>\n    ) => void;\n    config: GeneralModalConfig;\n\n    constructor(plugin: ObsidianGit, config: OptionalGeneralModalConfig) {\n        super(plugin.app);\n        this.config = { ...generalModalConfigDefaults, ...config };\n        this.setPlaceholder(this.config.placeholder);\n        if (this.config.obscure) {\n            this.inputEl.type = \"password\";\n            const promptContainer = this.containerEl.querySelector(\n                \".prompt-input-container\"\n            )!;\n            promptContainer.addClass(\"git-obscure-prompt\");\n            promptContainer.setAttr(\"git-is-obscured\", \"true\");\n            const obscureSwitchButton = promptContainer?.createDiv({\n                cls: \"search-input-clear-button\",\n            });\n            obscureSwitchButton.style.marginRight = \"32px\";\n            obscureSwitchButton.id = \"git-show-password\";\n            obscureSwitchButton.addEventListener(\"click\", () => {\n                const isObscured = promptContainer.getAttr(\"git-is-obscured\");\n                if (isObscured === \"true\") {\n                    this.inputEl.type = \"text\";\n                    promptContainer.setAttr(\"git-is-obscured\", \"false\");\n                } else {\n                    this.inputEl.type = \"password\";\n                    promptContainer.setAttr(\"git-is-obscured\", \"true\");\n                }\n            });\n        }\n    }\n\n    openAndGetResult(): Promise<string | undefined> {\n        return new Promise((resolve) => {\n            this.resolve = resolve;\n            this.open();\n            if (this.config.initialValue != undefined) {\n                this.inputEl.value = this.config.initialValue;\n                this.inputEl.dispatchEvent(new Event(\"input\"));\n            }\n        });\n    }\n\n    onClose() {\n        void new Promise((resolve) => setTimeout(resolve, 10)).then(() => {\n            if (this.resolve) this.resolve(undefined);\n        });\n    }\n\n    getSuggestions(query: string): string[] {\n        if (this.config.onlySelection) {\n            return this.config.options;\n        } else if (this.config.allowEmpty) {\n            return [query.length > 0 ? query : \" \", ...this.config.options];\n        } else {\n            return [query.length > 0 ? query : \"...\", ...this.config.options];\n        }\n    }\n\n    renderSuggestion(value: string, el: HTMLElement): void {\n        if (this.config.obscure) {\n            el.hide();\n        } else {\n            el.setText(value);\n        }\n    }\n\n    onChooseSuggestion(value: string, _: MouseEvent | KeyboardEvent) {\n        if (this.resolve) {\n            let res;\n            if (this.config.allowEmpty && value === \" \") res = \"\";\n            else if (value === \"...\") res = undefined;\n            else res = value;\n            this.resolve(res);\n        }\n    }\n}\n"
  },
  {
    "path": "src/ui/modals/ignoreModal.ts",
    "content": "import type { App } from \"obsidian\";\nimport { Modal } from \"obsidian\";\n\nexport class IgnoreModal extends Modal {\n    resolve:\n        | ((value: string | PromiseLike<string> | undefined) => void)\n        | null = null;\n    constructor(\n        app: App,\n        private content: string\n    ) {\n        super(app);\n    }\n\n    openAndGetReslt(): Promise<string> {\n        return new Promise((resolve) => {\n            this.resolve = resolve;\n            this.open();\n        });\n    }\n\n    onOpen() {\n        const { contentEl, titleEl } = this;\n        titleEl.setText(\"Edit .gitignore\");\n        const div = contentEl.createDiv();\n\n        const text = div.createEl(\"textarea\", {\n            text: this.content,\n            cls: [\"obsidian-git-textarea\"],\n            attr: { rows: 10, cols: 30, wrap: \"off\" },\n        });\n\n        div.createEl(\"button\", {\n            cls: [\"mod-cta\", \"obsidian-git-center-button\"],\n            text: \"Save\",\n        }).addEventListener(\"click\", () => {\n            this.resolve!(text.value);\n            this.close();\n        });\n    }\n\n    onClose() {\n        const { contentEl } = this;\n        contentEl.empty();\n        if (this.resolve) this.resolve(undefined);\n    }\n}\n"
  },
  {
    "path": "src/ui/sourceControl/components/fileComponent.svelte",
    "content": "<script lang=\"ts\">\n    import { setIcon, TFile } from \"obsidian\";\n    import { hoverPreview } from \"src/utils\";\n    import type { GitManager } from \"src/gitManager/gitManager\";\n    import type { FileStatusResult } from \"src/types\";\n    import { DiscardModal } from \"src/ui/modals/discardModal\";\n    import {\n        fileIsBinary,\n        fileOpenableInObsidian,\n        getDisplayPath,\n        getNewLeaf,\n        mayTriggerFileMenu,\n    } from \"src/utils\";\n    import type GitView from \"../sourceControl\";\n\n    interface Props {\n        change: FileStatusResult;\n        view: GitView;\n        manager: GitManager;\n    }\n\n    let { change, view, manager }: Props = $props();\n    let buttons: HTMLElement[] = $state([]);\n\n    let side = $derived(\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access\n        (view.leaf.getRoot() as any).side == \"left\" ? \"right\" : \"left\"\n    );\n\n    $effect(() => {\n        for (const b of buttons) if (b) setIcon(b, b.getAttr(\"data-icon\")!);\n    });\n\n    function mainClick(event: MouseEvent) {\n        event.stopPropagation();\n        if (fileIsBinary(change.path)) {\n            open(event);\n        } else {\n            showDiff(event);\n        }\n    }\n\n    function hover(event: MouseEvent) {\n        //Don't show previews of config- or hidden files.\n        if (view.app.vault.getAbstractFileByPath(change.vaultPath)) {\n            hoverPreview(view.app, event, view, change.vaultPath);\n        }\n    }\n\n    function open(event: MouseEvent) {\n        event.stopPropagation();\n        const file = view.app.vault.getAbstractFileByPath(change.vaultPath);\n\n        if (file instanceof TFile) {\n            getNewLeaf(view.app, event)\n                ?.openFile(file)\n                .catch((e) => view.plugin.displayError(e));\n        }\n    }\n\n    function stage(event: MouseEvent) {\n        event.stopPropagation();\n        manager\n            .stage(change.path, false)\n            .catch((e) => view.plugin.displayError(e))\n            .finally(() => {\n                view.app.workspace.trigger(\"obsidian-git:refresh\");\n            });\n    }\n\n    function showDiff(event: MouseEvent) {\n        event.stopPropagation();\n        view.plugin.tools.openDiff({\n            aFile: change.path,\n            aRef: \"\",\n            event,\n        });\n    }\n\n    function discard(event: MouseEvent) {\n        event.stopPropagation();\n        const deleteFile = change.workingDir == \"U\";\n        new DiscardModal({\n            app: view.app,\n            filesToDeleteCount: deleteFile ? 1 : 0,\n            filesToDiscardCount: deleteFile ? 0 : 1,\n            path: change.vaultPath,\n        })\n            .openAndGetResult()\n            .then(\n                async (result) => {\n                    if (result == \"delete\") {\n                        const tFile = view.app.vault.getAbstractFileByPath(\n                            change.vaultPath\n                        );\n                        if (tFile instanceof TFile) {\n                            await view.app.fileManager.trashFile(tFile);\n                        } else {\n                            await view.app.vault.adapter.remove(\n                                change.vaultPath\n                            );\n                        }\n                    } else if (result == \"discard\") {\n                        await manager.discard(change.path).finally(() => {\n                            view.app.workspace.trigger(\"obsidian-git:refresh\");\n                        });\n                    }\n\n                    view.app.workspace.trigger(\"obsidian-git:refresh\");\n                },\n                (e) => view.plugin.displayError(e)\n            );\n    }\n</script>\n\n<!-- TODO: Fix arai-label for left sidebar and if it's too long -->\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<!-- svelte-ignore a11y_unknown_aria_attribute -->\n<!-- svelte-ignore a11y_mouse_events_have_key_events -->\n<main\n    onmouseover={hover}\n    onclick={mainClick}\n    onauxclick={(event) => {\n        event.stopPropagation();\n        if (event.button == 2)\n            mayTriggerFileMenu(\n                view.app,\n                event,\n                change.vaultPath,\n                view.leaf,\n                \"git-source-control\"\n            );\n        else mainClick(event);\n    }}\n    class=\"tree-item nav-file\"\n>\n    <div\n        class=\"tree-item-self is-clickable nav-file-title\"\n        data-path={change.vaultPath}\n        data-tooltip-position={side}\n        aria-label={change.vaultPath}\n    >\n        <!-- <div\n\t\t\tdata-icon=\"folder\"\n\t\t\tbind:this={buttons[3]}\n\t\t\tstyle=\"padding-right: 5px; display: flex;\"\n\t\t/> -->\n        <div class=\"tree-item-inner nav-file-title-content\">\n            {getDisplayPath(change.vaultPath)}\n        </div>\n        <div class=\"git-tools\">\n            <div class=\"buttons\">\n                {#if fileOpenableInObsidian(change.vaultPath, view.app)}\n                    <div\n                        data-icon=\"go-to-file\"\n                        aria-label=\"Open File\"\n                        bind:this={buttons[0]}\n                        onauxclick={open}\n                        onclick={open}\n                        class=\"clickable-icon\"\n                    ></div>\n                {/if}\n                <div\n                    data-icon=\"undo\"\n                    aria-label=\"Discard\"\n                    bind:this={buttons[1]}\n                    onclick={discard}\n                    class=\"clickable-icon\"\n                ></div>\n                <div\n                    data-icon=\"plus\"\n                    aria-label=\"Stage\"\n                    bind:this={buttons[2]}\n                    onclick={stage}\n                    class=\"clickable-icon\"\n                ></div>\n            </div>\n            <div class=\"type\" data-type={change.workingDir}>\n                {change.workingDir}\n            </div>\n        </div>\n    </div>\n</main>\n"
  },
  {
    "path": "src/ui/sourceControl/components/pulledFileComponent.svelte",
    "content": "<script lang=\"ts\">\n    import { TFile } from \"obsidian\";\n    import { hoverPreview } from \"src/utils\";\n    import type { FileStatusResult } from \"src/types\";\n    import { getDisplayPath, getNewLeaf, mayTriggerFileMenu } from \"src/utils\";\n    import type GitView from \"../sourceControl\";\n\n    interface Props {\n        change: FileStatusResult;\n        view: GitView;\n    }\n\n    let { change, view }: Props = $props();\n    let side = $derived(\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access\n        (view.leaf.getRoot() as any).side == \"left\" ? \"right\" : \"left\"\n    );\n\n    function hover(event: MouseEvent) {\n        //Don't show previews of config- or hidden files.\n        if (view.app.vault.getAbstractFileByPath(change.vaultPath)) {\n            hoverPreview(view.app, event, view, change.vaultPath);\n        }\n    }\n\n    function open(event: MouseEvent) {\n        event.stopPropagation();\n        const file = view.app.vault.getAbstractFileByPath(change.vaultPath);\n        if (file instanceof TFile) {\n            getNewLeaf(view.app, event)\n                ?.openFile(file)\n                .catch((e) => view.plugin.displayError(e));\n        }\n    }\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->\n<!-- svelte-ignore a11y_mouse_events_have_key_events -->\n<main\n    onmouseover={hover}\n    onclick={open}\n    onauxclick={(event) => {\n        event.stopPropagation();\n        if (event.button == 2)\n            mayTriggerFileMenu(\n                view.app,\n                event,\n                change.vaultPath,\n                view.leaf,\n                \"git-source-control\"\n            );\n        else open(event);\n    }}\n    class=\"tree-item nav-file\"\n>\n    <div\n        class=\"tree-item-self is-clickable nav-file-title\"\n        data-path={change.vaultPath}\n        data-tooltip-position={side}\n        aria-label={change.vaultPath}\n    >\n        <div class=\"tree-item-inner nav-file-title-content\">\n            {getDisplayPath(change.vaultPath)}\n        </div>\n        <div class=\"git-tools\">\n            <span class=\"type\" data-type={change.workingDir}\n                >{change.workingDir}</span\n            >\n        </div>\n    </div>\n</main>\n"
  },
  {
    "path": "src/ui/sourceControl/components/stagedFileComponent.svelte",
    "content": "<script lang=\"ts\">\n    import { setIcon, TFile } from \"obsidian\";\n    import { hoverPreview } from \"src/utils\";\n    import type { GitManager } from \"src/gitManager/gitManager\";\n    import type { FileStatusResult } from \"src/types\";\n    import {\n        fileIsBinary,\n        fileOpenableInObsidian,\n        getDisplayPath,\n        getNewLeaf,\n        mayTriggerFileMenu,\n    } from \"src/utils\";\n    import type GitView from \"../sourceControl\";\n\n    interface Props {\n        change: FileStatusResult;\n        view: GitView;\n        manager: GitManager;\n    }\n\n    let { change, view, manager }: Props = $props();\n    let buttons: HTMLElement[] = $state([]);\n    let side = $derived(\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access\n        (view.leaf.getRoot() as any).side == \"left\" ? \"right\" : \"left\"\n    );\n\n    $effect(() => {\n        for (const b of buttons) if (b) setIcon(b, b.getAttr(\"data-icon\")!);\n    });\n\n    function mainClick(event: MouseEvent) {\n        event.stopPropagation();\n        if (fileIsBinary(change.path)) {\n            open(event);\n        } else {\n            showDiff(event);\n        }\n    }\n\n    function hover(event: MouseEvent) {\n        //Don't show previews of config- or hidden files.\n        if (view.app.vault.getFileByPath(change.vaultPath)) {\n            hoverPreview(view.app, event, view, change.vaultPath);\n        }\n    }\n\n    function open(event: MouseEvent) {\n        event.stopPropagation();\n        const file = view.app.vault.getAbstractFileByPath(change.vaultPath);\n        if (file instanceof TFile) {\n            getNewLeaf(view.app, event)\n                ?.openFile(file)\n                .catch((e) => view.plugin.displayError(e));\n        }\n    }\n\n    function showDiff(event: MouseEvent) {\n        event.stopPropagation();\n        view.plugin.tools.openDiff({\n            aFile: change.from ?? change.path,\n            bFile: change.path,\n            aRef: \"HEAD\",\n            bRef: \"\",\n            event,\n        });\n    }\n\n    function unstage(event: MouseEvent) {\n        event.stopPropagation();\n\n        manager\n            .unstage(change.path, false)\n            .catch((e) => view.plugin.displayError(e))\n            .finally(() => {\n                view.app.workspace.trigger(\"obsidian-git:refresh\");\n            });\n    }\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<!-- svelte-ignore a11y_mouse_events_have_key_events -->\n<main\n    onmouseover={hover}\n    onclick={mainClick}\n    onauxclick={(event) => {\n        event.stopPropagation();\n        if (event.button == 2)\n            mayTriggerFileMenu(\n                view.app,\n                event,\n                change.vaultPath,\n                view.leaf,\n                \"git-source-control\"\n            );\n        else mainClick(event);\n    }}\n    class=\"tree-item nav-file\"\n>\n    <div\n        class=\"tree-item-self is-clickable nav-file-title\"\n        data-path={change.vaultPath}\n        data-tooltip-position={side}\n        aria-label={change.vaultPath}\n    >\n        <div class=\"tree-item-inner nav-file-title-content\">\n            {getDisplayPath(change.vaultPath)}\n        </div>\n        <div class=\"git-tools\">\n            <div class=\"buttons\">\n                {#if fileOpenableInObsidian(change.vaultPath, view.app)}\n                    <div\n                        data-icon=\"go-to-file\"\n                        aria-label=\"Open File\"\n                        bind:this={buttons[0]}\n                        onclick={open}\n                        class=\"clickable-icon\"\n                    ></div>\n                {/if}\n                <div\n                    data-icon=\"minus\"\n                    aria-label=\"Unstage\"\n                    bind:this={buttons[1]}\n                    onclick={unstage}\n                    class=\"clickable-icon\"\n                ></div>\n            </div>\n            <div class=\"type\" data-type={change.index}>{change.index}</div>\n        </div>\n    </div>\n</main>\n"
  },
  {
    "path": "src/ui/sourceControl/components/tooManyFilesComponent.svelte",
    "content": "<script lang=\"ts\">\n    interface Props {\n        files: unknown[];\n    }\n\n    let { files }: Props = $props();\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->\n<main>\n    {#if files.length > 500}\n        <div class=\"tree-item nav-file\">\n            <div\n                class=\"tree-item-self nav-file-title\"\n                aria-label={\"And \" + (files.length - 500) + \" more files\"}\n            >\n                <div class=\"tree-item-inner nav-file-title-content\">\n                    {\"And \" + (files.length - 500) + \" more files\"}\n                </div>\n            </div>\n        </div>\n    {/if}\n</main>\n"
  },
  {
    "path": "src/ui/sourceControl/components/treeComponent.svelte",
    "content": "<!-- tslint:disable ts(2345)  -->\n<script lang=\"ts\">\n    import TreeComponent from \"./treeComponent.svelte\";\n\n    import type ObsidianGit from \"src/main\";\n    import type { StatusRootTreeItem, TreeItem } from \"src/types\";\n    import { FileType } from \"src/types\";\n    import { slide } from \"svelte/transition\";\n    import type GitView from \"../sourceControl\";\n    import FileComponent from \"./fileComponent.svelte\";\n    import PulledFileComponent from \"./pulledFileComponent.svelte\";\n    import StagedFileComponent from \"./stagedFileComponent.svelte\";\n    import { arrayProxyWithNewLength, mayTriggerFileMenu } from \"src/utils\";\n    import TooManyFilesComponent from \"./tooManyFilesComponent.svelte\";\n    import { onMount } from \"svelte\";\n    interface Props {\n        hierarchy: StatusRootTreeItem;\n        plugin: ObsidianGit;\n        view: GitView;\n        fileType: FileType;\n        topLevel?: boolean;\n        closed: Record<string, boolean>;\n    }\n\n    let {\n        hierarchy,\n        plugin,\n        view,\n        fileType,\n        topLevel = false,\n        closed = $bindable(),\n    }: Props = $props();\n\n    onMount(() => {\n        for (const entity of hierarchy.children) {\n            if ((entity.children?.length ?? 0) > 100)\n                closed[entity.title] = true;\n        }\n    });\n    /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */\n    let side = $derived(\n        (view.leaf.getRoot() as any).side == \"left\" ? \"right\" : \"left\"\n    );\n\n    function stage(event: MouseEvent, path: string) {\n        event.stopPropagation();\n        plugin.gitManager\n            .stageAll({ dir: path })\n            .catch((e) => plugin.displayError(e))\n            .finally(() => {\n                view.app.workspace.trigger(\"obsidian-git:refresh\");\n            });\n    }\n    function unstage(event: MouseEvent, path: string) {\n        event.stopPropagation();\n        plugin.gitManager\n            .unstageAll({ dir: path })\n            .catch((e) => plugin.displayError(e))\n            .finally(() => {\n                view.app.workspace.trigger(\"obsidian-git:refresh\");\n            });\n    }\n    function discard(event: MouseEvent, item: TreeItem) {\n        event.stopPropagation();\n        void plugin.discardAll(item.vaultPath);\n    }\n    function fold(event: MouseEvent, item: TreeItem) {\n        event.stopPropagation();\n        closed[item.path] = !closed[item.path];\n    }\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<main class:topLevel>\n    {#each arrayProxyWithNewLength(hierarchy.children, 500) as entity}\n        {#if entity.data}\n            <div>\n                {#if fileType == FileType.staged}\n                    <StagedFileComponent\n                        change={entity.data}\n                        manager={plugin.gitManager}\n                        {view}\n                    />\n                {:else if fileType == FileType.changed}\n                    <FileComponent\n                        change={entity.data}\n                        manager={plugin.gitManager}\n                        {view}\n                    />\n                {:else if fileType == FileType.pulled}\n                    <PulledFileComponent change={entity.data} {view} />\n                {/if}\n            </div>\n        {:else}\n            <div\n                onclick={(event) => fold(event, entity)}\n                onauxclick={(event) =>\n                    mayTriggerFileMenu(\n                        view.app,\n                        event,\n                        entity.vaultPath,\n                        view.leaf,\n                        \"git-source-control\"\n                    )}\n                class=\"tree-item nav-folder\"\n                class:is-collapsed={closed[entity.path]}\n            >\n                <div\n                    class=\"tree-item-self is-clickable nav-folder-title\"\n                    data-tooltip-position={side}\n                    aria-label={entity.vaultPath}\n                >\n                    <div\n                        data-icon=\"folder\"\n                        style=\"padding-right: 5px; display: flex; \"\n                    ></div>\n                    <div\n                        class=\"tree-item-icon nav-folder-collapse-indicator collapse-icon\"\n                        class:is-collapsed={closed[entity.path]}\n                    >\n                        <svg\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                            width=\"24\"\n                            height=\"24\"\n                            viewBox=\"0 0 24 24\"\n                            fill=\"none\"\n                            stroke=\"currentColor\"\n                            stroke-width=\"2\"\n                            stroke-linecap=\"round\"\n                            stroke-linejoin=\"round\"\n                            class=\"svg-icon right-triangle\"\n                            ><path d=\"M3 8L12 17L21 8\" /></svg\n                        >\n                    </div>\n                    <div class=\"tree-item-inner nav-folder-title-content\">\n                        {entity.title}\n                    </div>\n                    <div class=\"git-tools\">\n                        <div class=\"buttons\">\n                            {#if fileType == FileType.staged}\n                                <div\n                                    data-icon=\"minus\"\n                                    aria-label=\"Unstage\"\n                                    onclick={(event) =>\n                                        unstage(event, entity.path)}\n                                    class=\"clickable-icon\"\n                                >\n                                    <svg\n                                        width=\"18\"\n                                        height=\"18\"\n                                        viewBox=\"0 0 18 18\"\n                                        fill=\"none\"\n                                        stroke=\"currentColor\"\n                                        stroke-width=\"2\"\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        class=\"svg-icon lucide-minus\"\n                                        ><line\n                                            x1=\"4\"\n                                            y1=\"9\"\n                                            x2=\"14\"\n                                            y2=\"9\"\n                                        /></svg\n                                    >\n                                </div>\n                            {:else}\n                                <div\n                                    data-icon=\"undo\"\n                                    aria-label=\"Discard\"\n                                    onclick={(event) => discard(event, entity)}\n                                    class=\"clickable-icon\"\n                                >\n                                    <svg\n                                        xmlns=\"http://www.w3.org/2000/svg\"\n                                        width=\"24\"\n                                        height=\"24\"\n                                        viewBox=\"0 0 24 24\"\n                                        fill=\"none\"\n                                        stroke=\"currentColor\"\n                                        stroke-width=\"2\"\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        class=\"svg-icon lucide-undo\"\n                                        ><path d=\"M3 7v6h6\" /><path\n                                            d=\"M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13\"\n                                        /></svg\n                                    >\n                                </div>\n                                <div\n                                    data-icon=\"plus\"\n                                    aria-label=\"Stage\"\n                                    onclick={(event) =>\n                                        stage(event, entity.path)}\n                                    class=\"clickable-icon\"\n                                >\n                                    <svg\n                                        width=\"18\"\n                                        height=\"18\"\n                                        viewBox=\"0 0 18 18\"\n                                        fill=\"none\"\n                                        stroke=\"currentColor\"\n                                        stroke-width=\"2\"\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        class=\"svg-icon lucide-plus\"\n                                        ><line\n                                            x1=\"9\"\n                                            y1=\"4\"\n                                            x2=\"9\"\n                                            y2=\"14\"\n                                        /><line\n                                            x1=\"4\"\n                                            y1=\"9\"\n                                            x2=\"14\"\n                                            y2=\"9\"\n                                        /></svg\n                                    >\n                                </div>\n                            {/if}\n                            <div style=\"width:11px\"></div>\n                        </div>\n                    </div>\n                </div>\n\n                {#if !closed[entity.path]}\n                    <div\n                        class=\"tree-item-children nav-folder-children\"\n                        transition:slide|local={{ duration: 150 }}\n                    >\n                        <TreeComponent\n                            hierarchy={entity as StatusRootTreeItem}\n                            {plugin}\n                            {view}\n                            {fileType}\n                            bind:closed\n                        />\n                    </div>\n                {/if}\n            </div>\n        {/if}\n    {/each}\n\n    <TooManyFilesComponent files={hierarchy.children} />\n</main>\n"
  },
  {
    "path": "src/ui/sourceControl/sourceControl.svelte",
    "content": "<script lang=\"ts\">\n    import { Platform, Scope, setIcon } from \"obsidian\";\n    import { SOURCE_CONTROL_VIEW_CONFIG } from \"src/constants\";\n    import type ObsidianGit from \"src/main\";\n    import type {\n        FileStatusResult,\n        Status,\n        StatusRootTreeItem,\n    } from \"src/types\";\n    import { FileType } from \"src/types\";\n    import { arrayProxyWithNewLength, getDisplayPath } from \"src/utils\";\n    import { slide } from \"svelte/transition\";\n    import FileComponent from \"./components/fileComponent.svelte\";\n    import PulledFileComponent from \"./components/pulledFileComponent.svelte\";\n    import StagedFileComponent from \"./components/stagedFileComponent.svelte\";\n    import TreeComponent from \"./components/treeComponent.svelte\";\n    import type GitView from \"./sourceControl\";\n    import TooManyFilesComponent from \"./components/tooManyFilesComponent.svelte\";\n    import { onMount } from \"svelte\";\n\n    interface Props {\n        plugin: ObsidianGit;\n        view: GitView;\n    }\n\n    let { plugin, view }: Props = $props();\n    let loading: boolean = $state(false);\n    let status: Status | undefined = $state();\n    let lastPulledFiles: FileStatusResult[] = $state([]);\n    let commitMessage = $derived(plugin.settings.commitMessage);\n    let buttons: HTMLElement[] = $state([]);\n    let changeHierarchy: StatusRootTreeItem | undefined = $state();\n    let stagedHierarchy: StatusRootTreeItem | undefined = $state();\n    let lastPulledFilesHierarchy: StatusRootTreeItem | undefined = $state();\n    let changesOpen = $state(true);\n    let stagedOpen = $state(true);\n    let lastPulledFilesOpen = $state(true);\n    let unPushedCommits = $state(0);\n    let stagedClosed: Record<string, boolean> = $state({});\n    let unstagedClosed: Record<string, boolean> = $state({});\n    let pulledClosed: Record<string, boolean> = $state({});\n\n    let showTree = $derived(plugin.settings.treeStructure);\n    onMount(() => {\n        view.registerEvent(\n            view.app.workspace.on(\n                \"obsidian-git:loading-status\",\n                () => (loading = true)\n            )\n        );\n        view.registerEvent(\n            view.app.workspace.on(\n                \"obsidian-git:status-changed\",\n                () => void refresh().catch(console.error)\n            )\n        );\n        if (view.plugin.cachedStatus == undefined) {\n            view.plugin.refresh().catch(console.error);\n        } else {\n            refresh().catch(console.error);\n        }\n\n        view.scope = new Scope(plugin.app.scope);\n        view.scope.register([\"Ctrl\"], \"Enter\", (_: KeyboardEvent) =>\n            commitAndSync()\n        );\n    });\n    $effect(() => {\n        buttons.forEach((btn) => setIcon(btn, btn.getAttr(\"data-icon\")!));\n    });\n\n    $effect(() => {\n        // highlight push button if there are unpushed commits\n        buttons.forEach((btn) => {\n            // when reloading the view from settings change, the btn are null at first\n            if (!btn || btn.id != \"push\") return;\n            if (Platform.isMobile) {\n                btn.removeClass(\"button-border\");\n                if (unPushedCommits > 0) {\n                    btn.addClass(\"button-border\");\n                }\n            } else {\n                btn.firstElementChild?.removeAttribute(\"color\");\n                if (unPushedCommits > 0) {\n                    btn.firstElementChild?.setAttr(\n                        \"color\",\n                        \"var(--text-accent)\"\n                    );\n                }\n            }\n        });\n    });\n\n    function commit() {\n        loading = true;\n        if (status) {\n            const onlyStaged = status.staged.length > 0;\n            plugin.promiseQueue.addTask(() =>\n                plugin\n                    .commit({ fromAuto: false, commitMessage, onlyStaged })\n                    .then(() => (commitMessage = plugin.settings.commitMessage))\n                    .finally(triggerRefresh)\n            );\n        }\n    }\n\n    function commitAndSync() {\n        loading = true;\n        if (status) {\n            // If staged files exist only commit them, but if not, commit all.\n            // I hope this is the most intuitive way.\n            const onlyStaged = status.staged.length > 0;\n            plugin.promiseQueue.addTask(() =>\n                plugin\n                    .commitAndSync({\n                        fromAutoBackup: false,\n                        commitMessage,\n                        onlyStaged,\n                    })\n                    .then(() => {\n                        commitMessage = plugin.settings.commitMessage;\n                    })\n                    .finally(triggerRefresh)\n            );\n        }\n    }\n\n    async function refresh(): Promise<void> {\n        if (!plugin.gitReady) {\n            status = undefined;\n            return;\n        }\n        unPushedCommits = await plugin.gitManager.getUnpushedCommits();\n\n        status = plugin.cachedStatus;\n        loading = false;\n        if (\n            plugin.lastPulledFiles &&\n            plugin.lastPulledFiles != lastPulledFiles\n        ) {\n            lastPulledFiles = plugin.lastPulledFiles;\n\n            lastPulledFilesHierarchy = {\n                title: \"\",\n                path: \"\",\n                vaultPath: \"\",\n                children: plugin.gitManager.getTreeStructure(lastPulledFiles),\n            };\n        }\n        if (status) {\n            const sort = (a: FileStatusResult, b: FileStatusResult) => {\n                return a.vaultPath\n                    .split(\"/\")\n                    .last()!\n                    .localeCompare(getDisplayPath(b.vaultPath));\n            };\n            status.changed.sort(sort);\n            status.staged.sort(sort);\n            changeHierarchy = {\n                title: \"\",\n                path: \"\",\n                vaultPath: \"\",\n                children: plugin.gitManager.getTreeStructure(status.changed),\n            };\n            stagedHierarchy = {\n                title: \"\",\n                path: \"\",\n                vaultPath: \"\",\n                children: plugin.gitManager.getTreeStructure(status.staged),\n            };\n        } else {\n            changeHierarchy = undefined;\n            stagedHierarchy = undefined;\n        }\n    }\n\n    function triggerRefresh() {\n        view.app.workspace.trigger(\"obsidian-git:refresh\");\n    }\n\n    function stageAll(event: MouseEvent) {\n        event.stopPropagation();\n        loading = true;\n        plugin.promiseQueue.addTask(() =>\n            plugin.gitManager\n                .stageAll({ status: status })\n                .finally(triggerRefresh)\n        );\n    }\n\n    function unstageAll(event: MouseEvent) {\n        event.stopPropagation();\n        loading = true;\n        plugin.promiseQueue.addTask(() =>\n            plugin.gitManager\n                .unstageAll({ status: status })\n                .finally(triggerRefresh)\n        );\n    }\n\n    function push() {\n        loading = true;\n        plugin.promiseQueue.addTask(() =>\n            plugin.push().finally(triggerRefresh)\n        );\n    }\n    function pull() {\n        loading = true;\n        plugin.promiseQueue.addTask(() =>\n            plugin.pullChangesFromRemote().finally(triggerRefresh)\n        );\n    }\n    function discard(event: Event) {\n        event.stopPropagation();\n        void plugin.discardAll();\n    }\n\n    let rows = $derived((commitMessage.match(/\\n/g) || []).length + 1 || 1);\n</script>\n\n<!-- svelte-ignore a11y_click_events_have_key_events -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<main data-type={SOURCE_CONTROL_VIEW_CONFIG.type} class=\"git-view\">\n    <div class=\"nav-header\">\n        <div class=\"nav-buttons-container\">\n            <div\n                id=\"backup-btn\"\n                data-icon=\"arrow-up-circle\"\n                class=\"clickable-icon nav-action-button\"\n                aria-label=\"Commit-and-sync\"\n                bind:this={buttons[0]}\n                onclick={commitAndSync}\n            ></div>\n            <div\n                id=\"commit-btn\"\n                data-icon=\"check\"\n                class=\"clickable-icon nav-action-button\"\n                aria-label=\"Commit\"\n                bind:this={buttons[1]}\n                onclick={commit}\n            ></div>\n            <div\n                id=\"stage-all\"\n                class=\"clickable-icon nav-action-button\"\n                data-icon=\"plus-circle\"\n                aria-label=\"Stage all\"\n                bind:this={buttons[2]}\n                onclick={stageAll}\n            ></div>\n            <div\n                id=\"unstage-all\"\n                class=\"clickable-icon nav-action-button\"\n                data-icon=\"minus-circle\"\n                aria-label=\"Unstage all\"\n                bind:this={buttons[3]}\n                onclick={unstageAll}\n            ></div>\n            <div\n                id=\"push\"\n                class=\"clickable-icon nav-action-button\"\n                data-icon=\"upload\"\n                aria-label=\"Push\"\n                bind:this={buttons[4]}\n                onclick={push}\n            ></div>\n            <div\n                id=\"pull\"\n                class=\"clickable-icon nav-action-button\"\n                data-icon=\"download\"\n                aria-label=\"Pull\"\n                bind:this={buttons[5]}\n                onclick={pull}\n            ></div>\n            <div\n                id=\"layoutChange\"\n                class=\"clickable-icon nav-action-button\"\n                aria-label=\"Change Layout\"\n                data-icon={showTree ? \"list\" : \"folder\"}\n                bind:this={buttons[6]}\n                onclick={() => {\n                    showTree = !showTree;\n                    setIcon(buttons[6], showTree ? \"list\" : \"folder\");\n                    plugin.settings.treeStructure = showTree;\n                    void plugin.saveSettings();\n                }}\n            ></div>\n            <div\n                id=\"refresh\"\n                class=\"clickable-icon nav-action-button\"\n                class:loading\n                data-icon=\"refresh-cw\"\n                aria-label=\"Refresh\"\n                bind:this={buttons[7]}\n                onclick={triggerRefresh}\n            ></div>\n        </div>\n    </div>\n    <div class=\"git-commit-msg\">\n        <textarea\n            {rows}\n            class=\"commit-msg-input\"\n            spellcheck=\"true\"\n            placeholder=\"Commit Message\"\n            bind:value={commitMessage}\n        ></textarea>\n        {#if commitMessage}\n            <div\n                class=\"git-commit-msg-clear-button\"\n                onclick={() => (commitMessage = \"\")}\n                aria-label={\"Clear\"}\n            ></div>\n        {/if}\n    </div>\n\n    <div class=\"nav-files-container\" style=\"position: relative;\">\n        {#if status && stagedHierarchy && changeHierarchy}\n            <div class=\"tree-item nav-folder mod-root\">\n                <div\n                    class=\"staged tree-item nav-folder\"\n                    class:is-collapsed={!stagedOpen}\n                >\n                    <div\n                        class=\"tree-item-self is-clickable nav-folder-title\"\n                        onclick={() => (stagedOpen = !stagedOpen)}\n                    >\n                        <div\n                            class=\"tree-item-icon nav-folder-collapse-indicator collapse-icon\"\n                            class:is-collapsed={!stagedOpen}\n                        >\n                            <svg\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                                width=\"24\"\n                                height=\"24\"\n                                viewBox=\"0 0 24 24\"\n                                fill=\"none\"\n                                stroke=\"currentColor\"\n                                stroke-width=\"2\"\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                class=\"svg-icon right-triangle\"\n                                ><path d=\"M3 8L12 17L21 8\" /></svg\n                            >\n                        </div>\n                        <div class=\"tree-item-inner nav-folder-title-content\">\n                            Staged Changes\n                        </div>\n\n                        <div class=\"git-tools\">\n                            <div class=\"buttons\">\n                                <div\n                                    data-icon=\"minus\"\n                                    aria-label=\"Unstage\"\n                                    bind:this={buttons[8]}\n                                    onclick={unstageAll}\n                                    class=\"clickable-icon\"\n                                >\n                                    <svg\n                                        width=\"18\"\n                                        height=\"18\"\n                                        viewBox=\"0 0 18 18\"\n                                        fill=\"none\"\n                                        stroke=\"currentColor\"\n                                        stroke-width=\"2\"\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        class=\"svg-icon lucide-minus\"\n                                        ><line\n                                            x1=\"4\"\n                                            y1=\"9\"\n                                            x2=\"14\"\n                                            y2=\"9\"\n                                        /></svg\n                                    >\n                                </div>\n                            </div>\n                            <div class=\"files-count\">\n                                {status.staged.length}\n                            </div>\n                        </div>\n                    </div>\n                    {#if stagedOpen}\n                        <div\n                            class=\"tree-item-children nav-folder-children\"\n                            transition:slide|local={{ duration: 150 }}\n                        >\n                            {#if showTree}\n                                <TreeComponent\n                                    hierarchy={stagedHierarchy}\n                                    {plugin}\n                                    {view}\n                                    fileType={FileType.staged}\n                                    topLevel={true}\n                                    bind:closed={stagedClosed}\n                                />\n                            {:else}\n                                {#each arrayProxyWithNewLength(status.staged, 500) as stagedFile}\n                                    <StagedFileComponent\n                                        change={stagedFile}\n                                        {view}\n                                        manager={plugin.gitManager}\n                                    />\n                                {/each}\n                                <TooManyFilesComponent files={status.staged} />\n                            {/if}\n                        </div>\n                    {/if}\n                </div>\n                <div\n                    class=\"changes tree-item nav-folder\"\n                    class:is-collapsed={!changesOpen}\n                >\n                    <div\n                        onclick={() => (changesOpen = !changesOpen)}\n                        class=\"tree-item-self is-clickable nav-folder-title\"\n                    >\n                        <div\n                            class=\"tree-item-icon nav-folder-collapse-indicator collapse-icon\"\n                            class:is-collapsed={!changesOpen}\n                        >\n                            <svg\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                                width=\"24\"\n                                height=\"24\"\n                                viewBox=\"0 0 24 24\"\n                                fill=\"none\"\n                                stroke=\"currentColor\"\n                                stroke-width=\"2\"\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                class=\"svg-icon right-triangle\"\n                                ><path d=\"M3 8L12 17L21 8\" /></svg\n                            >\n                        </div>\n\n                        <div class=\"tree-item-inner nav-folder-title-content\">\n                            Changes\n                        </div>\n                        <div class=\"git-tools\">\n                            <div class=\"buttons\">\n                                <div\n                                    data-icon=\"undo\"\n                                    aria-label=\"Discard\"\n                                    onclick={discard}\n                                    class=\"clickable-icon\"\n                                >\n                                    <svg\n                                        xmlns=\"http://www.w3.org/2000/svg\"\n                                        width=\"24\"\n                                        height=\"24\"\n                                        viewBox=\"0 0 24 24\"\n                                        fill=\"none\"\n                                        stroke=\"currentColor\"\n                                        stroke-width=\"2\"\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        class=\"svg-icon lucide-undo\"\n                                        ><path d=\"M3 7v6h6\" /><path\n                                            d=\"M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13\"\n                                        /></svg\n                                    >\n                                </div>\n                                <div\n                                    data-icon=\"plus\"\n                                    aria-label=\"Stage\"\n                                    bind:this={buttons[9]}\n                                    onclick={stageAll}\n                                    class=\"clickable-icon\"\n                                >\n                                    <svg\n                                        xmlns=\"http://www.w3.org/2000/svg\"\n                                        width=\"24\"\n                                        height=\"24\"\n                                        viewBox=\"0 0 24 24\"\n                                        fill=\"none\"\n                                        stroke=\"currentColor\"\n                                        stroke-width=\"2\"\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        class=\"svg-icon lucide-plus\"\n                                        ><line\n                                            x1=\"12\"\n                                            y1=\"5\"\n                                            x2=\"12\"\n                                            y2=\"19\"\n                                        /><line\n                                            x1=\"5\"\n                                            y1=\"12\"\n                                            x2=\"19\"\n                                            y2=\"12\"\n                                        /></svg\n                                    >\n                                </div>\n                            </div>\n                            <div class=\"files-count\">\n                                {status.changed.length}\n                            </div>\n                        </div>\n                    </div>\n                    {#if changesOpen}\n                        <div\n                            class=\"tree-item-children nav-folder-children\"\n                            transition:slide|local={{ duration: 150 }}\n                        >\n                            {#if showTree}\n                                <TreeComponent\n                                    hierarchy={changeHierarchy}\n                                    {plugin}\n                                    {view}\n                                    fileType={FileType.changed}\n                                    topLevel={true}\n                                    bind:closed={unstagedClosed}\n                                />\n                            {:else}\n                                {#each arrayProxyWithNewLength(status.changed, 500) as change}\n                                    <FileComponent\n                                        {change}\n                                        {view}\n                                        manager={plugin.gitManager}\n                                    />\n                                {/each}\n                                <TooManyFilesComponent files={status.changed} />\n                            {/if}\n                        </div>\n                    {/if}\n                </div>\n                {#if lastPulledFiles.length > 0 && lastPulledFilesHierarchy}\n                    <div\n                        class=\"pulled nav-folder\"\n                        class:is-collapsed={!lastPulledFilesOpen}\n                    >\n                        <div\n                            class=\"tree-item-self is-clickable nav-folder-title\"\n                            onclick={() =>\n                                (lastPulledFilesOpen = !lastPulledFilesOpen)}\n                        >\n                            <div\n                                class=\"tree-item-icon nav-folder-collapse-indicator collapse-icon\"\n                            >\n                                <svg\n                                    xmlns=\"http://www.w3.org/2000/svg\"\n                                    width=\"24\"\n                                    height=\"24\"\n                                    viewBox=\"0 0 24 24\"\n                                    fill=\"none\"\n                                    stroke=\"currentColor\"\n                                    stroke-width=\"2\"\n                                    stroke-linecap=\"round\"\n                                    stroke-linejoin=\"round\"\n                                    class=\"svg-icon right-triangle\"\n                                    ><path d=\"M3 8L12 17L21 8\" /></svg\n                                >\n                            </div>\n\n                            <div\n                                class=\"tree-item-inner nav-folder-title-content\"\n                            >\n                                Recently Pulled Files\n                            </div>\n\n                            <span class=\"tree-item-flair\"\n                                >{lastPulledFiles.length}</span\n                            >\n                        </div>\n                        {#if lastPulledFilesOpen}\n                            <div\n                                class=\"tree-item-children nav-folder-children\"\n                                transition:slide|local={{ duration: 150 }}\n                            >\n                                {#if showTree}\n                                    <TreeComponent\n                                        hierarchy={lastPulledFilesHierarchy}\n                                        {plugin}\n                                        {view}\n                                        fileType={FileType.pulled}\n                                        topLevel={true}\n                                        bind:closed={pulledClosed}\n                                    />\n                                {:else}\n                                    {#each lastPulledFiles as change}\n                                        <PulledFileComponent {change} {view} />\n                                    {/each}\n                                    <TooManyFilesComponent\n                                        files={lastPulledFiles}\n                                    />\n                                {/if}\n                            </div>\n                        {/if}\n                    </div>\n                {/if}\n            </div>\n        {/if}\n    </div>\n</main>\n\n<style lang=\"scss\">\n    .commit-msg-input {\n        width: 100%;\n        overflow: hidden;\n        resize: none;\n        padding: 7px 5px;\n        background-color: var(--background-modifier-form-field);\n    }\n\n    .git-commit-msg {\n        position: relative;\n        padding: 0;\n        width: calc(100% - var(--size-4-8));\n        margin: 4px auto;\n    }\n    main {\n        .git-tools {\n            .files-count {\n                padding-left: var(--size-2-1);\n                width: 11px;\n                display: flex;\n                align-items: center;\n                justify-content: center;\n            }\n        }\n    }\n\n    .nav-folder-title {\n        align-items: center;\n    }\n\n    .git-commit-msg-clear-button {\n        position: absolute;\n        background: transparent;\n        border-radius: 50%;\n        color: var(--search-clear-button-color);\n        cursor: var(--cursor);\n        top: -4px;\n        right: 2px;\n        bottom: 0px;\n        line-height: 0;\n        height: var(--input-height);\n        width: 28px;\n        margin: auto;\n        padding: 0 0;\n        text-align: center;\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        transition: color 0.15s ease-in-out;\n    }\n\n    .git-commit-msg-clear-button:after {\n        content: \"\";\n        height: var(--search-clear-button-size);\n        width: var(--search-clear-button-size);\n        display: block;\n        background-color: currentColor;\n        mask-image: url(\"data:image/svg+xml,<svg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6 12C9.31371 12 12 9.31371 12 6C12 2.68629 9.31371 0 6 0C2.68629 0 0 2.68629 0 6C0 9.31371 2.68629 12 6 12ZM3.8705 3.09766L6.00003 5.22718L8.12955 3.09766L8.9024 3.8705L6.77287 6.00003L8.9024 8.12955L8.12955 8.9024L6.00003 6.77287L3.8705 8.9024L3.09766 8.12955L5.22718 6.00003L3.09766 3.8705L3.8705 3.09766Z' fill='currentColor'/></svg>\");\n        mask-repeat: no-repeat;\n        -webkit-mask-image: url(\"data:image/svg+xml,<svg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6 12C9.31371 12 12 9.31371 12 6C12 2.68629 9.31371 0 6 0C2.68629 0 0 2.68629 0 6C0 9.31371 2.68629 12 6 12ZM3.8705 3.09766L6.00003 5.22718L8.12955 3.09766L8.9024 3.8705L6.77287 6.00003L8.9024 8.12955L8.12955 8.9024L6.00003 6.77287L3.8705 8.9024L3.09766 8.12955L5.22718 6.00003L3.09766 3.8705L3.8705 3.09766Z' fill='currentColor'/></svg>\");\n        -webkit-mask-repeat: no-repeat;\n    }\n</style>\n"
  },
  {
    "path": "src/ui/sourceControl/sourceControl.ts",
    "content": "import type { HoverParent, HoverPopover, WorkspaceLeaf } from \"obsidian\";\nimport { ItemView } from \"obsidian\";\nimport { SOURCE_CONTROL_VIEW_CONFIG } from \"src/constants\";\nimport type ObsidianGit from \"src/main\";\nimport SourceControlViewComponent from \"./sourceControl.svelte\";\nimport { mount, unmount } from \"svelte\";\n\nexport default class GitView extends ItemView implements HoverParent {\n    plugin: ObsidianGit;\n    private _view: Record<string, unknown> | undefined;\n    hoverPopover: HoverPopover | null;\n\n    constructor(leaf: WorkspaceLeaf, plugin: ObsidianGit) {\n        super(leaf);\n        this.plugin = plugin;\n        this.hoverPopover = null;\n    }\n\n    getViewType(): string {\n        return SOURCE_CONTROL_VIEW_CONFIG.type;\n    }\n\n    getDisplayText(): string {\n        return SOURCE_CONTROL_VIEW_CONFIG.name;\n    }\n\n    getIcon(): string {\n        return SOURCE_CONTROL_VIEW_CONFIG.icon;\n    }\n\n    onClose(): Promise<void> {\n        if (this._view) {\n            // eslint-disable-next-line @typescript-eslint/no-floating-promises\n            unmount(this._view);\n        }\n        return super.onClose();\n    }\n\n    reload(): void {\n        if (this._view) {\n            // eslint-disable-next-line @typescript-eslint/no-floating-promises\n            unmount(this._view);\n        }\n        this._view = mount(SourceControlViewComponent, {\n            target: this.contentEl,\n            props: {\n                plugin: this.plugin,\n                view: this,\n            },\n        });\n    }\n\n    onOpen(): Promise<void> {\n        this.reload();\n        return super.onOpen();\n    }\n}\n"
  },
  {
    "path": "src/ui/statusBar/branchStatusBar.ts",
    "content": "import type ObsidianGit from \"src/main\";\n\nexport class BranchStatusBar {\n    constructor(\n        private statusBarEl: HTMLElement,\n        private readonly plugin: ObsidianGit\n    ) {\n        this.statusBarEl.addClass(\"mod-clickable\");\n        this.statusBarEl.onClickEvent((_) => {\n            this.plugin.switchBranch().catch((e) => plugin.displayError(e));\n        });\n    }\n\n    async display() {\n        if (this.plugin.gitReady) {\n            const branchInfo = await this.plugin.gitManager.branchInfo();\n            if (branchInfo.current != undefined) {\n                this.statusBarEl.setText(branchInfo.current);\n            } else {\n                this.statusBarEl.empty();\n            }\n        } else {\n            this.statusBarEl.empty();\n        }\n    }\n\n    remove() {\n        this.statusBarEl.remove();\n    }\n}\n"
  },
  {
    "path": "src/utils.ts",
    "content": "import * as cssColorConverter from \"css-color-converter\";\nimport { spawn, type SpawnOptionsWithoutStdio } from \"child_process\";\nimport deepEqual from \"deep-equal\";\nimport type { App, ItemView, RGB, WorkspaceLeaf } from \"obsidian\";\nimport { Keymap, Menu, moment, TFile } from \"obsidian\";\nimport { BINARY_EXTENSIONS } from \"./constants\";\n\nexport function assertNever(x: never): never {\n    // eslint-disable-next-line @typescript-eslint/restrict-template-expressions\n    throw new Error(`Unexpected object: ${x}`);\n}\n\nexport function plural(\n    count: number,\n    singular: string,\n    plural?: string\n): string {\n    if (count === 1) {\n        return `${count} ${singular}`;\n    } else {\n        return `${count} ${plural ?? singular + \"s\"}`;\n    }\n}\n\nexport const worthWalking = (filepath: string, root?: string) => {\n    if (filepath === \".\" || root == null || root.length === 0 || root === \".\") {\n        return true;\n    }\n    if (root.length >= filepath.length) {\n        return root.startsWith(filepath);\n    } else {\n        return filepath.startsWith(root);\n    }\n};\n\nexport function getNewLeaf(\n    app: App,\n    event?: MouseEvent\n): WorkspaceLeaf | undefined {\n    let leaf: WorkspaceLeaf | undefined;\n    if (event) {\n        if (event.button === 0 || event.button === 1) {\n            const type = Keymap.isModEvent(event);\n            leaf = app.workspace.getLeaf(type);\n        }\n    } else {\n        leaf = app.workspace.getLeaf(false);\n    }\n    return leaf;\n}\n\nexport function mayTriggerFileMenu(\n    app: App,\n    event: MouseEvent,\n    filePath: string,\n    view: WorkspaceLeaf,\n    source: string\n) {\n    if (event.button == 2) {\n        const file = app.vault.getAbstractFileByPath(filePath);\n        if (file != null) {\n            const fileMenu = new Menu();\n            app.workspace.trigger(\"file-menu\", fileMenu, file, source, view);\n            fileMenu.showAtPosition({ x: event.pageX, y: event.pageY });\n        } else {\n            const fileMenu = new Menu();\n            app.workspace.trigger(\n                \"obsidian-git:menu\",\n                fileMenu,\n                filePath,\n                source,\n                view\n            );\n            fileMenu.showAtPosition({ x: event.pageX, y: event.pageY });\n        }\n    }\n}\n\n/**\n * Creates a type-error, if this function is in a possible branch.\n *\n * Use this to ensure exhaustive switch cases.\n *\n * During runtime, an error will be thrown, if executed.\n */\nexport function impossibleBranch(x: never): never {\n    /* eslint-disable-next-line @typescript-eslint/restrict-plus-operands */\n    throw new Error(\"Impossible branch: \" + x);\n}\n\nexport function rgbToString(rgb: RGB): string {\n    return `rgb(${rgb.r},${rgb.g},${rgb.b})`;\n}\n\nexport function convertToRgb(str: string): RGB | undefined {\n    const color = cssColorConverter.fromString(str)?.toRgbaArray();\n    if (color === undefined) {\n        return undefined;\n    }\n    const [r, g, b] = color;\n    return { r, g, b };\n}\n\nexport function momentToEpochSeconds(instant: moment.Moment): number {\n    return instant.diff(moment.unix(0), \"seconds\");\n}\n\nexport function median(array: number[]): number | undefined {\n    if (array.length === 0) return undefined;\n    return array.slice().sort()[Math.floor(array.length / 2)];\n}\n\nexport function strictDeepEqual<T>(a: T, b: T): boolean {\n    return deepEqual(a, b, { strict: true });\n}\n\nexport function arrayProxyWithNewLength<T>(array: T[], length: number): T[] {\n    return new Proxy(array, {\n        get(target, prop) {\n            if (prop === \"length\") {\n                return Math.min(length, target.length);\n            }\n            return target[prop as keyof T[]];\n        },\n    });\n}\n\nexport function resizeToLength(\n    original: string,\n    desiredLength: number,\n    fillChar: string\n): string {\n    if (original.length <= desiredLength) {\n        const prefix = new Array(desiredLength - original.length)\n            .fill(fillChar)\n            .join(\"\");\n        return prefix + original;\n    } else {\n        return original.substring(original.length - desiredLength);\n    }\n}\n\nexport function prefixOfLengthAsWhitespace(\n    toBeRenderedText: string,\n    whitespacePrefixLength: number\n): string {\n    if (whitespacePrefixLength <= 0) return toBeRenderedText;\n\n    const whitespacePrefix = new Array(whitespacePrefixLength)\n        .fill(\" \")\n        .join(\"\");\n    const originalSuffix = toBeRenderedText.substring(\n        whitespacePrefixLength,\n        toBeRenderedText.length\n    );\n    return whitespacePrefix + originalSuffix;\n}\n\nexport function between(l: number, x: number, r: number) {\n    return l <= x && x <= r;\n}\nexport function splitRemoteBranch(\n    remoteBranch: string\n): readonly [string, string | undefined] {\n    const [remote, ...branch] = remoteBranch.split(\"/\");\n    return [remote, branch.length === 0 ? undefined : branch.join(\"/\")];\n}\n\nexport function getDisplayPath(path: string): string {\n    if (path.endsWith(\"/\")) return path;\n    return path.split(\"/\").last()!.replace(/\\.md$/, \"\");\n}\n\nexport function formatMinutes(minutes: number): string {\n    if (minutes === 1) return \"1 minute\";\n    return `${minutes} minutes`;\n}\n\nexport function getExtensionFromPath(path: string): string {\n    const dotIndex = path.lastIndexOf(\".\");\n    return path.substring(dotIndex + 1);\n}\n\n/**\n * Decides if a file is binary based on its extension.\n */\nexport function fileIsBinary(path: string): boolean {\n    // This is the case for the most files so we can save some time\n    if (path.endsWith(\".md\")) return false;\n\n    const ext = getExtensionFromPath(path);\n\n    return BINARY_EXTENSIONS.includes(ext);\n}\n\nexport function formatRemoteUrl(url: string): string {\n    if (\n        url.startsWith(\"https://github.com/\") ||\n        url.startsWith(\"https://gitlab.com/\")\n    ) {\n        if (!url.endsWith(\".git\")) {\n            url = url + \".git\";\n        }\n    }\n    return url;\n}\n\nexport function fileOpenableInObsidian(\n    relativeVaultPath: string,\n    app: App\n): boolean {\n    const file = app.vault.getAbstractFileByPath(relativeVaultPath);\n    if (!(file instanceof TFile)) {\n        return false;\n    }\n    try {\n        // Internal Obsidian API function\n        // If a view type is registired for the file extension, it can be opened in Obsidian.\n        // Just checking if Obsidian tracks the file is not enough,\n        // because it can also track files, it can only open externally.\n        return !!app.viewRegistry.getTypeByExtension(file.extension);\n    } catch {\n        // If the function doesn't exist anymore, it will throw an error. In that case, just skip the check.\n        return true;\n    }\n}\n\nexport function convertPathToAbsoluteGitignoreRule({\n    isFolder,\n    gitRelativePath,\n}: {\n    isFolder?: boolean;\n    gitRelativePath: string;\n}): string {\n    // Add a leading slash to set the rule as absolute from root, so it only excludes that exact path\n    let composedPath = \"/\";\n\n    composedPath += gitRelativePath;\n\n    // Add an explicit folder rule, so that the same path doesn't also apply for files with that same name\n    if (isFolder) {\n        composedPath += \"/\";\n    }\n\n    // Escape special characters, so that git treats them as literal characters.\n    const escaped = composedPath.replace(/([\\\\!#*?[\\]])/g, String.raw`\\$1`);\n\n    // Then escape each trailing whitespace character individually, because git trims trailing whitespace from the end of the rule.\n    // Files normally end with a file extension, not whitespace, but a file with trailing whitespace can appear if Obsidian's \"Detect all file extensions\" setting is turned on.\n    return escaped.replace(/\\s(?=\\s*$)/g, String.raw`\\ `);\n}\n\n/**\n * When hovering a link going to `to`, show the Obsidian hover-preview of that note.\n *\n * You probably have to hold down `Ctrl` when hovering the link for the preview to appear!\n * @param  {MouseEvent} event\n * @param  {YourView} view The view with the link being hovered\n * @param  {string} to The basename of the note to preview.\n * @template YourView The ViewType of your view\n * @returns void\n */\nexport function hoverPreview<YourView extends ItemView>(\n    app: App,\n    event: MouseEvent,\n    view: YourView,\n    to: string\n): void {\n    const targetEl = event.target as HTMLElement;\n\n    app.workspace.trigger(\"hover-link\", {\n        event,\n        source: view.getViewType(),\n        hoverParent: view,\n        targetEl,\n        linktext: to,\n    });\n}\n\nexport function spawnAsync(\n    command: string,\n    args: string[],\n    options: SpawnOptionsWithoutStdio = {}\n): Promise<{\n    stdout: string;\n    stderr: string;\n    code: number;\n    error: Error | undefined;\n}> {\n    return new Promise((resolve, _) => {\n        // Spawn the child process\n        const child = spawn(command, args, options);\n\n        let stdoutBuffer = \"\";\n        let stderrBuffer = \"\";\n\n        // Collect stdout data\n        child.stdout.on(\"data\", (data: Buffer) => {\n            stdoutBuffer += data.toString();\n        });\n\n        // Collect stderr data\n        child.stderr.on(\"data\", (data: Buffer) => {\n            stderrBuffer += data.toString();\n        });\n\n        // Handle process errors (e.g., command not found)\n        child.on(\"error\", (err) => {\n            resolve({\n                error: new Error(err.message),\n                stdout: stdoutBuffer,\n                stderr: stdoutBuffer,\n                code: 1,\n            });\n        });\n\n        // Handle process exit\n        child.on(\"close\", (code) => {\n            // Resolve the promise with collected data and exit code\n            resolve({\n                stdout: stdoutBuffer,\n                stderr: stderrBuffer,\n                code: code ?? 1,\n                error: undefined,\n            });\n        });\n    });\n}\n"
  },
  {
    "path": "styles.css",
    "content": "@keyframes loading {\n    0% {\n        transform: rotate(0deg);\n    }\n\n    100% {\n        transform: rotate(360deg);\n    }\n}\n\n.git-signs-gutter {\n    .cm-gutterElement {\n        /* Needed to align the sign properly for different line heigts. Such as\n         * when having a heading or list item.\n         */\n        padding-top: 0 !important;\n    }\n}\n\n.workspace-leaf-content[data-type=\"git-view\"] .button-border {\n    border: 2px solid var(--interactive-accent);\n    border-radius: var(--radius-s);\n}\n\n.workspace-leaf-content[data-type=\"git-view\"] .view-content {\n    padding-left: 0;\n    padding-top: 0;\n    padding-right: 0;\n}\n\n.workspace-leaf-content[data-type=\"git-history-view\"] .view-content {\n    padding-left: 0;\n    padding-top: 0;\n    padding-right: 0;\n}\n\n.loading {\n    overflow: hidden;\n}\n\n.loading > svg {\n    animation: 2s linear infinite loading;\n    transform-origin: 50% 50%;\n    display: inline-block;\n}\n\n.obsidian-git-center {\n    margin: auto;\n    text-align: center;\n    width: 50%;\n}\n\n.obsidian-git-textarea {\n    display: block;\n    margin-left: auto;\n    margin-right: auto;\n}\n\n.obsidian-git-disabled {\n    opacity: 0.5;\n}\n\n.obsidian-git-center-button {\n    display: block;\n    margin: 20px auto;\n}\n\n.tooltip.mod-left {\n    overflow-wrap: break-word;\n}\n\n.tooltip.mod-right {\n    overflow-wrap: break-word;\n}\n\n/* Limits the scrollbar to the view body */\n.git-view {\n    display: flex;\n    flex-direction: column;\n    position: relative;\n    height: 100%;\n}\n\n/* Re-enable wrapping of nav buttns to prevent overflow on smaller screens #*/\n.workspace-drawer .git-view .nav-buttons-container {\n    flex-wrap: wrap;\n}\n\n.git-tools {\n    display: flex;\n    margin-left: auto;\n}\n.git-tools .type {\n    padding-left: var(--size-2-1);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 11px;\n}\n\n.git-tools .type[data-type=\"M\"] {\n    color: orange;\n}\n.git-tools .type[data-type=\"D\"] {\n    color: red;\n}\n.git-tools .buttons {\n    display: flex;\n}\n.git-tools .buttons > * {\n    padding: 0 0;\n    height: auto;\n}\n\n.workspace-leaf-content[data-type=\"git-view\"] .tree-item-self,\n.workspace-leaf-content[data-type=\"git-history-view\"] .tree-item-self {\n    align-items: center;\n}\n\n.workspace-leaf-content[data-type=\"git-view\"]\n    .tree-item-self:hover\n    .clickable-icon,\n.workspace-leaf-content[data-type=\"git-history-view\"]\n    .tree-item-self:hover\n    .clickable-icon {\n    color: var(--icon-color-hover);\n}\n\n/* Highlight an item as active if it's diff is currently opened */\n.is-active .git-tools .buttons > * {\n    color: var(--nav-item-color-active);\n}\n\n.git-author {\n    color: var(--text-accent);\n}\n\n.git-date {\n    color: var(--text-accent);\n}\n\n.git-ref {\n    color: var(--text-accent);\n}\n\n/* ====== diff2html ======\nThe following styles are adapted from the obsidian-version-history plugin by\n@kometenstaub https://github.com/kometenstaub/obsidian-version-history-diff/blob/main/src/styles.scss\nwhich itself is adapted from the diff2html library with the following original license:\n\n   \thttps://github.com/rtfpessoa/diff2html/blob/master/LICENSE.md\n\n\tCopyright 2014-2016 Rodrigo Fernandes https://rtfpessoa.github.io/\n\n\tPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated\n\tdocumentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the\n\trights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit\n\tpersons to whom the Software is furnished to do so, subject to the following conditions:\n\n\tThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the\n\tSoftware.\n\n\tTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE\n\tWARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\n\tCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n\tOTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n*/\n\n.theme-dark,\n.theme-light {\n    --git-delete-bg: #ff475040;\n    --git-delete-hl: #96050a75;\n    --git-insert-bg: #68d36840;\n    --git-insert-hl: #23c02350;\n    --git-change-bg: #ffd55840;\n    --git-selected: #3572b0;\n\n    --git-delete: #c33;\n    --git-insert: #399839;\n    --git-change: #d0b44c;\n    --git-move: #3572b0;\n}\n\n.git-diff {\n    .d2h-d-none {\n        display: none;\n    }\n    .d2h-wrapper {\n        text-align: left;\n        border-radius: 0.25em;\n        overflow: auto;\n    }\n    .d2h-file-header.d2h-file-header {\n        background-color: var(--background-secondary);\n        border-bottom: 1px solid var(--background-modifier-border);\n        font-family:\n            Source Sans Pro,\n            Helvetica Neue,\n            Helvetica,\n            Arial,\n            sans-serif;\n        height: 35px;\n        padding: 5px 10px;\n    }\n    .d2h-file-header,\n    .d2h-file-stats {\n        display: -webkit-box;\n        display: -ms-flexbox;\n        display: flex;\n    }\n    .d2h-file-header {\n        display: none;\n    }\n    .d2h-file-stats {\n        font-size: 14px;\n        margin-left: auto;\n    }\n    .d2h-lines-added {\n        border: 1px solid var(--color-green);\n        border-radius: 5px 0 0 5px;\n        color: var(--color-green);\n        padding: 2px;\n        text-align: right;\n        vertical-align: middle;\n    }\n    .d2h-lines-deleted {\n        border: 1px solid var(--color-red);\n        border-radius: 0 5px 5px 0;\n        color: var(--color-red);\n        margin-left: 1px;\n        padding: 2px;\n        text-align: left;\n        vertical-align: middle;\n    }\n    .d2h-file-name-wrapper {\n        -webkit-box-align: center;\n        -ms-flex-align: center;\n        align-items: center;\n        display: -webkit-box;\n        display: -ms-flexbox;\n        display: flex;\n        font-size: 15px;\n        width: 100%;\n    }\n    .d2h-file-name {\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n        color: var(--text-normal);\n        font-size: var(--h5-size);\n    }\n    .d2h-file-wrapper {\n        border: 1px solid var(--background-secondary-alt);\n        border-radius: 3px;\n        margin-bottom: 1em;\n        max-height: 100%;\n    }\n    .d2h-file-collapse {\n        -webkit-box-pack: end;\n        -ms-flex-pack: end;\n        -webkit-box-align: center;\n        -ms-flex-align: center;\n        align-items: center;\n        border: 1px solid var(--background-secondary-alt);\n        border-radius: 3px;\n        cursor: pointer;\n        display: none;\n        font-size: 12px;\n        justify-content: flex-end;\n        padding: 4px 8px;\n    }\n    .d2h-file-collapse.d2h-selected {\n        background-color: var(--git-selected);\n    }\n    .d2h-file-collapse-input {\n        margin: 0 4px 0 0;\n    }\n    .d2h-diff-table {\n        border-collapse: collapse;\n        font-family: var(--font-monospace);\n        font-size: var(--code-size);\n        width: 100%;\n    }\n    .d2h-files-diff {\n        width: 100%;\n    }\n    .d2h-file-diff {\n        /*\n\t\toverflow-y: scroll;\n\t\t*/\n        border-radius: 5px;\n        font-size: var(--font-text-size);\n        line-height: var(--line-height-normal);\n    }\n    .d2h-file-side-diff {\n        display: inline-block;\n        margin-bottom: -8px;\n        margin-right: -4px;\n        overflow-x: scroll;\n        overflow-y: hidden;\n        width: 50%;\n    }\n    .d2h-code-line {\n        padding-left: 6em;\n        padding-right: 1.5em;\n    }\n    .d2h-code-line,\n    .d2h-code-side-line {\n        display: inline-block;\n        -webkit-user-select: none;\n        -moz-user-select: none;\n        -ms-user-select: none;\n        user-select: none;\n        white-space: nowrap;\n        width: 100%;\n    }\n    .d2h-code-side-line {\n        /* needed to be changed */\n        padding-left: 0.5em;\n        padding-right: 0.5em;\n    }\n    .d2h-code-line-ctn {\n        word-wrap: normal;\n        background: none;\n        display: inline-block;\n        padding: 0;\n        -webkit-user-select: text;\n        -moz-user-select: text;\n        -ms-user-select: text;\n        user-select: text;\n        vertical-align: middle;\n        width: 100%;\n        /* only works for line-by-line */\n        white-space: pre-wrap;\n    }\n    .d2h-code-line del,\n    .d2h-code-side-line del {\n        background-color: var(--git-delete-hl);\n        color: var(--text-normal);\n    }\n    .d2h-code-line del,\n    .d2h-code-line ins,\n    .d2h-code-side-line del,\n    .d2h-code-side-line ins {\n        border-radius: 0.2em;\n        display: inline-block;\n        margin-top: -1px;\n        text-decoration: none;\n        vertical-align: middle;\n    }\n    .d2h-code-line ins,\n    .d2h-code-side-line ins {\n        background-color: var(--git-insert-hl);\n        text-align: left;\n    }\n    .d2h-code-line-prefix {\n        word-wrap: normal;\n        background: none;\n        display: inline;\n        padding: 0;\n        white-space: pre;\n    }\n    .line-num1 {\n        float: left;\n    }\n    .line-num1,\n    .line-num2 {\n        -webkit-box-sizing: border-box;\n        box-sizing: border-box;\n        overflow: hidden;\n        /*\n\t\tpadding: 0 0.5em;\n\t\t*/\n        text-overflow: ellipsis;\n        width: 2.5em;\n        padding-left: 0;\n    }\n    .line-num2 {\n        float: right;\n    }\n    .d2h-code-linenumber {\n        background-color: var(--background-primary);\n        border: solid var(--background-modifier-border);\n        border-width: 0 1px;\n        -webkit-box-sizing: border-box;\n        box-sizing: border-box;\n        color: var(--text-faint);\n        cursor: pointer;\n        display: inline-block;\n        position: absolute;\n        text-align: right;\n        width: 5.5em;\n    }\n    .d2h-code-linenumber:after {\n        content: \"\\200b\";\n    }\n    .d2h-code-side-linenumber {\n        background-color: var(--background-primary);\n        border: solid var(--background-modifier-border);\n        border-width: 0 1px;\n        -webkit-box-sizing: border-box;\n        box-sizing: border-box;\n        color: var(--text-faint);\n        cursor: pointer;\n        overflow: hidden;\n        padding: 0 0.5em;\n        text-align: right;\n        text-overflow: ellipsis;\n        width: 4em;\n        /* needed to be changed */\n        display: table-cell;\n        position: relative;\n    }\n    .d2h-code-side-linenumber:after {\n        content: \"\\200b\";\n    }\n    .d2h-code-side-emptyplaceholder,\n    .d2h-emptyplaceholder {\n        background-color: var(--background-primary);\n        border-color: var(--background-modifier-border);\n    }\n    .d2h-code-line-prefix,\n    .d2h-code-linenumber,\n    .d2h-code-side-linenumber,\n    .d2h-emptyplaceholder {\n        -webkit-user-select: none;\n        -moz-user-select: none;\n        -ms-user-select: none;\n        user-select: none;\n    }\n    .d2h-code-linenumber,\n    .d2h-code-side-linenumber {\n        direction: rtl;\n    }\n    .d2h-del {\n        background-color: var(--git-delete-bg);\n        border-color: var(--git-delete-hl);\n    }\n    .d2h-ins {\n        background-color: var(--git-insert-bg);\n        border-color: var(--git-insert-hl);\n    }\n    .d2h-info {\n        background-color: var(--background-primary);\n        border-color: var(--background-modifier-border);\n        color: var(--text-faint);\n    }\n    .d2h-del,\n    .d2h-ins,\n    .d2h-file-diff .d2h-change {\n        color: var(--text-normal);\n    }\n    .d2h-file-diff .d2h-del.d2h-change {\n        background-color: var(--git-change-bg);\n    }\n    .d2h-file-diff .d2h-ins.d2h-change {\n        background-color: var(--git-insert-bg);\n    }\n    .d2h-file-list-wrapper {\n        a {\n            text-decoration: none;\n            cursor: default;\n            -webkit-user-drag: none;\n        }\n\n        svg {\n            display: none;\n        }\n    }\n    .d2h-file-list-header {\n        text-align: left;\n    }\n    .d2h-file-list-title {\n        display: none;\n    }\n    .d2h-file-list-line {\n        display: -webkit-box;\n        display: -ms-flexbox;\n        display: flex;\n        text-align: left;\n    }\n    .d2h-file-list {\n    }\n    .d2h-file-list > li {\n        border-bottom: 1px solid var(--background-modifier-border);\n        margin: 0;\n        padding: 5px 10px;\n    }\n    .d2h-file-list > li:last-child {\n        border-bottom: none;\n    }\n    .d2h-file-switch {\n        cursor: pointer;\n        display: none;\n        font-size: 10px;\n    }\n    .d2h-icon {\n        fill: currentColor;\n        margin-right: 10px;\n        vertical-align: middle;\n    }\n    .d2h-deleted {\n        color: var(--git-delete);\n    }\n    .d2h-added {\n        color: var(--git-insert);\n    }\n    .d2h-changed {\n        color: var(--git-change);\n    }\n    .d2h-moved {\n        color: var(--git-move);\n    }\n    .d2h-tag {\n        background-color: var(--background-secondary);\n        display: -webkit-box;\n        display: -ms-flexbox;\n        display: flex;\n        font-size: 10px;\n        margin-left: 5px;\n        padding: 0 2px;\n    }\n    .d2h-deleted-tag {\n        border: 1px solid var(--git-delete);\n    }\n    .d2h-added-tag {\n        border: 1px solid var(--git-insert);\n    }\n    .d2h-changed-tag {\n        border: 1px solid var(--git-change);\n    }\n    .d2h-moved-tag {\n        border: 1px solid var(--git-move);\n    }\n\n    /* needed for line-by-line*/\n\n    .d2h-diff-tbody {\n        position: relative;\n    }\n}\n\n/* ====================== Line Authoring Information ====================== */\n\n.cm-gutterElement.obs-git-blame-gutter {\n    /* Add background color to spacing inbetween and around the gutter for better aesthetics */\n    border-width: 0px 2px 0.2px 2px;\n    border-style: solid;\n    border-color: var(--background-secondary);\n    background-color: var(--background-secondary);\n}\n\n.cm-gutterElement.obs-git-blame-gutter > div,\n.line-author-settings-preview {\n    /* delegate text color to settings */\n    color: var(--obs-git-gutter-text);\n    font-family: monospace;\n    height: 100%; /* ensure, that age-based background color occupies entire parent */\n    text-align: right;\n    padding: 0px 6px 0px 6px;\n    white-space: pre; /* Keep spaces and do not collapse them. */\n}\n\n@media (max-width: 800px) {\n    /* hide git blame gutter not to superpose text */\n    .cm-gutterElement.obs-git-blame-gutter {\n        display: none;\n    }\n}\n\n.git-unified-diff-view,\n.git-split-diff-view .cm-deletedLine .cm-changedText {\n    background-color: #ee443330;\n}\n\n.git-unified-diff-view,\n.git-split-diff-view .cm-insertedLine .cm-changedText {\n    background-color: #22bb2230;\n}\n\n.git-obscure-prompt[git-is-obscured=\"true\"] #git-show-password:after {\n    -webkit-mask-image: url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"svg-icon lucide-eye\"><path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\"></path><circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg>');\n}\n\n.git-obscure-prompt[git-is-obscured=\"false\"] #git-show-password:after {\n    -webkit-mask-image: url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"svg-icon lucide-eye-off\"><path d=\"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49\"></path><path d=\"M14.084 14.158a3 3 0 0 1-4.242-4.242\"></path><path d=\"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143\"></path><path d=\"m2 2 20 20\"></path></svg>');\n}\n\n/* Override styling of Codemirror merge view \"collapsed lines\" indicator */\n.git-split-diff-view .ͼ2 .cm-collapsedLines {\n    background: var(--interactive-normal);\n    border-radius: var(--radius-m);\n    color: var(--text-accent);\n    font-size: var(--font-small);\n    padding: var(--size-4-1) var(--size-4-1);\n}\n.git-split-diff-view .ͼ2 .cm-collapsedLines:hover {\n    background: var(--interactive-hover);\n    color: var(--text-accent-hover);\n}\n\n.git-signs-gutter {\n    .cm-gutterElement {\n        display: grid;\n    }\n}\n\n.git-gutter-marker:hover {\n    border-radius: 2px;\n}\n\n.git-gutter-marker.git-add {\n    background-color: var(--color-green);\n    justify-self: center;\n    height: inherit;\n    width: 0.2rem;\n}\n\n.git-gutter-marker.git-change {\n    background-color: var(--color-yellow);\n    justify-self: center;\n    height: inherit;\n    width: 0.2rem;\n}\n\n.git-gutter-marker.git-changedelete {\n    color: var(--color-yellow);\n    font-weight: var(--font-bold);\n    font-size: 1rem;\n    justify-self: center;\n    height: inherit;\n}\n\n.git-gutter-marker.git-delete {\n    background-color: var(--color-red);\n    height: 0.2rem;\n    width: 0.8rem;\n    align-self: end;\n}\n\n.git-gutter-marker.git-topdelete {\n    background-color: var(--color-red);\n    height: 0.2rem;\n    width: 0.8rem;\n    align-self: start;\n}\n\ndiv:hover > .git-gutter-marker.git-change {\n    width: 0.6rem;\n}\n\ndiv:hover > .git-gutter-marker.git-add {\n    width: 0.6rem;\n}\n\ndiv:hover > .git-gutter-marker.git-delete {\n    height: 0.6rem;\n}\n\ndiv:hover > .git-gutter-marker.git-topdelete {\n    height: 0.6rem;\n}\n\ndiv:hover > .git-gutter-marker.git-changedelete {\n    font-weight: var(--font-bold);\n}\n\n.git-gutter-marker.staged {\n    opacity: 0.5;\n}\n\n.git-diff {\n    .cm-merge-revert {\n        width: 4em;\n    }\n    /* Ensure that merge revert markers are positioned correctly */\n    .cm-merge-revert > * {\n        position: absolute;\n        background-color: var(--background-secondary);\n        display: flex;\n    }\n}\n\n/* Prevent shifting of the editor when git signs gutter is the only gutter present */\n.cm-gutters.cm-gutters-before:has(> .git-signs-gutter:only-child) {\n    margin-inline-end: 0;\n    .git-signs-gutter {\n        margin-inline-start: -1rem;\n    }\n}\n\n.git-changes-status-bar-colored {\n    .git-add {\n        color: var(--color-green);\n    }\n    .git-change {\n        color: var(--color-yellow);\n    }\n    .git-delete {\n        color: var(--color-red);\n    }\n}\n\n.git-changes-status-bar .git-add {\n    margin-right: 0.3em;\n}\n\n.git-changes-status-bar .git-change {\n    margin-right: 0.3em;\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"baseUrl\": \".\",\n        \"inlineSourceMap\": true,\n        \"inlineSources\": true,\n        \"module\": \"ESNext\",\n        \"target\": \"ES6\",\n        \"allowJs\": true,\n        \"noImplicitAny\": true,\n        \"isolatedModules\": true,\n        \"moduleResolution\": \"node\",\n        \"strictNullChecks\": true,\n        \"importHelpers\": true,\n        \"allowSyntheticDefaultImports\": true,\n        \"verbatimModuleSyntax\": true,\n        \"lib\": [\n            \"DOM\",\n            \"ES5\",\n            \"ES6\",\n            \"ES7\"\n        ],\n        \"skipLibCheck\": true,\n        \"noUnusedLocals\": false\n    },\n    \"include\": [\n        \"**/*.ts\",\n        \"eslint.config.mjs\",\n        \"**/*.svelte\"\n    ]\n}\n"
  }
]