Repository: Vinzent03/obsidian-git Branch: master Commit: 2b95ce763984 Files: 90 Total size: 705.0 KB Directory structure: gitextract_b4m8u31b/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.yml │ └── workflows/ │ ├── releases.yml │ └── test.yml ├── .gitignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs/ │ ├── .gitignore │ ├── Authentication.md │ ├── Common issues.md │ ├── Features.md │ ├── Getting Started.md │ ├── Installation.md │ ├── Integration with other tools.md │ ├── Line Authoring.md │ ├── Start here.md │ ├── Tips-and-Tricks.md │ └── dev/ │ └── LineAuthorFeature.md ├── esbuild.config.mjs ├── eslint.config.mjs ├── manifest.json ├── package.json ├── polyfill_buffer.js ├── src/ │ ├── automaticsManager.ts │ ├── commands.ts │ ├── constants.ts │ ├── editor/ │ │ ├── control.ts │ │ ├── editorIntegration.ts │ │ ├── eventsPerFilepath.ts │ │ ├── lineAuthor/ │ │ │ ├── lineAuthorIntegration.ts │ │ │ ├── lineAuthorProvider.ts │ │ │ ├── model.ts │ │ │ └── view/ │ │ │ ├── cache.ts │ │ │ ├── contextMenu.ts │ │ │ ├── gutter/ │ │ │ │ ├── coloring.ts │ │ │ │ ├── commitChoice.ts │ │ │ │ ├── gutter.ts │ │ │ │ ├── gutterElementSearch.ts │ │ │ │ ├── initial.ts │ │ │ │ └── untrackedFile.ts │ │ │ └── view.ts │ │ └── signs/ │ │ ├── changesStatusBar.ts │ │ ├── diff.ts │ │ ├── gutter.ts │ │ ├── hunkActions.ts │ │ ├── hunkState.ts │ │ ├── hunks.ts │ │ ├── signsIntegration.ts │ │ ├── signsProvider.ts │ │ └── tooltip.ts │ ├── externalLibTypes.d.ts │ ├── gitManager/ │ │ ├── gitManager.ts │ │ ├── isomorphicGit.ts │ │ ├── myAdapter.ts │ │ └── simpleGit.ts │ ├── main.ts │ ├── openInGitHub.ts │ ├── pluginGlobalRef.ts │ ├── promiseQueue.ts │ ├── setting/ │ │ ├── localStorageSettings.ts │ │ └── settings.ts │ ├── statusBar.ts │ ├── tools.ts │ ├── types.ts │ ├── ui/ │ │ ├── diff/ │ │ │ ├── diffView.ts │ │ │ └── splitDiffView.ts │ │ ├── history/ │ │ │ ├── components/ │ │ │ │ ├── logComponent.svelte │ │ │ │ ├── logFileComponent.svelte │ │ │ │ └── logTreeComponent.svelte │ │ │ ├── historyView.svelte │ │ │ └── historyView.ts │ │ ├── modals/ │ │ │ ├── branchModal.ts │ │ │ ├── changedFilesModal.ts │ │ │ ├── customMessageModal.ts │ │ │ ├── discardModal.ts │ │ │ ├── generalModal.ts │ │ │ └── ignoreModal.ts │ │ ├── sourceControl/ │ │ │ ├── components/ │ │ │ │ ├── fileComponent.svelte │ │ │ │ ├── pulledFileComponent.svelte │ │ │ │ ├── stagedFileComponent.svelte │ │ │ │ ├── tooManyFilesComponent.svelte │ │ │ │ └── treeComponent.svelte │ │ │ ├── sourceControl.svelte │ │ │ └── sourceControl.ts │ │ └── statusBar/ │ │ └── branchStatusBar.ts │ └── utils.ts ├── styles.css └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # top-most EditorConfig file root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 tab_width = 4 ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: File a bug report title: "[Bug]: " labels: "bug" body: - type: markdown attributes: value: "**Please make sure you are on the latest version.**" - type: textarea id: what-happened attributes: label: Describe the bug placeholder: The following error occurs when running command X when I have X enabled. validations: required: true - type: textarea id: logs attributes: label: Relevant errors (if available) from notifications or console (`CTRL+SHIFT+I`) description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shell - type: textarea id: reproduce attributes: label: Steps to reproduce validations: required: true - type: textarea id: expected attributes: label: Expected Behavior description: A clear and concise description of what you expected to happen. - type: textarea id: context attributes: label: Addition context description: Add any other context about the problem here. - type: dropdown id: os attributes: label: Operating system description: Which OS are you using? options: - Windows - Linux - macOS - Android - iOS validations: required: true - type: dropdown id: installation-method attributes: label: Installation Method description: Only necessary on Linux options: - Flatpak - AppImage - Snap - Other validations: required: false - type: input id: version attributes: label: Plugin version validations: required: true ================================================ FILE: .github/workflows/releases.yml ================================================ name: Build obsidian plugin on: push: tags: - "*" env: PLUGIN_NAME: obsidian-git permissions: contents: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: latest - name: Build id: build run: | pnpm install pnpm run build --if-present mkdir ${{ env.PLUGIN_NAME }} cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} - name: Create Release id: create_release uses: softprops/action-gh-release@v1 with: tag_name: ${{ github.ref }} name: ${{ github.ref_name }} generate_release_notes: true draft: false prerelease: false - name: Upload zip file id: upload-zip uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./${{ env.PLUGIN_NAME }}.zip asset_name: ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip asset_content_type: application/zip - name: Upload main.js id: upload-main uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./main.js asset_name: main.js asset_content_type: text/javascript - name: Upload manifest.json id: upload-manifest uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./manifest.json asset_name: manifest.json asset_content_type: application/json - name: Upload styles.css id: upload-css uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./styles.css asset_name: styles.css asset_content_type: text/css ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: push: pull_request: jobs: svelte-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: latest - name: Install modules run: pnpm install - name: Run Svelte-Check run: pnpm run svelte lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: latest - name: Install modules run: pnpm install - name: Run ESLint run: pnpm run lint format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: latest - name: Install modules run: pnpm install - name: Run Prettier run: pnpm run format compile: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: latest - name: Install modules run: pnpm install - name: Run tsc run: tsc --noEmit build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: latest - name: Install modules run: pnpm install - name: Run build run: pnpm run build ================================================ FILE: .gitignore ================================================ # Intellij *.iml .idea # npm node_modules main.js yarn.lock # build *.js.map .prettierignore /data.json .vscode .DS_Store ================================================ FILE: .prettierrc.json ================================================ { "trailingComma": "es5", "tabWidth": 4, "semi": true, "plugins": ["prettier-plugin-svelte"], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. ## [2.38.0](https://github.com/Vinzent03/obsidian-git/compare/2.37.1...2.38.0) (2026-03-04) ### Features * 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)) ### Bug Fixes * 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) * 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) * 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) ### [2.37.1](https://github.com/Vinzent03/obsidian-git/compare/2.37.0...2.37.1) (2026-02-15) ## [2.37.0](https://github.com/Vinzent03/obsidian-git/compare/2.36.1...2.37.0) (2026-02-13) ### Features * add settings pane icon ([5ea08b8](https://github.com/Vinzent03/obsidian-git/commit/5ea08b874e2bfdc532e8e351921a4a3d97ff41bb)) * allow empty default manual commit message ([#1022](https://github.com/Vinzent03/obsidian-git/issues/1022)) ([20c942e](https://github.com/Vinzent03/obsidian-git/commit/20c942e4780e9ed272c92824f89b703dd59f53db)) * 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) * respect push.autoSetupRemote config ([bde2b3d](https://github.com/Vinzent03/obsidian-git/commit/bde2b3df909e4557e15591d6def20a80e15cec40)), closes [#1020](https://github.com/Vinzent03/obsidian-git/issues/1020) * 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) * show commit summary as tooltip in line author information ([8746e4a](https://github.com/Vinzent03/obsidian-git/commit/8746e4a4f9591a0f83c42d71b1cdb44911364f80)) ### Bug Fixes * some ssh askpass fixes ([b89d095](https://github.com/Vinzent03/obsidian-git/commit/b89d095a2b8a2b6ef943772c0d54602be74e6229)), closes [#994](https://github.com/Vinzent03/obsidian-git/issues/994) ### [2.36.1](https://github.com/Vinzent03/obsidian-git/compare/2.36.0...2.36.1) (2026-01-11) ### Bug Fixes * 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) * align signs properly with different line heights ([5bdf09c](https://github.com/Vinzent03/obsidian-git/commit/5bdf09c464d25e6dfb8b685f64c2a3c5af901f79)) * less gutter updates for same git result ([4da4c64](https://github.com/Vinzent03/obsidian-git/commit/4da4c647feded146f2e5896e20b50b2319415d0d)) * 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)) ## [2.36.0](https://github.com/Vinzent03/obsidian-git/compare/2.35.2...2.36.0) (2026-01-04) ### Features * buttons for hunk actions and display on click ([a3dcd02](https://github.com/Vinzent03/obsidian-git/commit/a3dcd02fba27afc895968af6df6e752c5f06842f)) * command to preview hunk ([7f26cfe](https://github.com/Vinzent03/obsidian-git/commit/7f26cfe131b6ec47c2c361d510855a19a412bcaf)) * display hunk changes inline ([f346cac](https://github.com/Vinzent03/obsidian-git/commit/f346cac94199b0a97fda8c8d7908765c075d6109)) * go to prev/next hunk commands ([48d6658](https://github.com/Vinzent03/obsidian-git/commit/48d66580564fd4105291cdae56180d7e005a080a)) * granular settings and change status bar ([29bd5ae](https://github.com/Vinzent03/obsidian-git/commit/29bd5ae0ff916b33b43bff028340ee476ad7b81c)) * hunk actions ([1c32ceb](https://github.com/Vinzent03/obsidian-git/commit/1c32ceb038f112c674ed102dc78bf36a2c7018cf)) * specify merge strategy in settings ([#934](https://github.com/Vinzent03/obsidian-git/issues/934)) ([d64e4d7](https://github.com/Vinzent03/obsidian-git/commit/d64e4d7dfa57a7f5fb2073f9ebaaa2643acefdf8)) * stage hunk ([5f0a5a2](https://github.com/Vinzent03/obsidian-git/commit/5f0a5a2344b2d390e0da300f50ed88d0221ec1f6)) * stage individual hunks from split diff view ([47e97c9](https://github.com/Vinzent03/obsidian-git/commit/47e97c9911655227de401d4da8eccbc160040f95)) ### Bug Fixes * 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) * disable staged hunks as their computation is not feasible ([5ff0fed](https://github.com/Vinzent03/obsidian-git/commit/5ff0fede7f15d4ae3e7e9e0b2c445ec1cced0d59)) * don't discard ignored files ([5483881](https://github.com/Vinzent03/obsidian-git/commit/54838815eae722a386de17fff71a1434bc6d9f84)), closes [#1006](https://github.com/Vinzent03/obsidian-git/issues/1006) * don't remove dom elements in line authoring ([4b29d76](https://github.com/Vinzent03/obsidian-git/commit/4b29d76df035be329c55f5718a0542a85bf2f941)) * 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) * fallback to debounced diff for slow diffs ([cfac162](https://github.com/Vinzent03/obsidian-git/commit/cfac162d52cddc5b3435ae95277ea1a7cce8ba9c)) * make diff buttons horizontal ([5d2627c](https://github.com/Vinzent03/obsidian-git/commit/5d2627c3b31ec233402c5cf58f3e86a292ee19a1)) * only show non 0 numbers in change status bar ([9ee0c06](https://github.com/Vinzent03/obsidian-git/commit/9ee0c06c0d5595f89fdc371942a1f60692d87403)) * properly disable signs via settings ([bda731b](https://github.com/Vinzent03/obsidian-git/commit/bda731b15f36f69219e54943be1caace6b5de47a)) * properly manage extensions ([067144c](https://github.com/Vinzent03/obsidian-git/commit/067144cd3c6fddd88eb3c478de7baf6a46efb459)) * properly show tooltip ([f5a48af](https://github.com/Vinzent03/obsidian-git/commit/f5a48afde5e6ce3a971f70a2b3866313045b3bd8)) * refresh cached file index version every 10 seconds ([4ba53a4](https://github.com/Vinzent03/obsidian-git/commit/4ba53a428bc61285f1bec37112f2996f234dcc12)) * reset changing an empty line ([9549bde](https://github.com/Vinzent03/obsidian-git/commit/9549bde6bd58f8186a558c5157a8f5ad3a9e4737)) * reset new empty line ([8cf494e](https://github.com/Vinzent03/obsidian-git/commit/8cf494e7375b62c4ec3e4cdefc8310c53dca0e19)) * select hunks and unstage no_nl_at_eof ([67ea27e](https://github.com/Vinzent03/obsidian-git/commit/67ea27e6925c901d6963ca27e7bf7a98a8bd29ed)) * show signs for new hunks on refresh ([491784d](https://github.com/Vinzent03/obsidian-git/commit/491784d381528c4f168da2399d7327c37e55479f)) * show tooltip for topdelete ([adae383](https://github.com/Vinzent03/obsidian-git/commit/adae383b0d661389d1e357a3e435d40f7025b939)) * small fixes ([b90eeb8](https://github.com/Vinzent03/obsidian-git/commit/b90eeb8229620f2d613b6816212709ce1b87a199)) * update settings description ([5e13e49](https://github.com/Vinzent03/obsidian-git/commit/5e13e492118c36beca22b4f138d7ec481a95f3ba)) * update styling ([ebd9734](https://github.com/Vinzent03/obsidian-git/commit/ebd973471ddfa7e267ad85c442a89f9ef2dd1eb1)) * use rem instead of px for dimensions ([60e11b7](https://github.com/Vinzent03/obsidian-git/commit/60e11b77ca2a49281d9c79ce45d7a858dfa7a905)) ### [2.35.2](https://github.com/Vinzent03/obsidian-git/compare/2.35.1...2.35.2) (2025-11-05) ### Bug Fixes * catch aborted clone depth modal ([d11579c](https://github.com/Vinzent03/obsidian-git/commit/d11579c96932f0b2c16c7306c5a05cfbae2509b1)) * 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) * 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) * 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) * trim git object result ([0f3d368](https://github.com/Vinzent03/obsidian-git/commit/0f3d368fea440f4a703ea8db21798c2af6d64557)) ### [2.35.1](https://github.com/Vinzent03/obsidian-git/compare/2.35.0...2.35.1) (2025-09-19) ### Bug Fixes * correctly abort selection when no branch selected ([23c009a](https://github.com/Vinzent03/obsidian-git/commit/23c009a2e9d23f0be6c882b2b992248e24bcf2da)) * don't collapse changed files on stage and add bottom padding ([3832059](https://github.com/Vinzent03/obsidian-git/commit/38320597ec712804eecc04dfcd64ce774963c303)) * get last commit time on branch without commits ([73300a1](https://github.com/Vinzent03/obsidian-git/commit/73300a1bf7c6333ea389a6792f73ea412b7f751d)) * 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)) * refresh view when staging files from menu ([1bd92c3](https://github.com/Vinzent03/obsidian-git/commit/1bd92c3961ac311312ff20e9fa2e3067a95c0434)) ## [2.35.0](https://github.com/Vinzent03/obsidian-git/compare/2.34.0...2.35.0) (2025-08-07) ### Features * add hidden commit staged command ([234bf8f](https://github.com/Vinzent03/obsidian-git/commit/234bf8f97e767b2523c74dc336a2c690b9d231b9)), closes [#932](https://github.com/Vinzent03/obsidian-git/issues/932) * auto commit only staged files ([e64ef60](https://github.com/Vinzent03/obsidian-git/commit/e64ef60406c52729f5105887f27e9db10a9e4cb2)), closes [#927](https://github.com/Vinzent03/obsidian-git/issues/927) * pause automatics via command ([f6f650e](https://github.com/Vinzent03/obsidian-git/commit/f6f650e4e85337f8e1613255899f191c12989f38)) ### Bug Fixes * catch not existing tracking branch ([5833ed3](https://github.com/Vinzent03/obsidian-git/commit/5833ed39c4c14e6c88e58713368c6a558a71425a)) * 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) * discard files correctly ([1c4e0c3](https://github.com/Vinzent03/obsidian-git/commit/1c4e0c3e37fbc466d33a2f0e8089a65f4de8b103)) * don't include all files in status on mobile ([8410e32](https://github.com/Vinzent03/obsidian-git/commit/8410e3219ae68b7fd9bea39274e0d81155a560f3)) * 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) * properly close toggle tree items ([69fdf79](https://github.com/Vinzent03/obsidian-git/commit/69fdf79c5b45a114043996b5fafd4839728f32e6)), closes [#950](https://github.com/Vinzent03/obsidian-git/issues/950) * properly color hovered icon buttons ([197a4c7](https://github.com/Vinzent03/obsidian-git/commit/197a4c7f82121b75e47ab61cae10ca0fb59e1587)) * use correct remote for push on mobile ([7e54350](https://github.com/Vinzent03/obsidian-git/commit/7e5435000116eaefc16826e6747cb5004675efa1)) ## [2.34.0](https://github.com/Vinzent03/obsidian-git/compare/2.33.0...2.34.0) (2025-06-14) ### Features * commit message from script ([c05f847](https://github.com/Vinzent03/obsidian-git/commit/c05f847baac1cfa121755bd6a10cfc5316a8f680)), closes [#849](https://github.com/Vinzent03/obsidian-git/issues/849) * 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) ### Bug Fixes * don't use spawnSync, but a non-blocking alternative ([ad03171](https://github.com/Vinzent03/obsidian-git/commit/ad03171300604b968340ca9212790da01b6ba1fd)) ## [2.33.0](https://github.com/Vinzent03/obsidian-git/compare/2.32.1...2.33.0) (2025-04-29) ### Features * 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) * 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)) * 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) ### Bug Fixes * 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) * 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)) * duplicated status bar ([f653019](https://github.com/Vinzent03/obsidian-git/commit/f65301946ee7d3160f5b6b00ca2f0aec8e0ef6ed)), closes [#892](https://github.com/Vinzent03/obsidian-git/issues/892) * 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)) * improve settings ui ([#886](https://github.com/Vinzent03/obsidian-git/issues/886)) ([edbbfb6](https://github.com/Vinzent03/obsidian-git/commit/edbbfb610462a3dd85436b4d97e74adfc4bcee7d)) * 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)) * 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)) * 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)) * 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) ### [2.32.1](https://github.com/Vinzent03/obsidian-git/compare/2.32.0...2.32.1) (2025-03-20) ### Bug Fixes * 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) * don't reset auto commit-and-sync timer while still running ([bd5b942](https://github.com/Vinzent03/obsidian-git/commit/bd5b9423eb6f90dce2e078cffa7591a4634bfc87)) * properly hide md extension ([6494ccf](https://github.com/Vinzent03/obsidian-git/commit/6494ccf0375d3790202e803864d2f6cbd46d1269)), closes [#873](https://github.com/Vinzent03/obsidian-git/issues/873) * **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) ## [2.32.0](https://github.com/Vinzent03/obsidian-git/compare/2.31.1...2.32.0) (2025-03-05) ### Features * 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) * 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) ### Bug Fixes * 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) * 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) * 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) ### [2.31.1](https://github.com/Vinzent03/obsidian-git/compare/2.31.0...2.31.1) (2025-01-04) ### Bug Fixes * 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)) * 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) * 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) ## [2.31.0](https://github.com/denolehov/obsidian-git/compare/2.30.1...2.31.0) (2024-12-31) ### Features * run raw git commands ([d05b99c](https://github.com/denolehov/obsidian-git/commit/d05b99ca827b630e1c96fdb2a4dc19469b0b9b81)) * split diff view ([8c8551f](https://github.com/denolehov/obsidian-git/commit/8c8551f05a2cdbc3f389097e870718ecaf04ff0a)) ### Bug Fixes * 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) * important fixes for diff view ([e5509f9](https://github.com/denolehov/obsidian-git/commit/e5509f981a51638c910f5cc960737b25d77a36ab)) * reset commit message to template ([16f0bd9](https://github.com/denolehov/obsidian-git/commit/16f0bd9fcf7a70687b529476bb4d9d5929375dbf)), closes [#828](https://github.com/denolehov/obsidian-git/issues/828) ### [2.30.1](https://github.com/denolehov/obsidian-git/compare/2.30.0...2.30.1) (2024-11-28) ### Bug Fixes * use custom git path ([5346fc8](https://github.com/denolehov/obsidian-git/commit/5346fc8995d11ef46de3277f724bca30aa208a49)), closes [#820](https://github.com/denolehov/obsidian-git/issues/820) ## [2.30.0](https://github.com/denolehov/obsidian-git/compare/2.29.0...2.30.0) (2024-11-25) ### Features * 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) ### Bug Fixes * set SSH_AKSPASS instead of GIT_ASKPASS and better error handling ([044cd6a](https://github.com/denolehov/obsidian-git/commit/044cd6a7ace2e2f18f146b4439344190943342ae)) ## [2.29.0](https://github.com/denolehov/obsidian-git/compare/2.28.2...2.29.0) (2024-11-24) ### Features * provide a GIT_ASKPASS handler via Obsidian modal ([1a788b0](https://github.com/denolehov/obsidian-git/commit/1a788b01af7cab7ad6d5a850f68a905ec680a2d2)) ### Bug Fixes * Clarification which information was not set ([#802](https://github.com/denolehov/obsidian-git/issues/802)) ([45ba119](https://github.com/denolehov/obsidian-git/commit/45ba119fff28547772c59856f2abf8700e544149)) * create new upstream branch ([c5a95c1](https://github.com/denolehov/obsidian-git/commit/c5a95c1ac9d9a0788d3ae053e83a11dbc30e590d)), closes [#808](https://github.com/denolehov/obsidian-git/issues/808) * get unset config values ([e229c22](https://github.com/denolehov/obsidian-git/commit/e229c227acd21d0a1674218b2a778726c3849908)) * Make the spelling of .gitignore consistent ([#805](https://github.com/denolehov/obsidian-git/issues/805)) ([8366927](https://github.com/denolehov/obsidian-git/commit/836692735d1cadb0023cd503573a58a14c76299e)) * properly disable auto pull ([f8464e6](https://github.com/denolehov/obsidian-git/commit/f8464e665850c0eac301a881b40b0c74809d4478)), closes [#816](https://github.com/denolehov/obsidian-git/issues/816) ### [2.28.2](https://github.com/denolehov/obsidian-git/compare/2.28.1...2.28.2) (2024-10-30) ### Bug Fixes * duplicated history view ([ec0668d](https://github.com/denolehov/obsidian-git/commit/ec0668d6a69e9907aa8174976e3030f8c289be90)), closes [#800](https://github.com/denolehov/obsidian-git/issues/800) ### [2.28.1](https://github.com/denolehov/obsidian-git/compare/2.28.0...2.28.1) (2024-10-27) ### Bug Fixes * open general modal ([2a0bbd1](https://github.com/denolehov/obsidian-git/commit/2a0bbd13aaff41bf08386133441b4681983538a2)), closes [#798](https://github.com/denolehov/obsidian-git/issues/798) ## [2.28.0](https://github.com/denolehov/obsidian-git/compare/2.27.0...2.28.0) (2024-10-26) ### Features * 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) * reload settings on file change ([d453c6f](https://github.com/denolehov/obsidian-git/commit/d453c6fe85143b6afcd672e3fd65ef7851dd9ae3)), closes [#779](https://github.com/denolehov/obsidian-git/issues/779) ### Bug Fixes * 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) * refresh status after opening source control view ([666b8a8](https://github.com/denolehov/obsidian-git/commit/666b8a8f263ce49a3d02dbfb040aa774cce95db2)) * rework errors ([ee3eff4](https://github.com/denolehov/obsidian-git/commit/ee3eff46e0d98999e6ab312971392adc008d4f6e)) * strip files list after 500 entries in source control view ([fe1aedb](https://github.com/denolehov/obsidian-git/commit/fe1aedb0a12d4e0b5d4a81821a8d08c1417dd6b8)) * typo in settings ([1e6c3dd](https://github.com/denolehov/obsidian-git/commit/1e6c3dddae7f5cd3b8393f36817fba94ed3cd12d)) ## [2.27.0](https://github.com/denolehov/obsidian-git/compare/2.26.0...2.27.0) (2024-09-18) ### Features * rename backup to commit-and-sync and better settings page ([cd9ffc2](https://github.com/denolehov/obsidian-git/commit/cd9ffc2ebe964dc59c8ce5d114f444222eb1d068)) ### Bug Fixes * discard deleted files ([42bf536](https://github.com/denolehov/obsidian-git/commit/42bf536b7973ef35a251a88fa61127b9a3b9972d)) * discard not tracked directory ([183929b](https://github.com/denolehov/obsidian-git/commit/183929b0cf3da47670faf930d3b6700df65db5c4)) * don't refresh views if git client is not ready ([7887c7f](https://github.com/denolehov/obsidian-git/commit/7887c7f4518fd2f1f83810ad3fc13cb53af4fe19)) * refresh data on view loading ([73d2c29](https://github.com/denolehov/obsidian-git/commit/73d2c299298d3b11dd5a0e976b1187d46d17041e)) * show better diff view for non existing file ([07d9fce](https://github.com/denolehov/obsidian-git/commit/07d9fce47306a3315128327bec6e50ac351d9bff)) * 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) ## [2.26.0](https://github.com/denolehov/obsidian-git/compare/2.25.0...2.26.0) (2024-09-01) ### Features * open source control view with ribbon button ([dea4d6f](https://github.com/denolehov/obsidian-git/commit/dea4d6f915492ecc3d43b259fbe8764b9c6210a4)) ### Bug Fixes * open diffs in new split with middle click ([65ef5ba](https://github.com/denolehov/obsidian-git/commit/65ef5ba2fa783717baaea24d05c6dcc8e760596d)) * remove root folding line in git views ([b2df0ed](https://github.com/denolehov/obsidian-git/commit/b2df0ed27b2af948df06dcc45021005b8c54e363)) ## [2.25.0](https://github.com/denolehov/obsidian-git/compare/2.24.3...2.25.0) (2024-07-23) ### Features * 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) ### Bug Fixes * catch sidebar leaf being null ([86065c9](https://github.com/denolehov/obsidian-git/commit/86065c987bb478cbf65b4baca1745d7162041b5d)) * 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) * 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) * use active color for buttons in file component ([c28d44b](https://github.com/denolehov/obsidian-git/commit/c28d44b1c6f358cdb0113ebcc4b1634a161dfdf2)) ### [2.24.3](https://github.com/denolehov/obsidian-git/compare/2.24.2...2.24.3) (2024-06-22) ### Bug Fixes * 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)) * limit amount of files to list in commit msg ([a0416ed](https://github.com/denolehov/obsidian-git/commit/a0416edf5f3ac5d9adc9fc37cf9a9c932583ced4)) * 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)) ### [2.24.2](https://github.com/denolehov/obsidian-git/compare/2.24.1...2.24.2) (2024-05-09) ### Bug Fixes * ask for upstream branch in backup ([d1143f7](https://github.com/denolehov/obsidian-git/commit/d1143f7d643581ddf674b492921bf0aab9044643)) * 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)) ### [2.24.1](https://github.com/denolehov/obsidian-git/compare/2.24.0...2.24.1) (2024-03-12) ### Bug Fixes * disable line authoring on mobile ([ac28656](https://github.com/denolehov/obsidian-git/commit/ac2865676135a22a81f8d1a440825e7583aa73ec)) ## [2.24.0](https://github.com/denolehov/obsidian-git/compare/2.23.2...2.24.0) (2024-03-04) ### Features * 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) ### Bug Fixes * 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) ### [2.23.2](https://github.com/denolehov/obsidian-git/compare/2.23.1...2.23.2) (2024-01-31) ### Bug Fixes * 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) ### [2.23.1](https://github.com/denolehov/obsidian-git/compare/2.23.0...2.23.1) (2024-01-29) ### Bug Fixes * commit in source control view ([90985b1](https://github.com/denolehov/obsidian-git/commit/90985b1b7733eb39247b3aaa71ddc2482012f272)), closes [#686](https://github.com/denolehov/obsidian-git/issues/686) ## [2.23.0](https://github.com/denolehov/obsidian-git/compare/2.22.2...2.23.0) (2024-01-28) ### Features * add commit amend command ([8f10261](https://github.com/denolehov/obsidian-git/commit/8f10261b08a498b0fc8f989209c1e0b048a27c35)), closes [#648](https://github.com/denolehov/obsidian-git/issues/648) * 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)) ### Bug Fixes * fold only one item ([cd1d932](https://github.com/denolehov/obsidian-git/commit/cd1d93226a4e2f5ebfaa89ada97851c60f35a4fd)), closes [#680](https://github.com/denolehov/obsidian-git/issues/680) ### [2.22.2](https://github.com/denolehov/obsidian-git/compare/2.22.1...2.22.2) (2024-01-26) ### [2.22.1](https://github.com/denolehov/obsidian-git/compare/2.22.0...2.22.1) (2024-01-08) ### Bug Fixes * 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) * create new remote ([1a4cca8](https://github.com/denolehov/obsidian-git/commit/1a4cca8baf20de91ce3ee825740a85f1d33c1744)), closes [#599](https://github.com/denolehov/obsidian-git/issues/599) * grammar improvement in settings ([#635](https://github.com/denolehov/obsidian-git/issues/635)) ([1d81577](https://github.com/denolehov/obsidian-git/commit/1d81577877ccb548b06fb91036a246aa442a41ae)) * tooltip direction ([#600](https://github.com/denolehov/obsidian-git/issues/600)) ([a913303](https://github.com/denolehov/obsidian-git/commit/a91330381e83cfc2ece14186325b129d7fc9b6bf)) * update settings grammar ([#656](https://github.com/denolehov/obsidian-git/issues/656)) ([d9e8be1](https://github.com/denolehov/obsidian-git/commit/d9e8be14b5dbb64a9b78b2d3fe56c474bd57596f)) ## [2.22.0](https://github.com/denolehov/obsidian-git/compare/2.21.0...2.22.0) (2023-08-30) ### Features * highlight opened diff view file ([5708c63](https://github.com/denolehov/obsidian-git/commit/5708c63ad7cad72c3939a4d554a5b98bc04783ed)), closes [#545](https://github.com/denolehov/obsidian-git/issues/545) ### Bug Fixes * ui alignment ([a9adfff](https://github.com/denolehov/obsidian-git/commit/a9adfff996d570f8e893a2e2786059d0fa2e1cb9)) ## [2.21.0](https://github.com/denolehov/obsidian-git/compare/2.20.7...2.21.0) (2023-08-22) ### Features * add fetch command ([222245b](https://github.com/denolehov/obsidian-git/commit/222245b7c750bd4d2740aa25913d74d538e5035d)) * 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) ### Bug Fixes * 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) * 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) ### [2.20.7](https://github.com/denolehov/obsidian-git/compare/2.20.6...2.20.7) (2023-07-31) ### Bug Fixes * properly collapse icon in tree views ([919d7f8](https://github.com/denolehov/obsidian-git/commit/919d7f8f65174e76a4f13a992b3f37931eaf7262)) * refresh status bar after push ([ed31df8](https://github.com/denolehov/obsidian-git/commit/ed31df88effc5e686cb2e81e0379df93627bdd9b)), closes [#566](https://github.com/denolehov/obsidian-git/issues/566) ### [2.20.6](https://github.com/denolehov/obsidian-git/compare/2.20.5...2.20.6) (2023-07-16) ### Bug Fixes * allow empty commit in history view ([2571473](https://github.com/denolehov/obsidian-git/commit/257147311cf65a2b5dedf957f4c71ad9624ce7be)) ### [2.20.5](https://github.com/denolehov/obsidian-git/compare/2.20.4...2.20.5) (2023-06-29) ### Bug Fixes * 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) * textarea for commit message in settings ([ea4a7a1](https://github.com/denolehov/obsidian-git/commit/ea4a7a105a8a954e705b372dac127fa3a83fddc1)) ### [2.20.4](https://github.com/denolehov/obsidian-git/compare/2.20.3...2.20.4) (2023-06-21) ### Bug Fixes * make `{{files}}` variable visible in settings ([#536](https://github.com/denolehov/obsidian-git/issues/536)) ([07abcce](https://github.com/denolehov/obsidian-git/commit/07abcce878a66e686e7f0221c68df746f69b590b)) * missing git file status 113 ([#537](https://github.com/denolehov/obsidian-git/issues/537)) ([ba2b40c](https://github.com/denolehov/obsidian-git/commit/ba2b40cbc5687f92ab9ca6a65110d5d6ec39c2ca)) ### [2.20.3](https://github.com/denolehov/obsidian-git/compare/2.20.2...2.20.3) (2023-06-04) ### Bug Fixes * show correct empty diff ([c8bbe7c](https://github.com/denolehov/obsidian-git/commit/c8bbe7c5d2b7beb49ab5fa55922f289c2bcdbed1)), closes [#327](https://github.com/denolehov/obsidian-git/issues/327) ### [2.20.2](https://github.com/denolehov/obsidian-git/compare/2.20.1...2.20.2) (2023-06-02) ### Bug Fixes * hide line authoring settings on mobile ([c135c0b](https://github.com/denolehov/obsidian-git/commit/c135c0b49cd0cc8033ea40f64e6f922702375aa0)) * properly resolve merge conflict ([80c0b65](https://github.com/denolehov/obsidian-git/commit/80c0b65f8d0d8dc8dbddff61118bd79733ffce94)), closes [#502](https://github.com/denolehov/obsidian-git/issues/502) ### [2.20.1](https://github.com/denolehov/obsidian-git/compare/2.20.0...2.20.1) (2023-05-31) ### Bug Fixes * 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) ## [2.20.0](https://github.com/denolehov/obsidian-git/compare/2.19.1...2.20.0) (2023-05-17) ### Features * Line Authoring ([aa8dd1b](https://github.com/denolehov/obsidian-git/commit/aa8dd1b3cf0fc440c4d7177831795f3fc5b0076c)), closes [#321](https://github.com/denolehov/obsidian-git/issues/321) ### Bug Fixes * 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) ### [2.19.1](https://github.com/denolehov/obsidian-git/compare/2.19.0...2.19.1) (2023-04-04) ### Bug Fixes * handle missing tracking branch ([#483](https://github.com/denolehov/obsidian-git/issues/483)) ([703fc18](https://github.com/denolehov/obsidian-git/commit/703fc18e7dccec89c810e6529a869ec5c271c21e)) ## [2.19.0](https://github.com/denolehov/obsidian-git/compare/2.18.0...2.19.0) (2023-03-22) ### Features * new History view * 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) ### Bug Fixes * catch error in diffView ([cbff377](https://github.com/denolehov/obsidian-git/commit/cbff37701fd8aa8b9e1257d09fb8ca9fb655b35b)) * catch huge auto intervals ([35bca00](https://github.com/denolehov/obsidian-git/commit/35bca003c98637f65295fe7d5a8bc4ae1fd68b07)), closes [#153](https://github.com/denolehov/obsidian-git/issues/153) ## [2.18.0](https://github.com/denolehov/obsidian-git/compare/2.17.4...2.18.0) (2023-03-20) ### Features * 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) * 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) ### Bug Fixes * catch huge auto intervals ([b96efc5](https://github.com/denolehov/obsidian-git/commit/b96efc5e06654f144d5837e784da297d79496c51)), closes [#153](https://github.com/denolehov/obsidian-git/issues/153) * minor source control view improvements ([fd7792c](https://github.com/denolehov/obsidian-git/commit/fd7792c80403d884d09c31bb09a76799bbd0dff0)) * typo in settings ([4014057](https://github.com/denolehov/obsidian-git/commit/4014057879d24cb176a2ee1baac868fab05bc856)), closes [#468](https://github.com/denolehov/obsidian-git/issues/468) ### [2.17.4](https://github.com/denolehov/obsidian-git/compare/2.17.3...2.17.4) (2023-03-07) ### Bug Fixes * add additional author check ([58ce847](https://github.com/denolehov/obsidian-git/commit/58ce84749936c78a2789f3eae1e2de3877350b96)) ### [2.17.3](https://github.com/denolehov/obsidian-git/compare/2.17.2...2.17.3) (2023-03-07) ### Bug Fixes * better error message for missing author ([2e9e3b1](https://github.com/denolehov/obsidian-git/commit/2e9e3b135de411f764ba6eef5b0aaf4d21216b55)) * don't checkout when nothing changed after merge ([f807d8a](https://github.com/denolehov/obsidian-git/commit/f807d8a19712bc8f697e37ba1a571b47be77c064)) * show diff with custom base path ([fdde0bf](https://github.com/denolehov/obsidian-git/commit/fdde0bf83b4fedf430fe829724992207f1393d48)) * use correct git path on clone on mobile ([686c323](https://github.com/denolehov/obsidian-git/commit/686c3230daff6a7fa1d51cf9270295ad975e2599)) ### [2.17.2](https://github.com/denolehov/obsidian-git/compare/2.17.1...2.17.2) (2023-03-06) ### Bug Fixes * use correct git dir on mobile ([fd456e5](https://github.com/denolehov/obsidian-git/commit/fd456e5f505ba1bedc0ab85fdc62ee9aa91c18e5)) ### [2.17.1](https://github.com/denolehov/obsidian-git/compare/2.17.0...2.17.1) (2023-03-05) ### Bug Fixes * show missing repo message ([70a6464](https://github.com/denolehov/obsidian-git/commit/70a64640f3b21fe61bbdaf0b6215d2878df732be)) ## [2.17.0](https://github.com/denolehov/obsidian-git/compare/2.16.0...2.17.0) (2023-02-25) ### Features * include old file name in log ([fa34fb5](https://github.com/denolehov/obsidian-git/commit/fa34fb5c87c9d9d6b294ef3fe28f5c7538df21ac)) * specify depth on clone ([cf81f0c](https://github.com/denolehov/obsidian-git/commit/cf81f0c1ea72931b2274265e32ab4db2d11d0c82)), closes [#307](https://github.com/denolehov/obsidian-git/issues/307) ### Bug Fixes * correct git dir for clone on mobile ([0b06487](https://github.com/denolehov/obsidian-git/commit/0b0648716f790e2676509b77dee444a72ef06814)) * handle github link errors ([#445](https://github.com/denolehov/obsidian-git/issues/445)) ([fd294cc](https://github.com/denolehov/obsidian-git/commit/fd294ccf50237c24000559d0d99cff4758e43b1a)) ## [2.16.0](https://github.com/denolehov/obsidian-git/compare/2.15.0...2.16.0) (2023-01-16) ### Features * additional environment variables ([f9b1bca](https://github.com/denolehov/obsidian-git/commit/f9b1bca38c6db23f05abfb211933f7ce4f69db7f)), closes [#414](https://github.com/denolehov/obsidian-git/issues/414) * custom GIT_DIR ([978453e](https://github.com/denolehov/obsidian-git/commit/978453ebb1cf0df1c70bd169665709cd512264dd)) ## [2.15.0](https://github.com/denolehov/obsidian-git/compare/2.14.0...2.15.0) (2023-01-06) ### Features * improve discard modal ([872fc18](https://github.com/denolehov/obsidian-git/commit/872fc182743df108a42ae244699bb2d2b03d7c69)) ## [2.14.0](https://github.com/denolehov/obsidian-git/compare/2.13.0...2.14.0) (2022-12-14) ### Features * add instructions to conflict file ([50291d3](https://github.com/denolehov/obsidian-git/commit/50291d3b182ba4789dac25164ca66f511ba1ab67)), closes [#402](https://github.com/denolehov/obsidian-git/issues/402) ### Bug Fixes * 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) ## [2.13.0](https://github.com/denolehov/obsidian-git/compare/2.12.1...2.13.0) (2022-12-07) ### Features * add file name to diff view tab name ([8520c2b](https://github.com/denolehov/obsidian-git/commit/8520c2beed20f9fe20e6af830c34f59b1678b36a)) ### Bug Fixes * move commit msg setting to correct heading ([88eabc9](https://github.com/denolehov/obsidian-git/commit/88eabc930ca98ea205d366e874a245af964efabd)) * 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) ### [2.12.1](https://github.com/denolehov/obsidian-git/compare/2.12.0...2.12.1) (2022-11-27) ### Bug Fixes * 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) ## [2.12.0](https://github.com/denolehov/obsidian-git/compare/2.11.0...2.12.0) (2022-11-27) ### Features * 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) ## [2.11.0](https://github.com/denolehov/obsidian-git/compare/2.10.2...2.11.0) (2022-11-26) ### Features * 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) ### Bug Fixes * 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) ### [2.10.2](https://github.com/denolehov/obsidian-git/compare/2.10.1...2.10.2) (2022-11-17) ### Bug Fixes * focus diff view via command ([e56641c](https://github.com/denolehov/obsidian-git/commit/e56641c25f9672fcffeb0801f09ca7eadf99ede0)), closes [#377](https://github.com/denolehov/obsidian-git/issues/377) ### [2.10.1](https://github.com/denolehov/obsidian-git/compare/2.10.0...2.10.1) (2022-11-13) ### Bug Fixes * add remote on mobile ([c529a37](https://github.com/denolehov/obsidian-git/commit/c529a377195fea76028b424d5973aae80498670e)), closes [#375](https://github.com/denolehov/obsidian-git/issues/375) ## [2.10.0](https://github.com/denolehov/obsidian-git/compare/2.9.4...2.10.0) (2022-11-08) ### Features * log git commands ([a63bb8a](https://github.com/denolehov/obsidian-git/commit/a63bb8a0063b69cc020a0fd0017b42d7ee31ed1e)) ### Bug Fixes * reorder settings item ([8d5b596](https://github.com/denolehov/obsidian-git/commit/8d5b59658500329b9f52a68127d619c3f5016906)) ### [2.9.4](https://github.com/denolehov/obsidian-git/compare/2.9.3...2.9.4) (2022-11-04) ### Bug Fixes * unset config on empty value ([d0f927e](https://github.com/denolehov/obsidian-git/commit/d0f927ecec9aeeae4ee86873511a208bf943e29c)) ### [2.9.3](https://github.com/denolehov/obsidian-git/compare/2.9.2...2.9.3) (2022-11-03) ### [2.9.2](https://github.com/denolehov/obsidian-git/compare/2.9.1...2.9.2) (2022-11-02) ### Bug Fixes * detect network unreachable ([76b894c](https://github.com/denolehov/obsidian-git/commit/76b894c21085ff99d2f0bbaf1c4f46351e3f19f1)), closes [#211](https://github.com/denolehov/obsidian-git/issues/211) * hide notification on mobile ([7d62527](https://github.com/denolehov/obsidian-git/commit/7d6252795ca62f4176fee90d674041659a0a1d9f)), closes [#292](https://github.com/denolehov/obsidian-git/issues/292) ### [2.9.1](https://github.com/denolehov/obsidian-git/compare/2.9.0...2.9.1) (2022-11-02) ### Bug Fixes * set path env var ([8a2ae4d](https://github.com/denolehov/obsidian-git/commit/8a2ae4dfe2ebb0023a351c251845d29a311a9560)) ## [2.9.0](https://github.com/denolehov/obsidian-git/compare/2.8.0...2.9.0) (2022-11-01) ### Features * custom PATH env paths ([2c42609](https://github.com/denolehov/obsidian-git/commit/2c4260942a738421bf517f1b0d063b536345f8bf)) ### Bug Fixes * store username in localstorage ([f3668ac](https://github.com/denolehov/obsidian-git/commit/f3668ac23f13d263e50b7d6b716d91150a11b6c7)) ## [2.8.0](https://github.com/denolehov/obsidian-git/compare/2.7.0...2.8.0) (2022-10-18) ### Features * new discard icon ([730e9a6](https://github.com/denolehov/obsidian-git/commit/730e9a6405b4018dc987b29c0a156feb01b583f2)) ### Bug Fixes * align buttons ([a09bc4a](https://github.com/denolehov/obsidian-git/commit/a09bc4ac2b5165b11a740230db55a4ef05e3c219)) * center buttons in discard modal ([79a1e86](https://github.com/denolehov/obsidian-git/commit/79a1e86ce5ba7e039393c49414b0e408e940aaa5)) * create .gitignore if not exists ([ac8e3ee](https://github.com/denolehov/obsidian-git/commit/ac8e3ee380340fbeedf0dac8e80a4c28aeadffa8)) * full directory path on hover ([0f2c9d5](https://github.com/denolehov/obsidian-git/commit/0f2c9d56b1733450283af487c740d01908201284)) ## [2.7.0](https://github.com/denolehov/obsidian-git/compare/2.6.0...2.7.0) (2022-10-18) ### Features * discard all changes ([3461a30](https://github.com/denolehov/obsidian-git/commit/3461a300ee563a316faf5d198473f2ccc323b1e8)) * discard directories ([149805f](https://github.com/denolehov/obsidian-git/commit/149805f24e310e2b225be904c75094b90d38dd33)) * stage/unstage button on category ([3373e6d](https://github.com/denolehov/obsidian-git/commit/3373e6d0ee4f2a4d84e7b3513fd7712046b2e889)) ### Bug Fixes * correct height for textarea ([b44c900](https://github.com/denolehov/obsidian-git/commit/b44c9008db9b12b1e4f23ef5fc87151618953231)) * jittering of refresh button ([dbf36b2](https://github.com/denolehov/obsidian-git/commit/dbf36b2a63617b4d937f75326cd007ea08cfb622)) * sum folder paths in n depth ([e690164](https://github.com/denolehov/obsidian-git/commit/e690164c0ef6bc1749008357299f92b9a244d960)) * unstage all on mobile ([4507fdb](https://github.com/denolehov/obsidian-git/commit/4507fdb9c6f2f5c890198a18980586d892786d0e)) * unstage dir ([3d421b7](https://github.com/denolehov/obsidian-git/commit/3d421b70e0611b5dbbd91c23668e8b98df57116a)) * unstage folder on desktop ([56afe51](https://github.com/denolehov/obsidian-git/commit/56afe510ade79fab52a8a1aa2a9c15739d16a904)) ## [2.6.0](https://github.com/denolehov/obsidian-git/compare/2.5.1...2.6.0) (2022-10-13) ### Features * combine multiple empty directory into one in git view ([4e45e6a](https://github.com/denolehov/obsidian-git/commit/4e45e6accc468402033c5b56d6fb56ec5b461c1e)) * redesign source control view ([06f3c22](https://github.com/denolehov/obsidian-git/commit/06f3c229cfd199c716401ad1f4e524e2e23bc4f7)) * stage/unstage directory ([61b3eb3](https://github.com/denolehov/obsidian-git/commit/61b3eb3ac38553ef4cda0512be19b1f4480d613c)) ### [2.5.1](https://github.com/denolehov/obsidian-git/compare/2.5.0...2.5.1) (2022-09-29) ### Bug Fixes * 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) ## [2.5.0](https://github.com/denolehov/obsidian-git/compare/2.4.1...2.5.0) (2022-09-28) ### Features * improve source control view style ([d5647a8](https://github.com/denolehov/obsidian-git/commit/d5647a8e2e49c8a77f28854bb4c276a17f390d55)) ### Bug Fixes * reveal source control view ([c88a1b4](https://github.com/denolehov/obsidian-git/commit/c88a1b43633e9964e3a9f60e94c0dc7f8307edc1)) ### [2.4.1](https://github.com/denolehov/obsidian-git/compare/2.4.0...2.4.1) (2022-09-22) ### Bug Fixes * keep git view on unload ([8b846da](https://github.com/denolehov/obsidian-git/commit/8b846da0010a852b5422d64034c1e4b309fa7f35)), closes [#321](https://github.com/denolehov/obsidian-git/issues/321) ## [2.4.0](https://github.com/denolehov/obsidian-git/compare/2.3.0...2.4.0) (2022-09-22) ### Features * prefill edit remote modal ([223193c](https://github.com/denolehov/obsidian-git/commit/223193c51b362788a0682dc598c7d0eefa9ccdf0)) ### Bug Fixes * middle click to open file/diff in new tab ([ddb1164](https://github.com/denolehov/obsidian-git/commit/ddb1164b10f5e0d373daaa4cd8ec4d60119cc544)) ## [2.3.0](https://github.com/denolehov/obsidian-git/compare/2.2.1...2.3.0) (2022-09-21) ### Features * 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) ### Bug Fixes * backup with reset sync method ([41a00ff](https://github.com/denolehov/obsidian-git/commit/41a00ff8b17212a23c515d0f19be69a0b8d2f1c1)), closes [#319](https://github.com/denolehov/obsidian-git/issues/319) ### [2.2.1](https://github.com/denolehov/obsidian-git/compare/2.2.0...2.2.1) (2022-09-20) ### Bug Fixes * localstorage migration ([1d9391a](https://github.com/denolehov/obsidian-git/commit/1d9391a970624f03fcc60fb68f3bd8ee450af24b)) ## [2.2.0](https://github.com/denolehov/obsidian-git/compare/2.1.2...2.2.0) (2022-09-20) ### Features * diff view on mobile ([86b4d5a](https://github.com/denolehov/obsidian-git/commit/86b4d5ad4be23b420fe0efd0f3dfd989047be23a)), closes [#302](https://github.com/denolehov/obsidian-git/issues/302) ### Bug Fixes * 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) * save localstorage per vault ([a3c4e4f](https://github.com/denolehov/obsidian-git/commit/a3c4e4f8916b78160de074e72444c5ccd91c32b2)) ### [2.1.2](https://github.com/denolehov/obsidian-git/compare/2.1.1...2.1.2) (2022-09-19) ### Bug Fixes * 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) * 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) ### [2.1.1](https://github.com/denolehov/obsidian-git/compare/2.1.0...2.1.1) (2022-09-15) ### Bug Fixes * open diff in new leaf ([6914830](https://github.com/denolehov/obsidian-git/commit/6914830ce03651b7f9604ba7be81581636a34f5b)), closes [#306](https://github.com/denolehov/obsidian-git/issues/306) * retry auth with different credentials ([f8da5f4](https://github.com/denolehov/obsidian-git/commit/f8da5f455a15f27e93e8b317af3bf9dba7dc3d57)), closes [#296](https://github.com/denolehov/obsidian-git/issues/296) ## [2.1.0](https://github.com/denolehov/obsidian-git/compare/2.0.3...2.1.0) (2022-09-08) ### Features * disable plugin per device ([82b2c1a](https://github.com/denolehov/obsidian-git/commit/82b2c1ad82196927eda16b965094f025a1ed2960)), closes [#301](https://github.com/denolehov/obsidian-git/issues/301) * specify source control refresh timer ([a1ecb1b](https://github.com/denolehov/obsidian-git/commit/a1ecb1b39954422de150169a71d0b9da8ee84167)), closes [#199](https://github.com/denolehov/obsidian-git/issues/199) ### [2.0.3](https://github.com/denolehov/obsidian-git/compare/2.0.2...2.0.3) (2022-09-06) ### Bug Fixes * don't show mobile notice on new installation ([218f002](https://github.com/denolehov/obsidian-git/commit/218f002f433ec3a69f92ebfdf1876c50cd99e85c)) ### [2.0.2](https://github.com/denolehov/obsidian-git/compare/2.0.1...2.0.2) (2022-09-06) ### Bug Fixes * don't show mobile notice on mobile ([c93ddfa](https://github.com/denolehov/obsidian-git/commit/c93ddfaee5d7621248f2ed1183b8819a7d216706)) ### [2.0.1](https://github.com/denolehov/obsidian-git/compare/2.0.0...2.0.1) (2022-09-06) ## [2.0.0](https://github.com/denolehov/obsidian-git/compare/1.31.0...2.0.0) (2022-09-06) ### ⚠ BREAKING CHANGES * mobile support ### Features * mobile support ([9ffda76](https://github.com/denolehov/obsidian-git/commit/9ffda762dbc0cba380942acdeabcb66adce8253d)), closes [#57](https://github.com/denolehov/obsidian-git/issues/57) ### Bug Fixes * password field description ([9dc5f7c](https://github.com/denolehov/obsidian-git/commit/9dc5f7c7bab3a3b1b24d42ae2fadb10e48cbc292)), closes [#293](https://github.com/denolehov/obsidian-git/issues/293) ## [1.31.0](https://github.com/denolehov/obsidian-git/compare/1.30.0...1.31.0) (2022-08-28) ### Features * 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) ### Bug Fixes * **mobile** don't show push notice on empty push ([9986667](https://github.com/denolehov/obsidian-git/commit/998666778ed07f380b6a4057afc33ca637c472c7)) * 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) ## [1.30.0](https://github.com/denolehov/obsidian-git/compare/1.29.2...1.30.0) (2022-08-24) ### Features * 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) ### Bug Fixes * **mobile:** clone and delete local config dir ([9b0bc8a](https://github.com/denolehov/obsidian-git/commit/9b0bc8afb2f21440382f40a442dfc7b1bd369cca)) * **mobile:** readdir with empty base path ([1c38b91](https://github.com/denolehov/obsidian-git/commit/1c38b913d15ed6a2410eeb9b0da7ae9675ef3ab3)) * 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) * too many changes to display ([c4bf4eb](https://github.com/denolehov/obsidian-git/commit/c4bf4eb8bd7d3e9110b354910eed9e29bafbafa6)) ### [1.29.2](https://github.com/denolehov/obsidian-git/compare/1.29.1...1.29.2) (2022-08-22) ### Bug Fixes * catch ssh network failure ([62e4a6a](https://github.com/denolehov/obsidian-git/commit/62e4a6a255e2ceedd38e4a016fa92f407e052485)), closes [#211](https://github.com/denolehov/obsidian-git/issues/211) * diff of new file ([92d24bf](https://github.com/denolehov/obsidian-git/commit/92d24bf8f25749f48ea8646088adec55a1ae2c25)), closes [#277](https://github.com/denolehov/obsidian-git/issues/277) * **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) * require upstream branch for pull ([3fac8ad](https://github.com/denolehov/obsidian-git/commit/3fac8ad86f8885ca322865b6e27f4a43b804a6ce)), closes [#261](https://github.com/denolehov/obsidian-git/issues/261) ### [1.29.1](https://github.com/denolehov/obsidian-git/compare/1.29.0...1.29.1) (2022-08-19) ### Bug Fixes * export mock IsomorphicGit.ts ([aa5fa37](https://github.com/denolehov/obsidian-git/commit/aa5fa37579903243f6623fa99592203c76cd5478)), closes [#281](https://github.com/denolehov/obsidian-git/issues/281) ## [1.29.0](https://github.com/denolehov/obsidian-git/compare/1.28.0...1.29.0) (2022-08-19) ### Features * add delete repo command ([26cdfb8](https://github.com/denolehov/obsidian-git/commit/26cdfb8629f2909e019fecebecd6ff745ad0b932)) * add to .gitignore command ([c824903](https://github.com/denolehov/obsidian-git/commit/c824903ea8572619b147b405dee76e51b4970f9c)) * edit .gitignore ([1cad1b7](https://github.com/denolehov/obsidian-git/commit/1cad1b72649c4ad7da931a32bb891176e2f96b3d)) * commit only staged files ([f6f4a97](https://github.com/denolehov/obsidian-git/commit/f6f4a97c36acda5950bb156f1732ab0ece89a63e)) * fix clone overwrite ([d853a4e](https://github.com/denolehov/obsidian-git/commit/d853a4ea00f636bcf98a3e5c31ad360923f30219)) * hide settings when git is not ready ([4c40556](https://github.com/denolehov/obsidian-git/commit/4c40556653132767d1dd424fa37c75ccf7cafe86)) * set author to config ([f40920d](https://github.com/denolehov/obsidian-git/commit/f40920d9970dcdf6146b9b108b76cad88d166fdc)) * stage and unstage to context menu ([081ad1d](https://github.com/denolehov/obsidian-git/commit/081ad1dda58f6ae8a3458bf8568de5165824410d)) ### Bug Fixes * abort edit remotes on no url ([e617278](https://github.com/denolehov/obsidian-git/commit/e617278e68019583b39ac961de27fe84d46f572a)) * require valid repo for list changed files ([fe300c7](https://github.com/denolehov/obsidian-git/commit/fe300c767d4dac81ce9968e29106eaaf6aeb3ea2)) * restart notice after clone ([140bed5](https://github.com/denolehov/obsidian-git/commit/140bed5cde1772b8c59a72db9ffa89a6eac9151e)) * set base path after clone ([0327090](https://github.com/denolehov/obsidian-git/commit/032709096b6afd8411868135596d5b9ef6c19fbd)) * stage individual file ([76e317b](https://github.com/denolehov/obsidian-git/commit/76e317b5320b3a7e9ab303b402518f3791333a8d)) ## [1.28.0](https://github.com/denolehov/obsidian-git/compare/1.27.1...1.28.0) (2022-07-25) ### Features * stage and unstage current file ([f014e52](https://github.com/denolehov/obsidian-git/commit/f014e52e11cbb345a313914a9e71e0807a0d4197)), closes [#265](https://github.com/denolehov/obsidian-git/issues/265) ### Bug Fixes * register event listener after initial load ([d32d0f4](https://github.com/denolehov/obsidian-git/commit/d32d0f4bc26db390da8008e8af878eef97ba98f4)) ### [1.27.1](https://github.com/denolehov/obsidian-git/compare/1.27.0...1.27.1) (2022-07-20) ### Bug Fixes * check for too big files in source control view ([2275d4f](https://github.com/denolehov/obsidian-git/commit/2275d4f716c00a305bf4371e9cb1b934669b2272)) ## [1.27.0](https://github.com/denolehov/obsidian-git/compare/1.26.4...1.27.0) (2022-07-20) ### Features * 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) ### [1.26.4](https://github.com/denolehov/obsidian-git/compare/1.26.3...1.26.4) (2022-07-20) ### Bug Fixes * 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) ### [1.26.3](https://github.com/denolehov/obsidian-git/compare/1.26.2...1.26.3) (2022-07-17) ### Bug Fixes * commit only staged files, again ([ba35555](https://github.com/denolehov/obsidian-git/commit/ba35555eebbac5f4a69aaa7da6847928e0ddd017)), closes [#253](https://github.com/denolehov/obsidian-git/issues/253) ### [1.26.2](https://github.com/denolehov/obsidian-git/compare/1.26.1...1.26.2) (2022-07-16) ### Bug Fixes * clarification about disabling notifications ([#249](https://github.com/denolehov/obsidian-git/issues/249)) ([f90b284](https://github.com/denolehov/obsidian-git/commit/f90b284f1f4140eab6aec1b77353cb52e661a8e3)) * commit only staged files ([f71fdf5](https://github.com/denolehov/obsidian-git/commit/f71fdf58271c1490887f057c6ecc5e6d3689dbd4)), closes [#253](https://github.com/denolehov/obsidian-git/issues/253) * 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) * 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) ### [1.26.1](https://github.com/denolehov/obsidian-git/compare/1.26.0...1.26.1) (2022-06-09) ### Bug Fixes * open file with custom base path ([8a11666](https://github.com/denolehov/obsidian-git/commit/8a11666e6d430ed3fba952a584b9f8af6cc462fe)) * use correct path with custom base path ([0d86e68](https://github.com/denolehov/obsidian-git/commit/0d86e6872fa16c809f7bf71f05e344acaf31008d)) ## [1.26.0](https://github.com/denolehov/obsidian-git/compare/1.25.3...1.26.0) (2022-06-09) ### Features * 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) * 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) ### Bug Fixes * handle merge conflict better ([101aff9](https://github.com/denolehov/obsidian-git/commit/101aff991ecaecd57f004dd7bfd1811866b755e5)) ### [1.25.3](https://github.com/denolehov/obsidian-git/compare/1.25.2...1.25.3) (2022-05-14) ### Bug Fixes * show renamed files ([b76c783](https://github.com/denolehov/obsidian-git/commit/b76c78332978dbbf7045f94295ed3228de7132a9)), closes [#226](https://github.com/denolehov/obsidian-git/issues/226) ### [1.25.2](https://github.com/denolehov/obsidian-git/compare/1.25.1...1.25.2) (2022-05-07) ### Bug Fixes * improve base path description ([8ee3a63](https://github.com/denolehov/obsidian-git/commit/8ee3a63fff91d4c9fd61cb9da73cc738026b8af1)) ### [1.25.1](https://github.com/denolehov/obsidian-git/compare/1.25.0...1.25.1) (2022-04-22) ### Bug Fixes * recursive submodules ([#217](https://github.com/denolehov/obsidian-git/issues/217)) ([98f566f](https://github.com/denolehov/obsidian-git/commit/98f566ffa29bb99dde44615f52fd352c099bd7f4)) ## [1.25.0](https://github.com/denolehov/obsidian-git/compare/1.24.1...1.25.0) (2022-04-07) ### Features * custom git repository root ([#209](https://github.com/denolehov/obsidian-git/issues/209)) ([4157e42](https://github.com/denolehov/obsidian-git/commit/4157e42fa6cbd0f69d8ed03169c5bc836229d6d4)) * offline mode ([6989ba4](https://github.com/denolehov/obsidian-git/commit/6989ba4fd7ce44bfac5c6f7479cf41ef8fcb5de3)), closes [#211](https://github.com/denolehov/obsidian-git/issues/211) ### Bug Fixes * refresh source control view less frequently ([b90b1a5](https://github.com/denolehov/obsidian-git/commit/b90b1a5fa596142f42698727dd76cadd97e9bdc6)) ### [1.24.1](https://github.com/denolehov/obsidian-git/compare/1.24.0...1.24.1) (2022-03-23) ### Bug Fixes * :adhesive_bandage: More specific CSS selectors for the diff-view ([c0c9a38](https://github.com/denolehov/obsidian-git/commit/c0c9a381f2c4c0527674e4e215e2418c71d68b73)) * refresh source control view on first open ([6e75300](https://github.com/denolehov/obsidian-git/commit/6e75300424eb8d78f1a4c79caf830ce5d5fd1727)) ## [1.24.0](https://github.com/denolehov/obsidian-git/compare/1.23.0...1.24.0) (2022-03-18) ### Features * add show, diff, log as api ([b3a72a4](https://github.com/denolehov/obsidian-git/commit/b3a72a46dfb917b28ca9af7848994668d1846b64)) ## [1.23.0](https://github.com/denolehov/obsidian-git/compare/1.22.0...1.23.0) (2022-03-18) ### Features * 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) ### Bug Fixes * expand selection width on stagedFileComponent too ([daf8ac7](https://github.com/denolehov/obsidian-git/commit/daf8ac7e4279d6334fd36b4706c85c77d2e8dbbe)) * highlight staged file on hover ([ef0d3e6](https://github.com/denolehov/obsidian-git/commit/ef0d3e6640712669e8d303d7cf1bff7dbdedbc7d)) * 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) ## [1.22.0](https://github.com/denolehov/obsidian-git/compare/1.21.2...1.22.0) (2022-03-02) ### Features * 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) ### Bug Fixes * automatically refresh source control ([9c2b063](https://github.com/denolehov/obsidian-git/commit/9c2b063584366226da876c9e5e6509868b6a01cd)), closes [#199](https://github.com/denolehov/obsidian-git/issues/199) * correct pull changes count ([6aead15](https://github.com/denolehov/obsidian-git/commit/6aead155571eda2499ef1ab6377236152124df96)), closes [#198](https://github.com/denolehov/obsidian-git/issues/198) ### [1.21.2](https://github.com/denolehov/obsidian-git/compare/1.21.1...1.21.2) (2022-03-01) ### Bug Fixes * catch git error on commit ([fe78ae3](https://github.com/denolehov/obsidian-git/commit/fe78ae364e5296a378a3d0844a3daa53b3d024c7)) * stage files without glob pattern ([99b1f6c](https://github.com/denolehov/obsidian-git/commit/99b1f6c3d61e4b2fca274531fa98359af0a8c64e)), closes [#196](https://github.com/denolehov/obsidian-git/issues/196) ### [1.21.1](https://github.com/denolehov/obsidian-git/compare/1.21.0...1.21.1) (2022-02-19) ### Bug Fixes * better automatic backup/pull description ([10c3072](https://github.com/denolehov/obsidian-git/commit/10c307228da5c79cf62acfa2d6c90d2f519855a8)), closes [#181](https://github.com/denolehov/obsidian-git/issues/181) * catch more git errors ([153fd82](https://github.com/denolehov/obsidian-git/commit/153fd82d7467d6c58905fa77c4376b2e79594810)) * stage filenames with leading '-' ([c06296e](https://github.com/denolehov/obsidian-git/commit/c06296e364962474299687e941fcdab8e03c9061)), closes [#184](https://github.com/denolehov/obsidian-git/issues/184) ### [1.21.0](https://github.com/denolehov/obsidian-git/compare/1.20.1...1.20.2) (2022-02-02) ### Bug Fixes * 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) ### Features * new sync method ([f1d6b33](https://github.com/denolehov/obsidian-git/commit/f1d6b334972b76271a15883c31784812f24d6878)) ### [1.20.1](https://github.com/denolehov/obsidian-git/compare/1.20.0...1.20.1) (2022-01-29) ### Bug Fixes * show correct debug console hotkey ([087582e](https://github.com/denolehov/obsidian-git/commit/087582e429d96345c1f1ee17e0d6a1eeb71d9489)), closes [#175](https://github.com/denolehov/obsidian-git/issues/175) ## [1.20.0](https://github.com/denolehov/obsidian-git/compare/1.19.0...1.20.0) (2022-01-08) ### Features * :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)) * :sparkles: Add Command to open file history on GitHub ([e7dd288](https://github.com/denolehov/obsidian-git/commit/e7dd288ba85a87e783d18c2b51e9027ec20f94fa)) * :sparkles: Add Diff View ([78cd43f](https://github.com/denolehov/obsidian-git/commit/78cd43fadece2b2d6bed80582bba18d842632e1a)), closes [#158](https://github.com/denolehov/obsidian-git/issues/158) * :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) * :sparkles: Allow multiline commit messages, fix [#157](https://github.com/denolehov/obsidian-git/issues/157) ([80ea17e](https://github.com/denolehov/obsidian-git/commit/80ea17e34f07f43bfe2aef1b5c520160a0e71e10)) * Add toggle in Settings to choose default layout ([38c7240](https://github.com/denolehov/obsidian-git/commit/38c7240918d1e463044431d976b9025eb1fdc318)) ### Bug Fixes * :bug: Fix RegEx for openInGitHub ([ca59a2d](https://github.com/denolehov/obsidian-git/commit/ca59a2db581643cfd77f3663850fe1243efe4260)) * :children_crossing: Show diff on double click ([407dcc0](https://github.com/denolehov/obsidian-git/commit/407dcc05d6e9679c7487c1d2dfa78f580c16b5da)) * catch diff for deleted file ([710cd2c](https://github.com/denolehov/obsidian-git/commit/710cd2cc6e69c1561277c762518ba0ba903e91f3)) * different tree data structure ([0fd2f95](https://github.com/denolehov/obsidian-git/commit/0fd2f954b66f0336475f8babb1904130711cbc50)) * many minor fixes ([7d29bef](https://github.com/denolehov/obsidian-git/commit/7d29bef4ed7793b399a704f73a3ab458e043e595)) * refresh source control view on change ([45e54e2](https://github.com/denolehov/obsidian-git/commit/45e54e21d097492d35084e4b7c52e1f7df5c59b1)) * remove tree structure from settings ([5af00ae](https://github.com/denolehov/obsidian-git/commit/5af00ae593d573016694da3bc9bbb218c8baa978)) ## [1.19.0](https://github.com/denolehov/obsidian-git/compare/1.18.1...1.19.0) (2021-12-22) ### Features * add rebase option for pull ([b04e444](https://github.com/denolehov/obsidian-git/commit/b04e444e99ca31d1abb1e4bfdd81cbdaca88caec)), closes [#155](https://github.com/denolehov/obsidian-git/issues/155) ### [1.18.1](https://github.com/denolehov/obsidian-git/compare/1.18.0...1.18.1) (2021-12-09) ### Bug Fixes * use more specific css class ([471b257](https://github.com/denolehov/obsidian-git/commit/471b257671b861f69747882fcd67be22f7dca287)) ## [1.18.0](https://github.com/denolehov/obsidian-git/compare/1.17.0...1.18.0) (2021-12-09) ### Features * 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) * use icons for status bar ([96dcbc4](https://github.com/denolehov/obsidian-git/commit/96dcbc443369803a6f11d69ca80f34176025864a)), closes [#147](https://github.com/denolehov/obsidian-git/issues/147) ### Bug Fixes * 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) ## [1.17.0](https://github.com/denolehov/obsidian-git/compare/1.16.2...1.17.0) (2021-12-08) ### Features * add hostname commit message placeholder ([32d8382](https://github.com/denolehov/obsidian-git/commit/32d8382c804b1e86effb409246dad06cad78506d)), closes [#146](https://github.com/denolehov/obsidian-git/issues/146) ### Bug Fixes * clear autobackup/pull correctly ([1c5eeab](https://github.com/denolehov/obsidian-git/commit/1c5eeab098609ab5925a2ddda3aeef76db2660b3)) * don't start autobackup with 0 interval time ([a36c741](https://github.com/denolehov/obsidian-git/commit/a36c741cb1a5e557615768af3656dc76d6391ed0)) ### [1.16.2](https://github.com/denolehov/obsidian-git/compare/1.16.1...1.16.2) (2021-11-29) ### Bug Fixes * don't use new auto backup after change by default ([cc95a96](https://github.com/denolehov/obsidian-git/commit/cc95a96613386c30c379457d7d33198808403c63)) ### [1.16.1](https://github.com/denolehov/obsidian-git/compare/1.16.0...1.16.1) (2021-11-28) ### Bug Fixes * proper utf-8 encoding ([1bc7d28](https://github.com/denolehov/obsidian-git/commit/1bc7d2844ec3b3a954abe3c11766fd1e2d1c1b2a)), closes [#121](https://github.com/denolehov/obsidian-git/issues/121) ## [1.16.0](https://github.com/denolehov/obsidian-git/compare/1.15.1...1.16.0) (2021-11-28) ### Features * 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) ### [1.15.1](https://github.com/denolehov/obsidian-git/compare/1.15.0...1.15.1) (2021-11-25) ### Bug Fixes * use custom git binary path for git check ([7188753](https://github.com/denolehov/obsidian-git/commit/718875300f6f9d22e8773a5336bd70b095f63845)) ## [1.15.0](https://github.com/denolehov/obsidian-git/compare/1.14.3...1.15.0) (2021-11-11) ### Features * 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) ### [1.14.3](https://github.com/denolehov/obsidian-git/compare/1.14.2...1.14.3) (2021-11-03) ### Bug Fixes * open file from Git view when no other file is opened ### [1.14.2](https://github.com/denolehov/obsidian-git/compare/1.14.1...1.14.2) (2021-11-01) ### Bug Fixes * replace '?' by 'U' for untracked files ([64cf162](https://github.com/denolehov/obsidian-git/commit/64cf1623e50513f0f46141f6860650d0a865238c)) * wrap tooltip for long paths ([1fc4c1f](https://github.com/denolehov/obsidian-git/commit/1fc4c1fd7afbdbb08d7e3a061dd5d602e6f195a3)) ### [1.14.1](https://github.com/denolehov/obsidian-git/compare/1.14.0...1.14.1) (2021-11-01) ### Bug Fixes * list files in commit body ([f52a18b](https://github.com/denolehov/obsidian-git/commit/f52a18b3a3b6b05d643541baf2f74c32bb3e88d4)), closes [#131](https://github.com/denolehov/obsidian-git/issues/131) ## [1.14.0](https://github.com/denolehov/obsidian-git/compare/1.13.1...1.14.0) (2021-10-31) ### Features * New Git view in the sidebar to stage and commit individual files. Thanks to @phibr0 for making the UI ### [1.13.1](https://github.com/denolehov/obsidian-git/compare/1.13.0...1.13.1) (2021-09-30) ### Bug Fixes * 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) ## [1.13.0](https://github.com/denolehov/obsidian-git/compare/1.12.0...1.13.0) (2021-09-21) ### Features * support cloning remote repos ([ab5ece7](https://github.com/denolehov/obsidian-git/commit/ab5ece75ceba3af5845770dc029732d5657720a3)) ## [1.12.0](https://github.com/denolehov/obsidian-git/compare/1.11.0...1.12.0) (2021-09-18) ### Features * support custom git binary path ([7793035](https://github.com/denolehov/obsidian-git/commit/77930351622a86ef3babdb4d60acbc8ff334cc84)), closes [#113](https://github.com/denolehov/obsidian-git/issues/113) ## [1.11.0](https://github.com/denolehov/obsidian-git/compare/1.10.2...1.11.0) (2021-09-15) ### Features * add remote editing ([f70363b](https://github.com/denolehov/obsidian-git/commit/f70363b522c2e144260411d01e26108f7dedb735)) * support initalizing a new repo ([0fd2062](https://github.com/denolehov/obsidian-git/commit/0fd20627c3c289a05e0aba179b36badfe11d2414)) * support selecting upstream branch ([013878e](https://github.com/denolehov/obsidian-git/commit/013878e378bdbc6bab23c94615fba0c2bb72e1dc)) ### [1.10.2](https://github.com/denolehov/obsidian-git/compare/1.10.1...1.10.2) (2021-09-05) ### Bug Fixes * plugin status bar now displays time from last update (push or pull) ([b835fc3](https://github.com/denolehov/obsidian-git/commit/b835fc3548884dca4084ec37a296ebebf9c9dab7)) ### [1.10.1](https://github.com/denolehov/obsidian-git/compare/1.10.0...1.10.1) (2021-08-19) ### Bug Fixes * checkRequirements cant find user.name/email ([1994a44](https://github.com/denolehov/obsidian-git/commit/1994a44c5ecec121965505e2627d26460425e4dd)) * rename commands to be more consistend ([5e07e80](https://github.com/denolehov/obsidian-git/commit/5e07e80a13640b6ba587185880ba01befbd563ac)) ## [1.10.0](https://github.com/denolehov/obsidian-git/compare/1.9.3...1.10.0) (2021-08-11) ### Features * add submodules support ([2a4ce6d](https://github.com/denolehov/obsidian-git/commit/2a4ce6d47696cd6667b639c8479b37f61346e9be)), closes [#93](https://github.com/denolehov/obsidian-git/issues/93) ### Bug Fixes * Changed the branchLocal command to branch with no-color ([dbd93cf](https://github.com/denolehov/obsidian-git/commit/dbd93cfe5f127874a514837577b42b34a07bcf3e)) ### [1.9.3](https://github.com/denolehov/obsidian-git/compare/1.9.2...1.9.3) (2021-07-13) ### Bug Fixes * 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) ### [1.9.2](https://github.com/denolehov/obsidian-git/compare/1.9.1...1.9.2) (2021-05-12) ### Bug Fixes * plugin started wrong when normally enabled ([dc9c4b1](https://github.com/denolehov/obsidian-git/commit/dc9c4b13387067793e315a9aca24c05c75fb6d38)) * 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) ### [1.9.1](https://github.com/denolehov/obsidian-git/compare/1.9.0...1.9.1) (2021-05-07) ### Bug Fixes * 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) ## [1.9.0](https://github.com/denolehov/obsidian-git/compare/1.8.1...1.9.0) (2021-05-02) ### Features * add env var OBSIDIAN_GIT for scripting ([2b76097](https://github.com/denolehov/obsidian-git/commit/2b7609774cfd8689297c23ea672264cea6255409)) * 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) * auto pull/backup outlives session ([7ec00e7](https://github.com/denolehov/obsidian-git/commit/7ec00e7cfd113aeb827f282d765ca061d85235a6)), closes [#68](https://github.com/denolehov/obsidian-git/issues/68) ### [1.8.1](https://github.com/denolehov/obsidian-git/compare/1.8.0...1.8.1) (2021-04-12) ### Bug Fixes * add promise queue ([f95d71a](https://github.com/denolehov/obsidian-git/commit/f95d71a5475107dbf1bbacfb3bdb4e74fd190d15)), closes [#61](https://github.com/denolehov/obsidian-git/issues/61) ## [1.8.0](https://github.com/denolehov/obsidian-git/compare/1.7.0...1.8.0) (2021-03-31) ### Features * open not supported files in changed files modal in default app ([93930e0](https://github.com/denolehov/obsidian-git/commit/93930e079384d0ae2ed165e94241dc1d0acee82a)) ## [1.7.0](https://github.com/denolehov/obsidian-git/compare/1.6.1...1.7.0) (2021-03-24) ### Features * add git initialization and conflict files status to statusbar ([ba0ef11](https://github.com/denolehov/obsidian-git/commit/ba0ef11a5abcc8ff11d9e33ca8157a283d06920b)) * auto pull on specified interval ([2aa7fb8](https://github.com/denolehov/obsidian-git/commit/2aa7fb866e41c1f7170b723a35d9acd2942921b0)), closes [#59](https://github.com/denolehov/obsidian-git/issues/59) * conflict files support ([358dc6e](https://github.com/denolehov/obsidian-git/commit/358dc6e492e6ef8156687535d14a9070ebadfb30)), closes [#38](https://github.com/denolehov/obsidian-git/issues/38) * list changed files ([5e28b94](https://github.com/denolehov/obsidian-git/commit/5e28b9449f3f7f978fe825fb102b61fb27d191e4)) ### Bug Fixes * conflict files pane was opened on pull error ([8d43e7b](https://github.com/denolehov/obsidian-git/commit/8d43e7b32e7b5082c3518537ce32c0627b35dfb2)) ### [1.6.1](https://github.com/denolehov/obsidian-git/compare/1.6.0...1.6.1) (2021-03-17) ### Bug Fixes * 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) ## [1.6.0](https://github.com/denolehov/obsidian-git/compare/1.5.0...1.6.0) (2021-03-15) ### Features * commit changes with specified message ([e992199](https://github.com/denolehov/obsidian-git/commit/e9921994e135ac01f5eda8f23d7c4db312cedd05)), closes [#26](https://github.com/denolehov/obsidian-git/issues/26) * 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) * pull before push ([30d8798](https://github.com/denolehov/obsidian-git/commit/30d8798d433f080404bd22c8a33a1ea49b37648f)), closes [#43](https://github.com/denolehov/obsidian-git/issues/43) ### Bug Fixes * 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) * git repository check ([98fa9f7](https://github.com/denolehov/obsidian-git/commit/98fa9f758f9b08546c0c9319a14fd25b85af4503)) * initialization procedure ([1d71418](https://github.com/denolehov/obsidian-git/commit/1d714181d8967fa6089cd380b879ce652332a3fa)), fixes [#27](https://github.com/denolehov/obsidian-git/issues/27) * lastUpdate gets changed when no changes are detected ([71d2a59](https://github.com/denolehov/obsidian-git/commit/71d2a59f1d5ea7f7fd08e77b1802a47d0aae3f46)) * needed tracking branch to commit ([619c5d1](https://github.com/denolehov/obsidian-git/commit/619c5d182e95c5f1ca946c56d8c002e6b3f09daf)) ## [1.5.0](https://github.com/denolehov/obsidian-git/compare/v1.2.0...v1.5.0) (2020-12-08) ### Features * add {{files}} template placeholder ([64adf0f](https://github.com/denolehov/obsidian-git/commit/64adf0f464cfdad544fec225e52798ccbb565d4d)) * add option to toggle pushing to remote ### Bug Fixes * 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)) * correctly update `.lastUpdate` timestamp during push/pull ([4b61297](https://github.com/denolehov/obsidian-git/commit/4b61297be84fa7940e2909ddfdd2ef1d8608e20d)) * fix plugin getting stuck at "checking repo status.." message ([4875519](https://github.com/denolehov/obsidian-git/commit/4875519f9986946f0628a343c8ffd94686b86fa4)) * fix status bar messages race conditions ([f3f0a63](https://github.com/denolehov/obsidian-git/commit/f3f0a63132e0cd38c27d0e14c08a8b7c59134a83)) ## [1.4.0](https://github.com/denolehov/obsidian-git/compare/v1.3.0...v1.4.0) (2020-11-01) ### Features * display messages in status bar (including error ones) ([e1e0fcc](https://github.com/denolehov/obsidian-git/commit/e1e0fcc26d5736637239316d5881a696f78eca30)) ## [1.3.0](https://github.com/denolehov/obsidian-git/compare/v1.2.0...v1.3.0) (2020-10-31) ### Features * add `{{numFiles}}` placeholder ([fbc6ce8](https://github.com/denolehov/obsidian-git/commit/fbc6ce85d4f6f2b183c7a41f9cbd8f2814027e92)) * add more granular customization of `{{date}}` commit message placeholder ([7063f5a](https://github.com/denolehov/obsidian-git/commit/7063f5a902c3141671ddbf3c82c2076e07cc872b)) ## [1.2.0](https://github.com/denolehov/obsidian-git/compare/v1.1.0...v1.2.0) (2020-10-31) ### Features * `master` branch is no longer hardcoded ([dc8f3bd](https://github.com/denolehov/obsidian-git/commit/dc8f3bda9751a358fdd64771eec0c6b25bb07f6d)) * allow specifying `{{date}}` placeholder in commit message ([43c5f6e](https://github.com/denolehov/obsidian-git/commit/43c5f6e509d1284411ff26332b7820710fd51c2f)) * rename "Autosave" to "Vault backup interval" ([26cd1e3](https://github.com/denolehov/obsidian-git/commit/26cd1e371ad5b7076ac1da7575983ba4f6791713)) ### Bug Fixes * fix `undefined` backup settings and rearrange settings a bit ([68f8b84](https://github.com/denolehov/obsidian-git/commit/68f8b8438c9aba3c314ee2baa857bfd1efd587d2)) * register interval functions so Obsidian properly unloads them ([717a538](https://github.com/denolehov/obsidian-git/commit/717a53811ef55907ca804ead83d7db6a4747199f)) * save settings on plugin unload ([67cd7a3](https://github.com/denolehov/obsidian-git/commit/67cd7a3f9303505b86b6399694bf1d8e4c8bff4e)) ## [1.1.0](https://github.com/denolehov/obsidian-git/compare/v1.0.0...v1.1.0) (2020-10-29) ### Features * Add "Disable notifications" setting + some minor fixes ([ec240a7](https://github.com/denolehov/obsidian-git/commit/ec240a7122656e551b93a79ad5af9b7be138b2ec)) * Add an option to automatically fetch updates from remote repository when Obsidian starts ([aa59d29](https://github.com/denolehov/obsidian-git/commit/aa59d29fb23ac5b42d8c6a644fdc413a04931966)) * Add status bar that shows status updates ([80dbf0f](https://github.com/denolehov/obsidian-git/commit/80dbf0f647fe27237bd86174feebe7987a90be63)) ## [1.0.0](https://github.com/denolehov/obsidian-git/compare/v0.0.6...v1.0.0) (2020-10-27) ### Bug Fixes * update some Notice messages ([a97c44e](https://github.com/denolehov/obsidian-git/commit/a97c44e2f5a1581e5bb8ea432deca108df8c7fde)) ### [0.0.6](https://github.com/denolehov/obsidian-git/compare/v0.0.5...v0.0.6) (2020-10-27) ### Features * Add autosave feature ([6f0d6bc](https://github.com/denolehov/obsidian-git/commit/6f0d6bc0b8b84fe6e14fcf1c85e6a6213c9da578)) ### [0.0.5](https://github.com/denolehov/obsidian-git/compare/v0.0.4...v0.0.5) (2020-10-27) ### Features * Add an ability to specify custom commit message (specified in plugin settings) ([ca67112](https://github.com/denolehov/obsidian-git/commit/ca671124c5b2dc5127b76f48ab94e63d1e2b3626)) ### [0.0.4](https://github.com/denolehov/obsidian-git/compare/v0.0.3...v0.0.4) (2020-10-27) ### Features * Improve UX a bit by showing notification of what's happening when user presses hotkey ([c562e74](https://github.com/denolehov/obsidian-git/commit/c562e746d7538923a378104d0204dad1f3f2aa61)) ### [0.0.3](https://github.com/denolehov/obsidian-git/compare/v0.0.2...v0.0.3) (2020-10-27) ### Features * add an ability to push changes to a remote repository ([f229516](https://github.com/denolehov/obsidian-git/commit/f2295165fbd77dd9ed6e4cdd2f6d085b3ee78bfe)) ### [0.0.2](https://github.com/denolehov/obsidian-git/compare/v0.0.1...v0.0.2) (2020-10-27) ### Features * Add an ability to pull changes from remote repository. ([88da6e5](https://github.com/denolehov/obsidian-git/commit/88da6e5bc01ef5066ab994e69640e0e101ed6b8f)) ### 0.0.1 (2020-10-27) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Vinzent03, Denis Olehov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Obsidian Git Plugin A 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. ## 📚 Documentation All setup instructions (including mobile), common issues, tips, and advanced configuration can be found in the 📖 [full documentation](https://publish.obsidian.md/git-doc). > Mobile users: The plugin is **highly unstable ⚠️ !** Please check the dedicated [Mobile](#-mobile-support-%EF%B8%8F--experimental) section below. ## Key Features - 🔁 **Automatic commit-and-sync** (commit, pull, and push) on a schedule. - 📥 **Auto-pull on Obsidian startup** - 📂 **Submodule support** for managing multiple repositories (desktop only and opt-in) - 🔧 **Source Control View** to stage/unstage, commit and diff files - Open it with the `Open source control view` command. - 📜 **History View** for browsing commit logs and changed files - Open it with the `Open history view` command. - 🔍 **Diff View** for viewing changes in a file - Open it with the `Open diff view` command. - 📝 **Signs in the editor** to indicate added, modified, and deleted lines/hunks (desktop only). - GitHub integration to open files and history in your browser > For detailed file history, consider pairing this plugin with the [Version History Diff](obsidian://show-plugin?id=obsidian-version-history-diff) plugin. ## UI Previews ### 🔧 Source Control View Manage your file changes directly inside Obsidian like stage/unstage individual files and commit them. ![Source Control View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/source-view.png) ### 📜 History View Show 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. ![History View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/history-view.png) ### 🔍 Diff View Compare versions with a clear and concise diff viewer. Open it from the source control view or via the `Open diff view` command. ![Diff View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/diff-view.png) ### 📝 Signs in the Editor View 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. ![Signs](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/signs.png) ## Available Commands > Not exhaustive - these are just some of the most common commands. For a full list, see the Command Palette in Obsidian. - 🔄 Changes - `List changed files`: Lists all changes in a modal - `Open diff view`: Open diff view for the current file - `Stage current file` - `Unstage current file` - `Discard all changes`: Discard all changes in the repository - ✅ Commit - `Commit`: If files are staged only commits those, otherwise commits only files that have been staged - `Commit with specific message`: Same as above, but with a custom message - `Commit all changes`: Commits all changes without pushing - `Commit all changes with specific message`: Same as above, but with a custom message - 🔀 Commit-and-sync - `Commit-and-sync`: With default settings, this will commit all changes, pull, and push - `Commit-and-sync with specific message`: Same as above, but with a custom message - `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. - 🌐 Remote - `Push`, `Pull` - `Edit remotes`: Add new remotes or edit existing remotes - `Remove remote` - `Clone an existing remote repo`: Opens dialog that will prompt for URL and authentication to clone a remote repo - `Open file on GitHub`: Open the file view of the current file on GitHub in a browser window. Note: only works on desktop - `Open file history on GitHub`: Open the file history of the current file on GitHub in a browser window. Note: only works on desktop - 🏠 Manage local repository - `Initialize a new repo` - `Create new branch` - `Delete branch` - `CAUTION: Delete repository` - 🧪 Miscellaneous - `Open source control view`: Opens side pane displaying [Source control view](#sidebar-view) - `Open history view`: Opens side pane displaying [History view](#history-view) - `Edit .gitignore` - `Add file to .gitignore`: Add current file to `.gitignore` ## 💻 Desktop Notes ### 🔐 Authentication Some Git services may require further setup for HTTPS/SSH authentication. Refer to the [Authentication Guide](https://publish.obsidian.md/git-doc/Authentication) ### Obsidian on Linux - ⚠️ Snap is not supported due to its sandboxing restrictions. - ⚠️ 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. - ✅ 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)) ## 📱 Mobile Support (⚠️ Experimental) The Git implementation on mobile is **very unstable**! I would not recommend using this plugin on mobile, but try other syncing services. One 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). > 🧪 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. ### ❌ Mobile Feature Limitations - No **SSH authentication** ([isomorphic-git issue](https://github.com/isomorphic-git/isomorphic-git/issues/231)) - Limited repo size, because of memory restrictions - No rebase merge strategy - No submodules support ### ⚠️ Performance Caveats > [!caution] > Depending on your device and available free RAM, Obsidian may > > - crash on clone/pull > - create buffer overflow errors > - run indefinitely. > > 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. ### Tips for Mobile Use: If you have a large repo/vault I recommend to stage individual files and only commit staged files. ## 🙋 Contact & Credits - The Line Authoring feature was developed by [GollyTicker](https://github.com/GollyTicker), so any questions may be best answered by her. - 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. - If you have any kind of feedback or questions, feel free to reach out via GitHub issues. ## ☕ Support If you find this plugin useful and would like to support its development, you can support me on Ko-fi. [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F195IQ5) ================================================ FILE: docs/.gitignore ================================================ .obsidian ================================================ FILE: docs/Authentication.md ================================================ --- aliases: - "04 Authentication" --- # macOS ## HTTPS Run the following to use the macOS keychain to store your credentials. ```bash git config --global credential.helper osxkeychain ``` You 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. ## SSH 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=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). # Windows ## HTTPS Ensure you are using Git 2.29 or higher and you are using Git Credential Manager as a credential helper. You 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`. ```bash git config credential.helper ``` If this doesn't output `manager`, please run `git config set credential.helper manager` Just execute any authentication command like push/pull/clone and a pop window should come up, allowing your to sign in. Alternatively, 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)., ## SSH 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=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). # Linux ## HTTPS ### Storing To 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). To 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. ```bash git config credential.helper libsecret ``` You 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. In 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. Here is an example for Ubuntu. ```bash sudo apt install libsecret-1-0 libsecret-1-dev make gcc sudo make --directory=/usr/share/doc/git/contrib/credential/libsecret # 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. git config --global credential.helper \ /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret ``` ### SSH_PASS Tools When 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. #### Native SSH_ASKPASS In 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. To 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. ``` SSH_ASKPASS=ksshaskpass ``` You should get a new window to enter your username/password when using a Git action needing authentication now. #### SSH_PASS integrated in Obsidian The 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. ## SSH With 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). ================================================ FILE: docs/Common issues.md ================================================ ## xcrun: error: invalid developer path This 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. ## Error: spansSync git ENOENT/ Cannot run Git command This 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. If you think everything is correctly set up and the error still occurs try the following: In 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: ### Windows Run `where git` in the terminal. It should return the path to the Git executable. If it fails, Git is not properly installed. ### Linux/MacOS Run `which git` in the terminal. It should return the path to the Git executable. If it fails, Git is not properly installed. ## Infinite pulling/pushing with no error That's most time caused by authentication problems. Head over to [[Authentication]] ## Bad owner or permissions on /home/\/.ssh/config Run `chmod 600 ~/.ssh/config` in the terminal. ## Files in `.gitignore` aren't ignored Since 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. It'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: 1. Run `git rm --cached ` in your terminal. The file will stay on your file system. It's just deleted in your repo. 2. The file should be listed as deleted in `git status` 3. Commit the deletion 4. Now any changes to the file are properly ignored. ## Cannot run gpg ``` Error: error: cannot run gpg: No such file or directory error: gpg failed to sign the data fatal: failed to write commit object ``` See [[Integration with other tools#GPG Signing]] on how to solve this. ## This repository is configured for Git LFS but 'git-lfs' was not found on your path. See [[Integration with other tools#Git Large File Storage]] on how to solve this. ================================================ FILE: docs/Features.md ================================================ ## Source Control View Open it using the "Open source control view" command. It lists all current changes like when you run `git status`. It provides the following features - Stage/Unstage individual files - Discard any changes to a specific file - Open the diff view for changed files - Stage/Unstage all files - Push/Pull - Commit or [[Start here#commit-and-sync|commit-and-sync]] - Switch between list and tree view using the button at the top ## History View Open 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. ## Line Authoring For each line, view the last time, it was modified: [[Line Authoring|Line Authoring]]. Technically known as `git-blame`. ## Automatic commit-and-sync See [[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. There 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. Another 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. The 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. ## Commit message The plugin uses [momentjs](https://momentjs.com/) for formatting the date, so read through their documentation on how to construct your date placeholder. ## Submodules Support Since 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. Additional **requirements**: - Checked out branch (not just a commit as it is when running `git submodule update --init`) - Tracking branch is set up, so that `git push` works - Tracking branch needs to be fetched, so that a `git diff` with the branch works ================================================ FILE: docs/Getting Started.md ================================================ # Desktop You 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]]. ## Create new local repository 1. Follow the [[Installation]] instructions for your operating system 2. Call the `Initialize a new repo` command 3. Create your first commit by creating some files and calling the `Commit all changes with specific message` command 4. If you want to Setup to push it to a remote repository like to GitHub: 1. Setup [[Authentication]] 2. 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]]. 3. 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. ## For existing remote repository To 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. `https`: `https://github.com//.git` `ssh`: `git@github.com:/.git` 1. Follow the [[Installation]] instructions for your operating system 2. Setup [[Authentication]] 3. Git can only clone a remote repo in a new folder. Thus you have two options - Use the "Clone an exising remote repository" command to clone your repo into a subfolder of your vault. You then have again two choices - Move all your files from the new folder (including `.git` !) into your vault root. - Open your new subfolder as a new vault. You may have to install the plugin again. - Run `git clone ` in the command line wherever you want your vault to be located. 4. Read on how to best configure your [[Tips-and-Tricks#Gitignore|.gitignore]] > [!info] iCloud and Git > 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. > - One solution is to put the git repository above your Obsidian vault. So that your vault is a sub directory of your git repository. > - 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: ` # Mobile The Git implementation on mobile is **very unstable**! I would not recommend using this plugin on mobile, but try other syncing services. One 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). Another alternative for iOS is [Working Copy](https://workingcopy.app/). ## Restrictions I 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. - SSH authentication is not supported ([isomorphic-git issue](https://github.com/isomorphic-git/isomorphic-git/issues/231)) - Repo size is limited, because of memory restrictions - Rebase merge strategy is not supported - Submodules are not supported ## Performance on mobile > [!danger] Warning > Depending on your device and available free RAM, Obsidian may > - crash on clone/pull > - create buffer overflow errors > - run indefinitely. > > 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. ## Start with existing remote repository ### Clone via plugin Follow these instructions for setting up an Obsidian Vault on a mobile device that is already backed up in a remote git repository. The instructions assume you are using [GitHub](https://github.com), but can be extrapolated to other providers. 1. Make sure any outstanding changes on all devices are pushed and reconciled with the remote repo. 2. Install Obsidian for Android or iOS. 3. Create a new vault (or point Obsidian to an empty directory). Do NOT select `Store in iCloud` if you are on iOS. 4. 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). - Minimal permissions required are - "Read access to metadata" - "Read and Write access to contents and commit status" 5. In Obsidian settings, enable community plugins. Browse plugins to install Git. 6. Enable Git (on the same screen) 7. Go to Options for the Git plugin (bottom of main settings page, under Community Plugins section) 8. Under the "Authentication/Commit Author" section, fill in the username on your git server and your password/personal access token. 9. Don't touch any settings under "Advanced" 10. Exit plugin settings, open command palette, choose "Git: Clone existing remote repo". 11. 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//.git` - E.g. `https://github.com/denolehov/obsidian-git.git` 12. Follow instructions to determine the folder to place repo in and whether an `.obsidian` directory already exits. 13. Clone should start. Popup notifications (if not disabled) will display the progress. Do not exit until a popup appears requesting that you "Restart Obsidian". ### Clone via Working Copy on iOS Depending 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. 1. Make sure any outstanding changes on all devices are pushed and reconciled with the remote repo. 2. Install Obsidian for Android or iOS. 3. Create a new vault (or point Obsidian to an empty directory). Do NOT select `Store in iCloud` if you are on iOS. 4. 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). - Minimal permissions required are - "Read access to metadata" - "Read and Write access to contents and commit status" 5. Swipe up and away Obsidian to fully close it. Open Working Copy app. 6. 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. 7. Open Files app. 8. Copy the repo from Working Copy. Delete the vault from Obsidian and paste the repo there (repo has the same name as the vault). 9. Open Obsidian. 10. All your cloned files should be visible. 11. Install and enable the Git plugin. 12. Add your name/email to the "Authentication/Commit Author" section in the plugin settings. 13. Use the command palette to call the "Pull" command. ## Start with new repo Similar 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]]. ================================================ FILE: docs/Installation.md ================================================ --- aliases: - 02 Installation --- > [!important] > Although the plugin itself is desktop platform independent, an incorrect installation of Obsidian or Git may break the plugin. ## Plugin installation ### From within Obsidian Go to "Settings" -> "Community plugins" -> "Browse", search for "Git", install and enable it. ### Manual 1. Download `obsidian-git-.zip` from the [latest release](https://github.com/Vinzent03/obsidian-git/releases/latest) 2. Unpack the zip in `/.obsidian/plugins/obsidian-git` 3. Restart Obsidian 4. Go to settings and disable restricted mode 5. Enable `Git` # Windows Installing [GitHub Desktop](https://github.com/apps/desktop) is **not** enough! You need to install regular Git as well. ## Git installation > [!info] > Ensure you are using Git 2.29 or higher. Install Git from the official [website](https://git-scm.com/download/win) with all default settings. Make sure you have `3rd-party software` access enabled. ![[third-party-windows-git.png]] Enable Git Credential Manager. You can verify this for existing installations by executing the following. It should ouput `manager`. ```bash git config credential.helper ``` ![[credential-manager-windows-git.png]] # Linux ## Obsidian installation Known **supported** Obsidian installation methods: - AppImage Known **not fully supported** package managers - Snap (Snap puts Obsidian in a kind of sandbox, so that Obsidian can't access Git) - [Flatpak](https://flathub.org/apps/details/md.obsidian.Obsidian) can access Git, but not all system files, so it's not recommended. If you installed Obsidian a while ago via **Flatpak**, and it doesn't work, please run the following snippet. ``` $ flatpak update md.obsidian.Obsidian $ flatpak override --reset md.obsidian.Obsidian $ flatpak run md.obsidian.Obsidian ``` [Source of this snippet](https://github.com/flathub/md.obsidian.Obsidian/issues/5#issuecomment-736974662) # MacOS ## Git Installation In 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) ## Keychain Run the following to use the macOS keychain to store your credentials. ```zsh git config --global credential.helper osxkeychain ``` >[!info] > 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. ================================================ FILE: docs/Integration with other tools.md ================================================ 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. # Git Large File Storage Git Large File Storage is supported, but may need a bit configuration for the plugin to find the `git-lfs` executable. ## MacOS 1. Make sure to install [git-lfs](https://git-lfs.com/) using `brew install git-lfs`. - This will install `git-lfs` to `/opt/homebrew/bin/`, which is probably not in your `PATH` environment variable when using Obsidian. 2. To make `/opt/homebrew/bin/` available in Obsidian, add `/opt/homebrew/bin/` to the "Additional PATH environment variables paths" setting under "Advanced". 3. Restart Obsidian. ## Linux 1. Make sure to install [git-lfs](https://git-lfs.com/). - 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. 2. Run `which git-lfs` in your terminal to get the installation path. It should output something of the form `/git-lfs. 2. Add the `` part of the previous step to the "Additional PATH environment variables paths" setting under "Advanced". 3. Restart Obsidian. ## Windows There 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. # GPG Signing GitHub 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. One issue you might encounter though is the following: ``` Error: error: cannot run gpg: No such file or directory error: gpg failed to sign the data fatal: failed to write commit object ``` This 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`. - You can either add that to the "Additional PATH environment variables" plugin setting to provide the gpg binary to your plugin installation only. - Or set it in your Git config via `git config --global gpg.program ` to set the gpg binary globally for all git repositories. Please create an issue if you encounter any issues and the documentation needs to be improved. ================================================ FILE: docs/Line Authoring.md ================================================ # Quick User Guide A quick showcase of all functionality. This feature is based on [git-blame](https://git-scm.com/docs/git-blame). ℹ️ The line author view only works in Live-Preview and Source mode - not in Reading mode. ℹ️ Currently, only Obsidian on desktop is supported. ℹ️ The recently released Obsidian v1.0 is fully supported. The images and GIFs in this document are however not yet updated. ## Activate ![](assets/line-author-activate.png) It can also be activated via Command Palette `Git: Toggle line author information`. ## Default line author information ![](assets/line-author-default.png) Shows the initials of the author as well as the authoring date in `YYYY-MM-DD` format. The `*` indicates, that the author and committer (or their timestamps) are different - i.e., due to a rebase. ## Commit hash and full name ![](assets/line-author-commit-hash-full-name.png) via config ![](assets/line-author-commit-hash-full-name-config.png) ## Natural language dates ![](assets/line-author-natural-language-dates.png) ## Custom date formats ![](assets/line-author-custom-dates.png) via config ![](assets/line-author-custom-dates-config.png) ## Commit time in local/author/UTC time-zone **UTC+0000/Z** The simplest option to start with is showing the time in `UTC+00:00/Z` time-zone. This is independent of both your local and the author's time-zone. It is shown with a suffix `Z` to avoid confusion with local time. ![](assets/line-author-tz-utc0000.png) This is the time displayed in the guter is the same for all users. **My local (default)** By 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. ![](assets/line-author-tz-viewer-plus0100.png) Note, how the displayed time is `1h` ahead of the above `UTC+0000` time. **Author's local** Alternatively, 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?` This is independent of your local time-zone and the same time is displayed for all users. ![](assets/line-author-tz-author-local.png) **Configuration** ![](assets/line-author-tz-config.png) ## Age-based gutter colors The line gutter color is based on the age of the commit. It adapts to the dark/light mode automatically. ![](assets/line-author-dark-light.gif) Red-ish means newer and blue-ish means older. All commits at and above a certain maximum coloring age (configurable; default `1 year`) get the same strongest blue-ish color. The colors are configurable and the defaults are chosen to be accessible. ![](assets/line-author-color-config.png) ## Adjust text color CSS based on theme By default, the gutter text color uses `var(--text-muted)` which is whatever is defined by your theme. You can however, change it to a different CSS color or variable. ![](assets/line-author-text-color.png) Example: | `var(--text-muted)` | `var(--text-normal)` | |----------------------------------------------|-----------------------------------------------| | ![](assets/line-author-text-color-muted.png) | ![](assets/line-author-text-color-normal.png) | ## Copy commit hash ![](assets/line-author-copy-commit-hash.png) ## Quick configure gutter ![](assets/line-author-quick-configure-gutter.gif) ## New/uncommitted lines and files show `+++` ![](assets/line-author-untracked.png) ## Follow lines across cut-copy-paste-ing within same commit / all commits By default, each line shows the last commit, where it was changed. This means, that cut-copy-paste-ing lines will show the new commit, even though it was not originally written in that commit. ![](assets/line-author-follow-no-follow.png) However, if for instance following is set to `all commits`, then this is the result: ![](assets/line-author-follow-all-commits.png) Configuration: ![](assets/line-author-follow-config.png) ## Soft and unintrusive ansynchronous view updates Since computing the line author information takes time (due to a `git blame` shell invocation) the result appears delayed. To minimize distraction and improve user experience, the view is updated in a soft and unintrusive manner. When opening a file, a placeholder is shown meanwhile: ![](assets/line-author-soft-unintrusive-ux.gif) While editing, a placeholder is shown as well until the file is saved and the line author information is computed. ![](assets/line-author-soft-unintrusive-ux-editing.gif) ## Multi-line block support The markdown rendering of multiple lines as a combined block is also supported. In this case the newest of all lines is shown in the gutter. ![](assets/line-author-multi-line-newest.gif) ## Ignore whitespace and newlines This can be activated in the settings. | **Original** | **Changed with preserved whitespace** | **Changed with ignored whitespace** | | ---------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------- | | ![](assets/line-author-ignore-whitespace-before.png) | ![](assets/line-author-ignore-whitespace-preserved.png) | ![](assets/line-author-ignore-whitespace-ignored.png) | Note, how ignoring the whitespace does not mark the indented lines as changes, as only additional whitespace was added. ## Submodules support Line author information is fully supported in submodules. ================================================ FILE: docs/Start here.md ================================================ --- aliases: - "01 Start here" --- # Git plugin Documentation ## Topics - [[Installation|Installation]] - [[Getting Started|Getting Started]] - [[Authentication|Authentication]] - [[Integration with other tools]] - [[Features|Features]] - [[Tips-and-Tricks|Tips-and-Tricks]] - [[Common issues|Common Issues]] - [[Line Authoring|Line Authoring]] > [!warning] Obsidian installation on Linux > Please don't use Flatpak or Snap to install Obsidian on Linux. Learn more [[Installation#Linux|here]] ![[Getting Started#Performance on mobile]] ## What is Git? Git 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). > [!info] Git/GitHub is not a syncing service! > 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. You 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. Git 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). Git 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. ## Terminology and concepts ### Backup - no longer in use For simplification, the term "Backup" refers to staging everything -> committing -> pulling -> pushing. ### Sync Syncing 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. ### Commit-and-sync Commit-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. ================================================ FILE: docs/Tips-and-Tricks.md ================================================ # Tips and Tricks ## Gitignore To exclude cache files from the repository, create `.gitignore` file in the root of your vault and add the lines in the snippet below. There's also the `Edit .gitignore` command that will open the file in a modal. ``` # to exclude Obsidian's settings (including plugin and hotkey configurations) .obsidian/ # to only exclude plugin configuration. Might be useful to prevent some plugin from exposing sensitive data .obsidian/plugins # OR only to exclude workspace cache .obsidian/workspace.json # to exclude workspace cache specific to mobile devices .obsidian/workspace-mobile.json # Add below lines to exclude OS settings and caches .trash/ .DS_Store ``` ## Usage with Obsidian Sync A 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. ### Use Git plugin only on one device In 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. ### Use Git plugin, but not to pull your files Another 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. ================================================ FILE: docs/dev/LineAuthorFeature.md ================================================ # Line Authoring Feature - Developer Documentation - This feature was developed by [GollyTicker](https://github.com/GollyTicker). - [Feature documentation for users](https://publish.obsidian.md/git-doc/Line+Authoring) ## Architecture To 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/): - Architecture Overview > (everything) - Data Model - Configuration - Facets - Transactions - View > (intro) - Extending Codemirror - State Fields Furthermore, the following concepts are necessary: - [EditorState](https://codemirror.net/docs/ref/#state.EditorState) - [State Field](https://codemirror.net/docs/ref/#state.StateField) - [Transaction](https://codemirror.net/docs/ref/#state.Transaction) - [Creating a transaction](https://codemirror.net/docs/ref/#state.EditorState.update) - [Annotation within a transaction](https://codemirror.net/docs/ref/#state.Annotation) - [ChangeSet](https://codemirror.net/docs/ref/#state.ChangeSet) (for the unsaved changes gutter update) - [Exmaple: Document Changes](https://codemirror.net/examples/change/) - [Example: Configuratoin and Extension](https://codemirror.net/examples/config/) Given 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. When doing this, we need to integrate with the declarative modeling of Codemirror - and have its views automatically updated, when we change its associated data. We achieve the goal via the following steps: 1. Every new editor pane in Obsidian subscribes itself by its filepath ([LineAuthoringSubcriber](/src/lineAuthor/control.ts)) and listens in an internal publish-subscriber-model ([eventsPerFilepath.ts](/src/lineAuthor/eventsPerFilepath.ts)) for updates on that filepath. 2. Any changed file in the Obsidian Vault or anytime when a new file is opened, [lineAuthorProvider](/src/lineAuthor/lineAuthoProvider.ts) initiates the asynchronous computation of the [LineAuthoring](/src/lineAuthor/model.ts) via [simpleGit.ts](/src/simpleGit.ts) - which parses the output of `git-blame`. 3. Once the `LineAuthoring` is computed, the publish-subscriber-model is notified of the new value for the corresponding filepath. 4. The notified `LineAuthoringSubcriber` creates a new transaction (via [newComputationResultAsTransaction](/src/lineAuthor/model.ts)) containing the `LineAuthoring`. 5. The `LineAuthoringSubscriber` [dispatches the transaction on the current EditorView](https://codemirror.net/docs/ref/#view.EditorView.dispatch). 6. The [StateField's update](https://codemirror.net/docs/ref/#state.StateField^define^config.update) method is called by Codemirror due to the dispatched transaction. The [lineAuthorState](/src/lineAuthor/model.ts) updates itself with the newest `LineAuthoring`, if it one was provided in the transaction. 7. The [lineAuthorGutter](/src/lineAuthor/view/view.ts) is automatically re-rendered, due to the dispatch and the changes of the state-fields. The re-rendering now accesses the newest state-field values - resulting in a new DOM. ## Development You can use this test-vault https://github.com/GollyTicker/obsidian-git-test-vault-online. Once the watchmode npm is started, one can simply open the `test-vault` in Obsidian to test the plugin. The Git plugin files are symbolic links to the automatically re-compiled files at repository root level. One can additionally use the [docker-setup from this branch for a reproduceable developer setup](https://github.com/GollyTicker/obsidian-git/tree/docker-setup). ## Edge cases and error cases These cases should be tested, when changes to this feature have been made. - running outside of a git repository - opening an untracked file - opening and closing obsidian windows of panes/notes - notes with a starting "--" in their filename - special characters in filenames - unicode filenames - empty file - file with populated last line - multi-line block with differeing line commits - examples for moving/copy-following - submodules - vault root != repository root - error in git blame result - open multiple files simultanously - open same file multiple times - and edit - open same files in multiple windows - and edit - open empty tracked file and make edits. quick update should respond sensibly - open file in a large, complex real-world vault with unknown characteristics (the private vault of the developer GollyTicker suffices) and repeatedly press Enter in a tracked file. - We expect no errors, but after adding the unsaved changed gutter update feature, an early bu was present, where errors would occur during rendering and the view would become messed up. - UI should render correctly regardless of whether line numbers are shown as well or not. - [[see obsidan forum discussion](https://forum.obsidian.md/t/added-editor-gutter-overlaps-and-obscures-editor-content/45217) - indentation changes and changes after last line (without trailing newline) with 'Ignored whitespace' enabled/disabled - [Unsaved Changes Gutter Update Scenario](#unsaved-changes-gutter-update-scenario) - commit file in a different time-zone than the current Obsidian user - check that time-zone "local" formatting is correct - time-zone "UTC" should always show the same result regardless of the local time-zone - line authoring id correctly uses submodule HEAD revision rather than super-project. - 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. 1. remember the lineAuthoringId A for a file in a submodule in the vault. - it uses the HEAD of the git super-project rather than of the submodule the file is contained in. 2. add a few lines in the file. The plugin will correctly detect the changed file-contents hash, which will trigger re-computation and re-render. 3. commit the changes in the submodule - without making a corresponding commit in the super-project. 4. Close the file and re-open it in Obsidian. - In the submodule, the HEAD has changed - but not in the super-project. - Since the file path and file contents are same after committing, they haven't changed. - The current cache key doesn't detect this change and hence the view isn't updated. - Reloading Obsidian entirely will evict the cache - and the line authoring will be shown correctly again. ### Unsaved Changes Gutter Update Scenario This scenario contains two main cases to test: #### 1. Untracked file 1. Open an untracked file. It should show +++ everywhere. 2. Make insertions, deletions and in-line changes. It should always show +++. #### 2. Tracked file 1. Open a tracked file with different line author dates and colors 2. Make insertions, deletions and in-line changes. - It should first show % until the changes are saved and the line authoring is computed. - The % should preserving the color of the changed line and insertions/deletions should shift the line authoring for subsequent lines accordingly 3. Make multi-line insertions, deletions and in-line changes (e.g. via cut-copy-pasting of blocks of text). - Hint: Use Ctrl+Z as well. - The behavior should be same as above. 4. Make changes at the intersection of unsaved and saved changes. The result should be consistent with above. ## Potential Future Improvements - show commit info when click/hover on gutter - show / highlight diff when hover/click on gutter - small tooltip widget when hovering/right-clicking on line author gutter with author/hash, etc. - show deleted lines - interpret new 'newline' at end of line as non-change to make gutter change marking more intuitive. - [one option is to add a setting which switches between compatibility-mode and comfort-mode](https://github.com/denolehov/obsidian-git/pull/288) - distinguish untracked and changed line (e.g. "~" and "+") - use addMomentFormat in settings.ts when configuring the line author date format. - main.ts: refreshUpdatedHead(): Detect, if the head has changed from outside of Git (e.g. script) and run this callback then. - Avoid "Uncaught illegal access error" when closing a separate Obsidian window. It doesn't seem to have any impact on UX yet though... - Unique initials option: [work in progress branch](https://github.com/GollyTicker/obsidian-git/tree/line-author-unique-initials) ================================================ FILE: esbuild.config.mjs ================================================ import esbuild from "esbuild"; import esbuildSvelte from "esbuild-svelte"; import process from "process"; import { sveltePreprocess } from "svelte-preprocess"; const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source visit the plugins github repository (https://github.com/denolehov/obsidian-git) */ `; const prod = process.argv[2] === "production"; const context = await esbuild.context({ banner: { js: banner, }, entryPoints: ["src/main.ts"], bundle: true, external: [ "obsidian", "electron", "child_process", "fs", "os", "path", "moment", "node:events", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr", ], format: "cjs", target: "es2018", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, platform: "browser", minify: prod, conditions: [prod ? "production" : "development"], // https://www.npmjs.com/package/esm-env plugins: [ esbuildSvelte({ compilerOptions: { css: "injected", dev: !prod, }, filterWarnings: (warning) => { if (warning.code.startsWith("a11y-")) return false; return true; }, preprocess: sveltePreprocess(), }), ], inject: ["polyfill_buffer.js"], outfile: "main.js", }); if (prod) { await context.rebuild(); process.exit(0); } else { await context.watch(); } ================================================ FILE: eslint.config.mjs ================================================ import svelteParser from "svelte-eslint-parser"; import tsParser from "@typescript-eslint/parser"; import eslint from "@eslint/js"; import tseslint from "typescript-eslint"; import eslintPluginSvelte from "eslint-plugin-svelte"; import { defineConfig } from "eslint/config"; export default defineConfig( { ignores: ["**/node_modules/", "**/main.js"], }, eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, ...eslintPluginSvelte.configs["flat/prettier"], { languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, }, }, rules: { "@typescript-eslint/no-unused-vars": [ "error", { args: "all", argsIgnorePattern: "^_", caughtErrors: "all", caughtErrorsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_", varsIgnorePattern: "^_", ignoreRestSiblings: true, }, ], }, }, { files: ["**/*.svelte"], languageOptions: { parser: svelteParser, parserOptions: { extraFileExtensions: [".svelte"], parser: tsParser, }, }, rules: { "no-undef": "off", }, } ); ================================================ FILE: manifest.json ================================================ { "author": "Vinzent", "authorUrl": "https://github.com/Vinzent03", "id": "obsidian-git", "name": "Git", "description": "Integrate Git version control with automatic backup and other advanced features.", "isDesktopOnly": false, "fundingUrl": "https://ko-fi.com/vinzent", "version": "2.38.0" } ================================================ FILE: package.json ================================================ { "name": "obsidian-git", "version": "2.38.0", "description": "Integrate Git version control with automatic backup and other advanced features in Obsidian.md", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs dev", "build": "node esbuild.config.mjs production", "release": "standard-version", "lint": "eslint src", "format": "prettier --check src", "tsc": "tsc --noEmit", "svelte": "svelte-check", "all": "pnpm run tsc && pnpm run svelte && pnpm run format && pnpm run lint" }, "keywords": [], "author": "Vinzent03", "license": "MIT", "standard-version": { "t": "" }, "engines": { "node": ">=18", "pnpm": ">=9" }, "devDependencies": { "@eslint/js": "^9.39.2", "@types/debug": "^4.1.12", "@types/deep-equal": "^1.0.4", "@types/diff": "^5.2.3", "@types/diff3": "^0.0.2", "@types/node": "^22.19.10", "@typescript-eslint/parser": "8.47.0", "esbuild": "^0.24.2", "esbuild-svelte": "^0.8.2", "eslint": "^9.39.2", "eslint-plugin-svelte": "^2.46.1", "obsidian": "^1.11.4", "prettier": "3.3.2", "prettier-plugin-svelte": "^3.4.1", "scss": "^0.2.4", "standard-version": "^9.5.0", "svelte-check": "^4.3.6", "svelte-eslint-parser": "^0.43.0", "svelte-preprocess": "^6.0.3", "tslib": "^2.8.1", "typescript": "5.8.3", "typescript-eslint": "^8.54.0" }, "dependencies": { "@codemirror/commands": "^6.10.2", "@codemirror/merge": "^6.12.0", "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.39.13", "buffer": "^6.0.3", "codemirror": "^6.0.2", "css-color-converter": "^2.0.0", "debug": "^4.4.3", "deep-equal": "^2.2.3", "diff": "^8.0.3", "diff2html": "^3.4.56", "diff3": "^0.0.4", "isomorphic-git": "^1.36.3", "js-sha256": "^0.9.0", "simple-git": "github:Vinzent03/git-js#release", "supports-color": "^9.4.0", "svelte": "^5.50.0" }, "moduleFileExtensions": [ "js", "ts", "svelte" ] } ================================================ FILE: polyfill_buffer.js ================================================ import { Platform } from 'obsidian'; let buffer; if (Platform.isMobileApp) { buffer = require('buffer/index.js').Buffer } else { buffer = global.Buffer } export const Buffer = buffer; ================================================ FILE: src/automaticsManager.ts ================================================ import { debounce } from "obsidian"; import type ObsidianGit from "./main"; export default class AutomaticsManager { private timeoutIDCommitAndSync?: number; private timeoutIDPush?: number; private timeoutIDPull?: number; constructor(private readonly plugin: ObsidianGit) {} private saveLastAuto(date: Date, mode: "backup" | "pull" | "push") { if (mode === "backup") { this.plugin.localStorage.setLastAutoBackup(date.toString()); } else if (mode === "pull") { this.plugin.localStorage.setLastAutoPull(date.toString()); } else if (mode === "push") { this.plugin.localStorage.setLastAutoPush(date.toString()); } } private loadLastAuto(): { backup: Date; pull: Date; push: Date } { return { backup: new Date( this.plugin.localStorage.getLastAutoBackup() ?? "" ), pull: new Date(this.plugin.localStorage.getLastAutoPull() ?? ""), push: new Date(this.plugin.localStorage.getLastAutoPush() ?? ""), }; } async init() { await this.setUpAutoCommitAndSync(); const lastAutos = this.loadLastAuto(); if ( this.plugin.settings.differentIntervalCommitAndPush && this.plugin.settings.autoPushInterval > 0 ) { const diff = this.diff( this.plugin.settings.autoPushInterval, lastAutos.push ); this.startAutoPush(diff); } if (this.plugin.settings.autoPullInterval > 0) { const diff = this.diff( this.plugin.settings.autoPullInterval, lastAutos.pull ); this.startAutoPull(diff); } } unload() { this.clearAutoPull(); this.clearAutoPush(); this.clearAutoCommitAndSync(); } /** * Clears all timers and sets all timers to their current settings. * * This does not calculate any differences to last autos or commits. * Should only be used when settings are changed. */ reload(...type: ("commit" | "push" | "pull")[]) { if (this.plugin.localStorage.getPausedAutomatics()) return; if (type.contains("commit")) { this.clearAutoCommitAndSync(); if (this.plugin.settings.autoSaveInterval > 0) { this.startAutoCommitAndSync( this.plugin.settings.autoSaveInterval ); } } if (type.contains("push")) { this.clearAutoPush(); if ( this.plugin.settings.differentIntervalCommitAndPush && this.plugin.settings.autoPushInterval > 0 ) { this.startAutoPush(this.plugin.settings.autoPushInterval); } } if (type.contains("pull")) { this.clearAutoPull(); if (this.plugin.settings.autoPullInterval > 0) { this.startAutoPull(this.plugin.settings.autoPullInterval); } } } /** * Starts the auto commit-and-sync with the correct remaining time. * * Additionally, if `setLastSaveToLastCommit` is enabled, the last auto commit-and-sync * is set to the last commit time. */ private async setUpAutoCommitAndSync() { if (this.plugin.settings.setLastSaveToLastCommit) { this.clearAutoCommitAndSync(); const lastCommitDate = await this.plugin.gitManager.getLastCommitTime(); if (lastCommitDate) { this.saveLastAuto(lastCommitDate, "backup"); } } if (!this.timeoutIDCommitAndSync && !this.plugin.autoCommitDebouncer) { const lastAutos = this.loadLastAuto(); if (this.plugin.settings.autoSaveInterval > 0) { const diff = this.diff( this.plugin.settings.autoSaveInterval, lastAutos.backup ); this.startAutoCommitAndSync(diff); } } } private startAutoCommitAndSync(minutes?: number) { let time = (minutes ?? this.plugin.settings.autoSaveInterval) * 60000; if (this.plugin.settings.autoBackupAfterFileChange) { if (minutes === 0) { this.doAutoCommitAndSync(); } else { this.plugin.autoCommitDebouncer = debounce( () => this.doAutoCommitAndSync(), time, true ); } } else { // max timeout in js if (time > 2147483647) time = 2147483647; this.timeoutIDCommitAndSync = window.setTimeout( () => this.doAutoCommitAndSync(), time ); } } // This is used for both auto commit-and-sync and commit only private doAutoCommitAndSync(): void { this.plugin.promiseQueue.addTask( async () => { // Re-check if the auto commit should run now or be postponed, // because the last commit time has changed if (this.plugin.settings.setLastSaveToLastCommit) { const lastCommitDate = await this.plugin.gitManager.getLastCommitTime(); if (lastCommitDate) { this.saveLastAuto(lastCommitDate, "backup"); const diff = this.diff( this.plugin.settings.autoSaveInterval, lastCommitDate ); if (diff > 0) { this.startAutoCommitAndSync(diff); // Return false to mark the next iteration // already being scheduled return false; } } } const onlyStaged = this.plugin.settings.autoCommitOnlyStaged; if (this.plugin.settings.differentIntervalCommitAndPush) { await this.plugin.commit({ fromAuto: true, onlyStaged }); } else { await this.plugin.commitAndSync({ fromAutoBackup: true, onlyStaged, }); } return true; }, (schedule) => { // Don't schedule if the next iteration is already scheduled if (schedule !== false) { this.saveLastAuto(new Date(), "backup"); this.startAutoCommitAndSync(); } } ); } private startAutoPull(minutes?: number) { let time = (minutes ?? this.plugin.settings.autoPullInterval) * 60000; // max timeout in js if (time > 2147483647) time = 2147483647; this.timeoutIDPull = window.setTimeout(() => this.doAutoPull(), time); } private doAutoPull(): void { this.plugin.promiseQueue.addTask( () => this.plugin.pullChangesFromRemote(), () => { this.saveLastAuto(new Date(), "pull"); this.startAutoPull(); } ); } private startAutoPush(minutes?: number) { let time = (minutes ?? this.plugin.settings.autoPushInterval) * 60000; // max timeout in js if (time > 2147483647) time = 2147483647; this.timeoutIDPush = window.setTimeout(() => this.doAutoPush(), time); } private doAutoPush(): void { this.plugin.promiseQueue.addTask( () => this.plugin.push(), () => { this.saveLastAuto(new Date(), "push"); this.startAutoPush(); } ); } private clearAutoCommitAndSync(): boolean { let wasActive = false; if (this.timeoutIDCommitAndSync) { window.clearTimeout(this.timeoutIDCommitAndSync); this.timeoutIDCommitAndSync = undefined; wasActive = true; } if (this.plugin.autoCommitDebouncer) { this.plugin.autoCommitDebouncer?.cancel(); this.plugin.autoCommitDebouncer = undefined; wasActive = true; } return wasActive; } private clearAutoPull(): boolean { if (this.timeoutIDPull) { window.clearTimeout(this.timeoutIDPull); this.timeoutIDPull = undefined; return true; } return false; } private clearAutoPush(): boolean { if (this.timeoutIDPush) { window.clearTimeout(this.timeoutIDPush); this.timeoutIDPush = undefined; return true; } return false; } /** * Calculates the minutes until the next auto action. >= 0 * * This is done by the difference between the setting and the time since the last auto action, but at least 0. */ private diff(setting: number, lastAuto: Date) { const now = new Date(); const diff = setting - Math.round((now.getTime() - lastAuto.getTime()) / 1000 / 60); return Math.max(0, diff); } } ================================================ FILE: src/commands.ts ================================================ import { Notice, Platform, TFolder, WorkspaceLeaf } from "obsidian"; import { HISTORY_VIEW_CONFIG, SOURCE_CONTROL_VIEW_CONFIG } from "./constants"; import { SimpleGit } from "./gitManager/simpleGit"; import ObsidianGit from "./main"; import { openHistoryInGitHub, openLineInGitHub } from "./openInGitHub"; import { ChangedFilesModal } from "./ui/modals/changedFilesModal"; import { GeneralModal } from "./ui/modals/generalModal"; import { IgnoreModal } from "./ui/modals/ignoreModal"; import { assertNever } from "./utils"; import { togglePreviewHunk } from "./editor/signs/tooltip"; export function addCommmands(plugin: ObsidianGit) { const app = plugin.app; plugin.addCommand({ id: "edit-gitignore", name: "Edit .gitignore", callback: async () => { const path = plugin.gitManager.getRelativeVaultPath(".gitignore"); if (!(await app.vault.adapter.exists(path))) { await app.vault.adapter.write(path, ""); } const content = await app.vault.adapter.read(path); const modal = new IgnoreModal(app, content); const res = await modal.openAndGetReslt(); if (res !== undefined) { await app.vault.adapter.write(path, res); await plugin.refresh(); } }, }); plugin.addCommand({ id: "open-git-view", name: "Open source control view", callback: async () => { const leafs = app.workspace.getLeavesOfType( SOURCE_CONTROL_VIEW_CONFIG.type ); let leaf: WorkspaceLeaf; if (leafs.length === 0) { leaf = app.workspace.getRightLeaf(false) ?? app.workspace.getLeaf(); await leaf.setViewState({ type: SOURCE_CONTROL_VIEW_CONFIG.type, }); } else { leaf = leafs.first()!; } await app.workspace.revealLeaf(leaf); // Is not needed for the first open, but allows to refresh the view // per hotkey even if already opened app.workspace.trigger("obsidian-git:refresh"); }, }); plugin.addCommand({ id: "open-history-view", name: "Open history view", callback: async () => { const leafs = app.workspace.getLeavesOfType( HISTORY_VIEW_CONFIG.type ); let leaf: WorkspaceLeaf; if (leafs.length === 0) { leaf = app.workspace.getRightLeaf(false) ?? app.workspace.getLeaf(); await leaf.setViewState({ type: HISTORY_VIEW_CONFIG.type, }); } else { leaf = leafs.first()!; } await app.workspace.revealLeaf(leaf); // Is not needed for the first open, but allows to refresh the view // per hotkey even if already opened app.workspace.trigger("obsidian-git:refresh"); }, }); plugin.addCommand({ id: "open-diff-view", name: "Open diff view", checkCallback: (checking) => { const file = app.workspace.getActiveFile(); if (checking) { return file !== null; } else { const filePath = plugin.gitManager.getRelativeRepoPath( file!.path, true ); plugin.tools.openDiff({ aFile: filePath, aRef: "", }); } }, }); plugin.addCommand({ id: "view-file-on-github", name: "Open file on GitHub", editorCallback: (editor, { file }) => { if (file) return openLineInGitHub(editor, file, plugin.gitManager); }, }); plugin.addCommand({ id: "view-history-on-github", name: "Open file history on GitHub", editorCallback: (_, { file }) => { if (file) return openHistoryInGitHub(file, plugin.gitManager); }, }); plugin.addCommand({ id: "pull", name: "Pull", callback: () => plugin.promiseQueue.addTask(() => plugin.pullChangesFromRemote()), }); plugin.addCommand({ id: "fetch", name: "Fetch", callback: () => plugin.promiseQueue.addTask(() => plugin.fetch()), }); plugin.addCommand({ id: "switch-to-remote-branch", name: "Switch to remote branch", callback: () => plugin.promiseQueue.addTask(() => plugin.switchRemoteBranch()), }); plugin.addCommand({ id: "add-to-gitignore", name: "Add file to .gitignore", checkCallback: (checking) => { const file = app.workspace.getActiveFile(); if (checking) { return file !== null; } else { plugin .addFileToGitignore(file!.path, file instanceof TFolder) .catch((e) => plugin.displayError(e)); } }, }); plugin.addCommand({ id: "push", name: "Commit-and-sync", callback: () => plugin.promiseQueue.addTask(() => plugin.commitAndSync({ fromAutoBackup: false }) ), }); plugin.addCommand({ id: "backup-and-close", name: "Commit-and-sync and then close Obsidian", callback: () => plugin.promiseQueue.addTask(async () => { await plugin.commitAndSync({ fromAutoBackup: false }); window.close(); }), }); plugin.addCommand({ id: "commit-push-specified-message", name: "Commit-and-sync with specific message", callback: () => plugin.promiseQueue.addTask(() => plugin.commitAndSync({ fromAutoBackup: false, requestCustomMessage: true, }) ), }); plugin.addCommand({ id: "commit", name: "Commit all changes", callback: () => plugin.promiseQueue.addTask(() => plugin.commit({ fromAuto: false }) ), }); plugin.addCommand({ id: "commit-specified-message", name: "Commit all changes with specific message", callback: () => plugin.promiseQueue.addTask(() => plugin.commit({ fromAuto: false, requestCustomMessage: true, }) ), }); plugin.addCommand({ id: "commit-smart", name: "Commit", callback: () => plugin.promiseQueue.addTask(async () => { const status = await plugin.updateCachedStatus(); const onlyStaged = status.staged.length > 0; return plugin.commit({ fromAuto: false, requestCustomMessage: false, onlyStaged: onlyStaged, }); }), }); plugin.addCommand({ id: "commit-staged", name: "Commit staged", checkCallback: function (checking) { // Don't show this command in command palette, because the // commit-smart command is more useful. Still provide this command // for hotkeys and automation. if (checking) return false; plugin.promiseQueue.addTask(async () => { return plugin.commit({ fromAuto: false, requestCustomMessage: false, }); }); }, }); if (Platform.isDesktopApp) { plugin.addCommand({ id: "commit-amend-staged-specified-message", name: "Amend staged", callback: () => plugin.promiseQueue.addTask(() => plugin.commit({ fromAuto: false, requestCustomMessage: true, onlyStaged: true, amend: true, }) ), }); } plugin.addCommand({ id: "commit-smart-specified-message", name: "Commit with specific message", callback: () => plugin.promiseQueue.addTask(async () => { const status = await plugin.updateCachedStatus(); const onlyStaged = status.staged.length > 0; return plugin.commit({ fromAuto: false, requestCustomMessage: true, onlyStaged: onlyStaged, }); }), }); plugin.addCommand({ id: "commit-staged-specified-message", name: "Commit staged with specific message", checkCallback: function (checking) { // Same reason as for commit-staged if (checking) return false; return plugin.promiseQueue.addTask(() => plugin.commit({ fromAuto: false, requestCustomMessage: true, onlyStaged: true, }) ); }, }); plugin.addCommand({ id: "push2", name: "Push", callback: () => plugin.promiseQueue.addTask(() => plugin.push()), }); plugin.addCommand({ id: "stage-current-file", name: "Stage current file", checkCallback: (checking) => { const file = app.workspace.getActiveFile(); if (checking) { return file !== null; } else { plugin.promiseQueue.addTask(() => plugin.stageFile(file!)); } }, }); plugin.addCommand({ id: "unstage-current-file", name: "Unstage current file", checkCallback: (checking) => { const file = app.workspace.getActiveFile(); if (checking) { return file !== null; } else { plugin.promiseQueue.addTask(() => plugin.unstageFile(file!)); } }, }); plugin.addCommand({ id: "edit-remotes", name: "Edit remotes", callback: () => plugin.editRemotes().catch((e) => plugin.displayError(e)), }); plugin.addCommand({ id: "remove-remote", name: "Remove remote", callback: () => plugin.removeRemote().catch((e) => plugin.displayError(e)), }); plugin.addCommand({ id: "set-upstream-branch", name: "Set upstream branch", callback: () => plugin.setUpstreamBranch().catch((e) => plugin.displayError(e)), }); plugin.addCommand({ id: "delete-repo", name: "CAUTION: Delete repository", callback: async () => { const repoExists = await app.vault.adapter.exists( `${plugin.settings.basePath}/.git` ); if (repoExists) { const modal = new GeneralModal(plugin, { options: ["NO", "YES"], placeholder: "Do you really want to delete the repository (.git directory)? plugin action cannot be undone.", onlySelection: true, }); const shouldDelete = (await modal.openAndGetResult()) === "YES"; if (shouldDelete) { await app.vault.adapter.rmdir( `${plugin.settings.basePath}/.git`, true ); new Notice( "Successfully deleted repository. Reloading plugin..." ); plugin.unloadPlugin(); await plugin.init({ fromReload: true }); } } else { new Notice("No repository found"); } }, }); plugin.addCommand({ id: "init-repo", name: "Initialize a new repo", callback: () => plugin.createNewRepo().catch((e) => plugin.displayError(e)), }); plugin.addCommand({ id: "clone-repo", name: "Clone an existing remote repo", callback: () => plugin.cloneNewRepo().catch((e) => plugin.displayError(e)), }); plugin.addCommand({ id: "list-changed-files", name: "List changed files", callback: async () => { if (!(await plugin.isAllInitialized())) return; try { const status = await plugin.updateCachedStatus(); if (status.changed.length + status.staged.length > 500) { plugin.displayError("Too many changes to display"); return; } new ChangedFilesModal(plugin, status.all).open(); } catch (e) { plugin.displayError(e); } }, }); plugin.addCommand({ id: "switch-branch", name: "Switch branch", callback: () => { plugin.switchBranch().catch((e) => plugin.displayError(e)); }, }); plugin.addCommand({ id: "create-branch", name: "Create new branch", callback: () => { plugin.createBranch().catch((e) => plugin.displayError(e)); }, }); plugin.addCommand({ id: "delete-branch", name: "Delete branch", callback: () => { plugin.deleteBranch().catch((e) => plugin.displayError(e)); }, }); plugin.addCommand({ id: "discard-all", name: "CAUTION: Discard all changes", callback: async () => { const res = await plugin.discardAll(); switch (res) { case "discard": new Notice("Discarded all changes in tracked files."); break; case "delete": new Notice("Discarded all files."); break; case false: break; default: assertNever(res); } }, }); plugin.addCommand({ id: "pause-automatic-routines", name: "Pause/Resume automatic routines", callback: () => { const pause = !plugin.localStorage.getPausedAutomatics(); plugin.localStorage.setPausedAutomatics(pause); if (pause) { plugin.automaticsManager.unload(); new Notice(`Paused automatic routines.`); } else { plugin.automaticsManager.reload("commit", "push", "pull"); new Notice(`Resumed automatic routines.`); } }, }); plugin.addCommand({ id: "raw-command", name: "Raw command", checkCallback: (checking) => { const gitManager = plugin.gitManager; if (checking) { // only available on desktop return gitManager instanceof SimpleGit; } else { plugin.tools .runRawCommand() .catch((e) => plugin.displayError(e)); } }, }); plugin.addCommand({ id: "toggle-line-author-info", name: "Toggle line author information", callback: () => plugin.settingsTab?.configureLineAuthorShowStatus( !plugin.settings.lineAuthor.show ), }); plugin.addCommand({ id: "reset-hunk", name: "Reset hunk", editorCheckCallback(checking, _, __) { if (checking) { return ( plugin.settings.hunks.hunkCommands && plugin.hunkActions.editor !== undefined ); } plugin.hunkActions.resetHunk(); }, }); plugin.addCommand({ id: "stage-hunk", name: "Stage hunk", editorCheckCallback: (checking, _, __) => { if (checking) { return ( plugin.settings.hunks.hunkCommands && plugin.hunkActions.editor !== undefined ); } plugin.promiseQueue.addTask(() => plugin.hunkActions.stageHunk()); }, }); plugin.addCommand({ id: "preview-hunk", name: "Preview hunk", editorCheckCallback: (checking, _, __) => { if (checking) { return ( plugin.settings.hunks.hunkCommands && plugin.hunkActions.editor !== undefined ); } const editor = plugin.hunkActions.editor!.editor; togglePreviewHunk(editor); }, }); plugin.addCommand({ id: "next-hunk", name: "Go to next hunk", editorCheckCallback: (checking, _, __) => { if (checking) { return ( plugin.settings.hunks.hunkCommands && plugin.hunkActions.editor !== undefined ); } plugin.hunkActions.goToHunk("next"); }, }); plugin.addCommand({ id: "prev-hunk", name: "Go to previous hunk", editorCheckCallback: (checking, _, __) => { if (checking) { return ( plugin.settings.hunks.hunkCommands && plugin.hunkActions.editor !== undefined ); } plugin.hunkActions.goToHunk("prev"); }, }); } ================================================ FILE: src/constants.ts ================================================ import { Platform } from "obsidian"; import type { ObsidianGitSettings } from "./types"; export const DATE_FORMAT = "YYYY-MM-DD"; export const DATE_TIME_FORMAT_MINUTES = `${DATE_FORMAT} HH:mm`; export const DATE_TIME_FORMAT_SECONDS = `${DATE_FORMAT} HH:mm:ss`; export const GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH = 40; export const CONFLICT_OUTPUT_FILE = "conflict-files-obsidian-git.md"; export const DEFAULT_SETTINGS: ObsidianGitSettings = { commitMessage: "vault backup: {{date}}", autoCommitMessage: "vault backup: {{date}}", commitMessageScript: "", commitDateFormat: DATE_TIME_FORMAT_SECONDS, autoSaveInterval: 0, autoPushInterval: 0, autoPullInterval: 0, autoPullOnBoot: false, autoCommitOnlyStaged: false, disablePush: false, pullBeforePush: true, disablePopups: false, showErrorNotices: true, disablePopupsForNoChanges: false, listChangedFilesInMessageBody: false, showStatusBar: true, updateSubmodules: false, syncMethod: "merge", mergeStrategy: "none", customMessageOnAutoBackup: false, autoBackupAfterFileChange: false, treeStructure: false, refreshSourceControl: Platform.isDesktopApp, basePath: "", differentIntervalCommitAndPush: false, changedFilesInStatusBar: false, showedMobileNotice: false, refreshSourceControlTimer: 7000, showBranchStatusBar: true, setLastSaveToLastCommit: false, submoduleRecurseCheckout: false, gitDir: "", showFileMenu: true, authorInHistoryView: "hide", dateInHistoryView: false, diffStyle: "split", hunks: { showSigns: false, hunkCommands: false, statusBar: "disabled", }, lineAuthor: { show: false, followMovement: "inactive", authorDisplay: "initials", showCommitHash: false, dateTimeFormatOptions: "date", dateTimeFormatCustomString: DATE_TIME_FORMAT_MINUTES, dateTimeTimezone: "viewer-local", coloringMaxAge: "1y", // colors were picked via: // https://color.adobe.com/de/create/color-accessibility colorNew: { r: 255, g: 150, b: 150 }, colorOld: { r: 120, g: 160, b: 255 }, textColorCss: "var(--text-muted)", // more pronounced than line numbers, but less than the content text ignoreWhitespace: false, gutterSpacingFallbackLength: 5, }, }; export const SOURCE_CONTROL_VIEW_CONFIG = { type: "git-view", name: "Source Control", icon: "git-pull-request", }; export const HISTORY_VIEW_CONFIG = { type: "git-history-view", name: "History", icon: "history", }; export const SPLIT_DIFF_VIEW_CONFIG = { type: "split-diff-view", name: "Diff view", icon: "diff", }; export const DIFF_VIEW_CONFIG = { type: "diff-view", name: "Diff View", icon: "git-pull-request", }; export const DEFAULT_WIN_GIT_PATH = "C:\\Program Files\\Git\\cmd\\git.exe"; export const ASK_PASS_INPUT_FILE = ".git_credentials_input"; export const ASK_PASS_SCRIPT_FILE = "obsidian_askpass.sh"; export const ASK_PASS_SCRIPT = `#!/bin/sh PROMPT="$1" TEMP_FILE="$OBSIDIAN_GIT_CREDENTIALS_INPUT" cleanup() { rm -f "$TEMP_FILE" "$TEMP_FILE.response" } trap cleanup EXIT echo "$PROMPT" > "$TEMP_FILE" while [ ! -e "$TEMP_FILE.response" ]; do if [ ! -e "$TEMP_FILE" ]; then echo "Trigger file got removed: Abort" >&2 exit 1 fi sleep 0.1 done RESPONSE=$(cat "$TEMP_FILE.response") echo "$RESPONSE" `; /** * Copied from https://github.com/sindresorhus/binary-extensions/blob/main/binary-extensions.json */ export const BINARY_EXTENSIONS = [ "3dm", "3ds", "3g2", "3gp", "7z", "a", "aac", "adp", "afdesign", "afphoto", "afpub", "ai", "aif", "aiff", "alz", "ape", "apk", "appimage", "ar", "arj", "asf", "au", "avi", "bak", "baml", "bh", "bin", "bk", "bmp", "btif", "bz2", "bzip2", "cab", "caf", "cgm", "class", "cmx", "cpio", "cr2", "cur", "dat", "dcm", "deb", "dex", "djvu", "dll", "dmg", "dng", "doc", "docm", "docx", "dot", "dotm", "dra", "DS_Store", "dsk", "dts", "dtshd", "dvb", "dwg", "dxf", "ecelp4800", "ecelp7470", "ecelp9600", "egg", "eol", "eot", "epub", "exe", "f4v", "fbs", "fh", "fla", "flac", "flatpak", "fli", "flv", "fpx", "fst", "fvt", "g3", "gh", "gif", "graffle", "gz", "gzip", "h261", "h263", "h264", "icns", "ico", "ief", "img", "ipa", "iso", "jar", "jpeg", "jpg", "jpgv", "jpm", "jxr", "key", "ktx", "lha", "lib", "lvp", "lz", "lzh", "lzma", "lzo", "m3u", "m4a", "m4v", "mar", "mdi", "mht", "mid", "midi", "mj2", "mka", "mkv", "mmr", "mng", "mobi", "mov", "movie", "mp3", "mp4", "mp4a", "mpeg", "mpg", "mpga", "mxu", "nef", "npx", "numbers", "nupkg", "o", "odp", "ods", "odt", "oga", "ogg", "ogv", "otf", "ott", "pages", "pbm", "pcx", "pdb", "pdf", "pea", "pgm", "pic", "png", "pnm", "pot", "potm", "potx", "ppa", "ppam", "ppm", "pps", "ppsm", "ppsx", "ppt", "pptm", "pptx", "psd", "pya", "pyc", "pyo", "pyv", "qt", "rar", "ras", "raw", "resources", "rgb", "rip", "rlc", "rmf", "rmvb", "rpm", "rtf", "rz", "s3m", "s7z", "scpt", "sgi", "shar", "snap", "sil", "sketch", "slk", "smv", "snk", "so", "stl", "suo", "sub", "swf", "tar", "tbz", "tbz2", "tga", "tgz", "thmx", "tif", "tiff", "tlz", "ttc", "ttf", "txz", "udf", "uvh", "uvi", "uvm", "uvp", "uvs", "uvu", "viv", "vob", "war", "wav", "wax", "wbmp", "wdp", "weba", "webm", "webp", "whl", "wim", "wm", "wma", "wmv", "wmx", "woff", "woff2", "wrm", "wvx", "xbm", "xif", "xla", "xlam", "xls", "xlsb", "xlsm", "xlsx", "xlt", "xltm", "xltx", "xm", "xmind", "xpi", "xpm", "xwd", "xz", "z", "zip", "zipx", ]; ================================================ FILE: src/editor/control.ts ================================================ import type { EditorState } from "@codemirror/state"; import { StateField } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { editorEditorField, editorInfoField } from "obsidian"; import { eventsPerFilePathSingleton } from "./eventsPerFilepath"; import type { LineAuthoring, LineAuthoringId } from "./lineAuthor/model"; import { newComputationResultAsTransaction } from "./lineAuthor/model"; import { hunksState, newGitCompareResultAsTransaction, type GitCompareResult, } from "./signs/hunkState"; /* ================== CONTROL ====================== Contains classes and function responsible for updating the model given the changes in the Obsidian UI. */ /** * Subscribes to changes in the files on a specific filepath. * It knows its corresponding editor and initiates editor view changes. */ export class FileSubscriber { private lastSeenPath: string; // remember path to detect and adapt to renames constructor(private state: EditorState) { this.subscribeMe(); } public notifyLineAuthoring(id: LineAuthoringId, la: LineAuthoring) { if (this.view === undefined) { console.warn( `Git: View is not defined for editor cache key. Unforeseen situation. id: ${id}` ); return; } // using "this.state" directly here leads to some problems when closing panes. Hence, "this.view.state" const state = this.view.state; const transaction = newComputationResultAsTransaction(id, la, state); this.view.dispatch(transaction); } public notifyGitCompare(data: GitCompareResult) { if (this.view === undefined) { console.warn( `Git: View is not defined for editor cache key. Unforeseen situation. id: ` ); //TODO removed it above in the error message return; } // Prevent updates to stale subscribers if (this.removeIfStale()) { return; } // using "this.state" directly here leads to some problems when closing panes. Hence, "this.view.state" const state = this.view.state; const hunkState = state.field(hunksState); if ( !hunkState || hunkState.compareText != data.compareText || hunkState.compareTextHead != data.compareTextHead ) { const transaction = newGitCompareResultAsTransaction(data, state); this.view.dispatch(transaction); } } public updateToNewState(state: EditorState) { this.state = state; // If no filepath was previously available subscribe now if (!this.lastSeenPath && this.filepath) { this.subscribeMe(); // the update of the view by starting a new computation is done by // listening to rename events in the line authoring controller. } return this; } public removeIfStale(): boolean { // If a new `subscribeNewEditor` field has been created, then this instance is stale. // This happens when in the same leaf and `EditorView` a new file is opened if ( this.view?.state.field(subscribeNewEditor, false) != this || // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (this.view as any).destroyed ) { this.unsubscribeMe(this.lastSeenPath); return true; } return false; } // When a file is renamed, the editor's filepath changes. // So we resubscribe all editors to the new filepath. public changeToNewFilepath(filepath: string) { this.unsubscribeMe(this.lastSeenPath); this.subscribeMe(filepath); // the update of the view by starting a new computation is done by // listening to rename events in the line authoring controller. } private subscribeMe(filepath?: string) { filepath ??= this.filepath; if (filepath === undefined) return; // happens on the very first editor after start. eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers( filepath, (subs) => subs.add(this) ); this.lastSeenPath = filepath; } private unsubscribeMe(oldFilepath: string) { eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers( oldFilepath, (subs) => subs.delete(this) ); } private get filepath(): string | undefined { return this.state.field(editorInfoField)?.file?.path; } private get view(): EditorView | undefined { return this.state.field(editorEditorField); } } export type FileSubscribers = Set; /** * The Codemirror {@link Extension} used to make each editor subscribe itself to this pub-sub. */ export const subscribeNewEditor: StateField = StateField.define({ create: (state) => new FileSubscriber(state), update: (v, transaction) => v.updateToNewState(transaction.state), compare: (a, b) => a === b, }); ================================================ FILE: src/editor/editorIntegration.ts ================================================ import type ObsidianGit from "src/main"; import { LineAuthoringFeature } from "./lineAuthor/lineAuthorIntegration"; import { SignsFeature } from "./signs/signsIntegration"; import { subscribeNewEditor } from "./control"; export class EditorIntegration { constructor(private plg: ObsidianGit) {} lineAuthoringFeature: LineAuthoringFeature = new LineAuthoringFeature( this.plg ); signsFeature: SignsFeature = new SignsFeature(this.plg); onUnloadPlugin() { this.lineAuthoringFeature.deactivateFeature(); this.signsFeature.deactivateFeature(); } onLoadPlugin() { this.plg.registerEditorExtension(subscribeNewEditor); this.lineAuthoringFeature.onLoadPlugin(); this.signsFeature.onLoadPlugin(); } onReady() { this.lineAuthoringFeature.conditionallyActivateBySettings(); this.signsFeature.conditionallyActivateBySettings(); } activateLineAuthoring() { this.lineAuthoringFeature.activateFeature(); } deactiveLineAuthoring() { this.lineAuthoringFeature.deactivateFeature(); } refreshSignsSettings() { const hunkSettings = this.plg.settings.hunks; if ( hunkSettings.showSigns || hunkSettings.statusBar != "disabled" || hunkSettings.hunkCommands ) { this.signsFeature.deactivateFeature(); this.signsFeature.activateFeature(); } else { this.signsFeature.deactivateFeature(); } } } ================================================ FILE: src/editor/eventsPerFilepath.ts ================================================ import type { FileSubscriber, FileSubscribers } from "./control"; const SECONDS = 1000; const REMOVE_STALES_FREQUENCY = 60 * SECONDS; /** * * stores the subscribers/editors interested in changed per filepath * * We need this pub-sub design, because a filepath may be opened in multiple editors * and each editor should be updated asynchronously and independently. * * Subscribers can be cleared when the feature is deactivated */ class EventsPerFilePath { private eventsPerFilepath: Map = new Map(); private removeStalesSubscribersTimer: number; constructor() { this.startRemoveStalesSubscribersInterval(); } /** * Run the {@link handler} on the subscribers to {@link filepath}. */ public ifFilepathDefinedTransformSubscribers( filepath: string | undefined, handler: (lass: FileSubscribers) => T ): T | undefined { if (!filepath) return; this.ensureInitialized(filepath); return handler(this.eventsPerFilepath.get(filepath)!); } public forEachSubscriber(handler: (las: FileSubscriber) => void): void { this.eventsPerFilepath.forEach((subs) => subs.forEach(handler)); } private ensureInitialized(filepath: string) { if (!this.eventsPerFilepath.get(filepath)) this.eventsPerFilepath.set(filepath, new Set()); } private startRemoveStalesSubscribersInterval() { this.removeStalesSubscribersTimer = window.setInterval( () => this?.forEachSubscriber((las) => las?.removeIfStale()), REMOVE_STALES_FREQUENCY ); } public clear() { window.clearInterval(this.removeStalesSubscribersTimer); this.eventsPerFilepath.clear(); } } export const eventsPerFilePathSingleton = new EventsPerFilePath(); ================================================ FILE: src/editor/lineAuthor/lineAuthorIntegration.ts ================================================ import type { Extension } from "@codemirror/state"; import type { EventRef, TAbstractFile, WorkspaceLeaf } from "obsidian"; import { MarkdownView, Platform, TFile } from "obsidian"; import { SimpleGit } from "src/gitManager/simpleGit"; import { LineAuthorProvider, enabledLineAuthorInfoExtensions, } from "./lineAuthorProvider"; import type { LineAuthorSettings } from "./model"; import { provideSettingsAccess } from "./model"; import { handleContextMenu } from "./view/contextMenu"; import { setTextColorCssBasedOnSetting } from "./view/gutter/coloring"; import { prepareGutterSearchForContextMenuHandling } from "./view/gutter/gutterElementSearch"; import type ObsidianGit from "src/main"; /** * Manages the interaction between Obsidian (file-open event, modification event, etc.) * and the line authoring feature. It also manages the (de-) activation of the * line authoring functionality. */ export class LineAuthoringFeature { private lineAuthorInfoProvider?: LineAuthorProvider; private fileOpenEvent?: EventRef; private workspaceLeafChangeEvent?: EventRef; private fileModificationEvent?: EventRef; private headChangeEvent?: EventRef; private refreshOnCssChangeEvent?: EventRef; private fileRenameEvent?: EventRef; private gutterContextMenuEvent?: EventRef; private codeMirrorExtensions: Extension[] = []; constructor(private plg: ObsidianGit) {} // ========================= INIT and DE-INIT ========================== public onLoadPlugin() { this.plg.registerEditorExtension(this.codeMirrorExtensions); provideSettingsAccess( () => this.plg.settings.lineAuthor, (laSettings: LineAuthorSettings) => { this.plg.settings.lineAuthor = laSettings; void this.plg.saveSettings(); } ); } public conditionallyActivateBySettings() { if (this.plg.settings.lineAuthor.show) { this.activateFeature(); } } public activateFeature() { try { if (!this.isAvailableOnCurrentPlatform().available) return; setTextColorCssBasedOnSetting(this.plg.settings.lineAuthor); this.lineAuthorInfoProvider = new LineAuthorProvider(this.plg); this.createEventHandlers(); this.activateCodeMirrorExtensions(); console.log(this.plg.manifest.name + ": Enabled line authoring."); } catch (e) { console.warn("Git: Error while loading line authoring feature.", e); this.deactivateFeature(); } } /** * Deactivates the feature. This function is very defensive, as it is also * called to cleanup, if a critical error in the line authoring has occurred. */ public deactivateFeature() { this.destroyEventHandlers(); this.deactivateCodeMirrorExtensions(); this.lineAuthorInfoProvider?.destroy(); this.lineAuthorInfoProvider = undefined; console.log(this.plg.manifest.name + ": Disabled line authoring."); } public isAvailableOnCurrentPlatform(): { available: boolean; gitManager: SimpleGit; } { return { available: this.plg.useSimpleGit && Platform.isDesktopApp, gitManager: this.plg.gitManager instanceof SimpleGit ? this.plg.gitManager : undefined!, }; } // ========================= REFRESH ========================== public refreshLineAuthorViews() { if (this.plg.settings.lineAuthor.show) { this.deactivateFeature(); this.activateFeature(); } } // ========================= CODEMIRROR EXTENSIONS ========================== private activateCodeMirrorExtensions() { // Yes, we need to directly modify the array and notify the change to have // toggleable Codemirror extensions. this.codeMirrorExtensions.push(enabledLineAuthorInfoExtensions); this.plg.app.workspace.updateOptions(); // Handle all already opened files this.plg.app.workspace.iterateAllLeaves(this.handleWorkspaceLeaf); } private deactivateCodeMirrorExtensions() { // Yes, we need to directly modify the array and notify the change to have // toggleable Codemirror extensions. for (const ext of this.codeMirrorExtensions) { this.codeMirrorExtensions.remove(ext); } this.plg.app.workspace.updateOptions(); } // ========================= HANDLERS ========================== private createEventHandlers() { this.gutterContextMenuEvent = this.createGutterContextMenuHandler(); this.fileOpenEvent = this.createFileOpenEvent(); this.workspaceLeafChangeEvent = this.createWorkspaceLeafChangeEvent(); this.fileModificationEvent = this.createVaultFileModificationHandler(); this.headChangeEvent = this.createHeadChangeEvent(); this.refreshOnCssChangeEvent = this.createCssRefreshHandler(); this.fileRenameEvent = this.createFileRenameEvent(); prepareGutterSearchForContextMenuHandling(); this.plg.registerEvent(this.gutterContextMenuEvent); this.plg.registerEvent(this.refreshOnCssChangeEvent); this.plg.registerEvent(this.fileOpenEvent); this.plg.registerEvent(this.workspaceLeafChangeEvent); this.plg.registerEvent(this.fileModificationEvent); this.plg.registerEvent(this.headChangeEvent); this.plg.registerEvent(this.fileRenameEvent); } private destroyEventHandlers() { this.plg.app.workspace.offref(this.gutterContextMenuEvent!); this.plg.app.workspace.offref(this.refreshOnCssChangeEvent!); this.plg.app.workspace.offref(this.fileOpenEvent!); this.plg.app.workspace.offref(this.workspaceLeafChangeEvent!); this.plg.app.workspace.offref(this.refreshOnCssChangeEvent!); this.plg.app.vault.offref(this.fileModificationEvent!); this.plg.app.workspace.offref(this.headChangeEvent!); this.plg.app.vault.offref(this.fileRenameEvent!); } private handleWorkspaceLeaf = (leaf: WorkspaceLeaf) => { if (!this.lineAuthorInfoProvider) { console.warn( "Git: undefined lineAuthorInfoProvider. Unexpected situation." ); return; } const obsView = leaf?.view; if ( !(obsView instanceof MarkdownView) || obsView.file == null || obsView?.allowNoFile === true ) return; this.lineAuthorInfoProvider .trackChanged(obsView.file) .catch(console.error); }; private createFileOpenEvent(): EventRef { return this.plg.app.workspace.on( "file-open", (file: TFile) => void this.lineAuthorInfoProvider ?.trackChanged(file) .catch(console.error) ); } private createWorkspaceLeafChangeEvent(): EventRef { return this.plg.app.workspace.on( "active-leaf-change", this.handleWorkspaceLeaf ); } private createFileRenameEvent(): EventRef { return this.plg.app.vault.on( "rename", (file, _old) => file instanceof TFile && this.lineAuthorInfoProvider?.trackChanged(file) ); } private createVaultFileModificationHandler() { return this.plg.app.vault.on( "modify", (anyPath: TAbstractFile) => anyPath instanceof TFile && this.lineAuthorInfoProvider?.trackChanged(anyPath) ); } private createHeadChangeEvent(): EventRef { return this.plg.app.workspace.on("obsidian-git:head-change", () => { this.refreshLineAuthorViews(); }); } private createCssRefreshHandler(): EventRef { return this.plg.app.workspace.on("css-change", () => this.refreshLineAuthorViews() ); } private createGutterContextMenuHandler() { return this.plg.app.workspace.on("editor-menu", handleContextMenu); } } ================================================ FILE: src/editor/lineAuthor/lineAuthorProvider.ts ================================================ import type { Extension } from "@codemirror/state"; import { Prec } from "@codemirror/state"; import type { TFile } from "obsidian"; import { eventsPerFilePathSingleton } from "src/editor/eventsPerFilepath"; import type { LineAuthoring, LineAuthoringId, } from "src/editor/lineAuthor/model"; import { lineAuthorState, lineAuthoringId } from "src/editor/lineAuthor/model"; import { clearViewCache } from "src/editor/lineAuthor/view/cache"; import { lineAuthorGutter } from "src/editor/lineAuthor/view/view"; import type ObsidianGit from "src/main"; export { previewColor } from "src/editor/lineAuthor/view/gutter/coloring"; /** * * handles changes in git head, filesystem, etc. by initiating computation * * Initiates the line authoring computation via * git-blame * * notifies computation results and settings to subscribers (editors) * * deytroys cache and editor-subscribers when plugin is deactivated */ export class LineAuthorProvider { /** * Saves all computed line authoring results. * * See {@link LineAuthoringId} */ private lineAuthorings: Map = new Map(); constructor(private plugin: ObsidianGit) {} public async trackChanged(file: TFile) { return this.trackChangedHelper(file).catch((reason) => { console.warn("Git: Error in trackChanged." + reason); // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors return Promise.reject(reason); }); } private async trackChangedHelper(file: TFile) { if (!file) return; if (file.path === undefined) { console.warn( "Git: Attempted to track change of undefined filepath. Unforeseen situation." ); return; } return this.computeLineAuthorInfo(file.path); } public destroy() { this.lineAuthorings.clear(); clearViewCache(); } private async computeLineAuthorInfo(filepath: string) { const gitManager = this.plugin.editorIntegration.lineAuthoringFeature.isAvailableOnCurrentPlatform() .gitManager; const headRevision = await gitManager.submoduleAwareHeadRevisonInContainingDirectory( filepath ); const fileHash = await gitManager.hashObject(filepath); const key = lineAuthoringId(headRevision, fileHash, filepath); if (key === undefined) { return; } if (this.lineAuthorings.has(key)) { // already computed. just tell the editor to update to the key's state } else { const gitAuthorResult = await gitManager.blame( filepath, this.plugin.settings.lineAuthor.followMovement, this.plugin.settings.lineAuthor.ignoreWhitespace ); this.lineAuthorings.set(key, gitAuthorResult); } this.notifyComputationResultToSubscribers(filepath, key); } private notifyComputationResultToSubscribers( filepath: string, key: string ) { eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers( filepath, (subs) => subs.forEach((sub) => sub.notifyLineAuthoring(key, this.lineAuthorings.get(key)!) ) ); } } // ========================================================= export const enabledLineAuthorInfoExtensions: Extension = Prec.high([ lineAuthorState, lineAuthorGutter, ]); ================================================ FILE: src/editor/lineAuthor/model.ts ================================================ import type { EditorState, Transaction } from "@codemirror/state"; import { StateEffect, StateField } from "@codemirror/state"; import type { Hasher } from "js-sha256"; import { sha256 } from "js-sha256"; import type { RGB } from "obsidian"; import { DEFAULT_SETTINGS } from "src/constants"; import { parseColoringMaxAgeDuration } from "src/setting/settings"; import type { Blame } from "src/types"; /* ================== MODEL ====================== Contains types and variables describing the essential contents of the line authoring and further types. */ // use a more neutral word for this functionality export type LineAuthoring = Blame | "untracked"; /** * An identifier for each line authoring. * * For every {@link LineAuthoring} there is exactly one valid corresponding {@link LineAuthoringId} * This is used to disambiguate differing line authoring content. * * For instance, when there are UI-changes in an editor, we want to quickly figure out * whether the LineAuthoring has changed as well. Computing this cache-identifiying key/id * allows us to quickly find that out and avoid re-computing the result, if the corresponding * line authoring already has been computed. This is used in lineAuthorInfoProvider.ts. * * This implementation assumes, that each {@link LineAuthoring} is unique, given * the HEAD revision of the git repository, the hash of the current contents of the file and * the path to the file within the vault. The exact syntax is determined by {@link lineAuthoringId}. * * * HEAD: This ensures, that adding a new commit or changing the checked out revision * forces re-computation. * * We always want to use the submodule which contains the file rather than any super-project, * as the file is only committed in lowest level submodule - and only it's HEAD revision * will be updated during a commit. * * contents-hash: Whenever the contents of the file produce a different hash, * the view needs updating - hence the re-computation. * * the path of the file in git matters as well, as the exact file content at different * paths in a git repository can have different histories */ export type LineAuthoringId = string; export function lineAuthoringId( head: string, objHash: string, path: string ): string | undefined { if (head === undefined || objHash === undefined || path === undefined) { return undefined; } return `head${head}-obj${objHash}-path${path}`; } // =================== LineAuthoring inside a Codemirror Transaction ===================== export type LineAuthoringWithChanges = { la: LineAuthoring; key: LineAuthoringId; /** * See {@link enrichUnsavedChanges} */ lineOffsetsFromUnsavedChanges: Map; }; /** * The {@link StateEffect} used in Codemirror {@link Transaction}s to * update the {@link EditorState} with the {@link LineAuthoring}, that should be displayed. * * See users of {@link newComputationResultAsTransaction} for the value providers. * The {@link StateField} {@link lineAuthorState} hold the value of this transaction. */ const LineAuthoringContainerType = StateEffect.define(); export function newComputationResultAsTransaction( key: LineAuthoringId, la: LineAuthoring, state: EditorState ): Transaction { return state.update({ effects: LineAuthoringContainerType.of({ key, la, lineOffsetsFromUnsavedChanges: new Map(), }), }); } // ================ Codemirror StateField containing the current Line Authoring =================== /** * The Codemirror {@link StateField} which contains the current {@link LineAuthoring} * that is being shown. * * The update method extracts the value from the StateEffect, if one is provided. * * Strictly speaking, if the StateEffect of a previous and outdated computation * appears after a new a recent one, it might happen, that the old and stale one * will be shown instead. This is because we only ever show the StateEffect which * was most recently in a transaction - and we do not track any time here. * * When caching this, please use {@link laStateDigest} to compute the key. */ export const lineAuthorState: StateField = StateField.define({ create: (_state) => undefined, /** * The state can be updated from either an annotated transaction containing * the newest line authoring (for the saved document) - or from * unsaved changes of the document as the user is actively typing in the editor. * * In the first case, we take the new line authoring and discard anything we had remembered * from unsaved changes. In the second case, we use the unsaved changes in {@link enrichUnsavedChanges} to pre-compute information to immediately update the * line author gutter without needing to wait until the document is saved and the * line authoring is properly computed. */ update: (previous, transaction) => { for (const effect of transaction.effects) { if (effect.is(LineAuthoringContainerType)) { return effect.value; } } return enrichUnsavedChanges(transaction, previous); }, // compare cache keys. // equality rate is >= 95% :) // hence avoids recomputation of views compare: (l, r) => l?.key === r?.key, }); export function laStateDigest( laState: LineAuthoringWithChanges | undefined ): Hasher { const digest = sha256.create(); if (!laState) return digest; const { la, key, lineOffsetsFromUnsavedChanges } = laState; digest.update(la === "untracked" ? "t" : "f"); digest.update(key); for (const [k, v] of lineOffsetsFromUnsavedChanges.entries() ?? []) digest.update([k, v]); return digest; } // =============== Line Authoring Settings ================= export type LineAuthorSettings = { show: boolean; showCommitHash: boolean; followMovement: LineAuthorFollowMovement; authorDisplay: LineAuthorDisplay; lastShownAuthorDisplay?: LineAuthorDisplay; dateTimeFormatOptions: LineAuthorDateTimeFormatOptions; lastShownDateTimeFormatOptions?: LineAuthorDateTimeFormatOptions; dateTimeFormatCustomString: string; dateTimeTimezone: LineAuthorTimezoneOption; coloringMaxAge: string; colorOld: RGB; colorNew: RGB; textColorCss: string; ignoreWhitespace: boolean; gutterSpacingFallbackLength: number; }; export type LineAuthorFollowMovement = | "inactive" | "same-commit" | "all-commits"; export type LineAuthorDisplay = | "hide" | "full" | "first name" | "last name" | "initials"; export type LineAuthorDateTimeFormatOptions = | "hide" | "date" | "datetime" | "natural language" | "custom"; export type LineAuthorTimezoneOption = | "viewer-local" | "author-local" | "utc0000"; // =============================================================== /** * Global mutable container to get access to the latest Obsidian settings. * * This is stored here globally and populated during line author feature loading * via {@link provideSettingsAccess}. * * It is used to provide the editors with the recent settings when created, as this allows * us to create the correct spacing up-front as well as have the latest settings * when rendering. */ export const latestSettings = { get: undefined! as () => LineAuthorSettings, save: undefined! as (settings: LineAuthorSettings) => void, }; export function provideSettingsAccess( settingsGetter: () => LineAuthorSettings, settingsSetter: (settings: LineAuthorSettings) => void ) { latestSettings.get = settingsGetter; latestSettings.save = settingsSetter; } export function maxAgeInDaysFromSettings(settings: LineAuthorSettings) { return ( parseColoringMaxAgeDuration(settings.coloringMaxAge)?.asDays() ?? parseColoringMaxAgeDuration( DEFAULT_SETTINGS.lineAuthor.coloringMaxAge )!.asDays() ); } /** * Given a transaction containing editor changes and the previous line author state, * we want to update the `lineOffsetsFromUnsavedChanges` in {@link LineAuthoringWithChanges}. * * This property contains for each line `ln` in the new document the following: * * if the line has not been changed, then it is not contained and `.get(ln)` is undefined. * * if the line has been changed and its ChangeSet does not change the number of lines, * then `.get(ln)` is 0. * * if the line has been changed and its ChangeSet indicates that the number of lines has changed * (e.g. removed or added lines), then all but the last lines in the ChangeSet will have * `.get(ln)=0` and the last line will have `.get(ln)=n` where `n` is the number * of added lines. If `n` is negative, then lines have been removed instead. */ function enrichUnsavedChanges( tr: Transaction, prev: LineAuthoringWithChanges | undefined ): LineAuthoringWithChanges | undefined { if (!prev) return undefined; if (!tr.changes.empty) { tr.changes.iterChanges((fromA, toA, fromB, toB) => { const oldDoc = tr.startState.doc; const { newDoc } = tr; const beforeFrom = oldDoc.lineAt(fromA).number; const beforeTo = oldDoc.lineAt(toA).number; const afterFrom = newDoc.lineAt(fromB).number; const afterTo = newDoc.lineAt(toB).number; const beforeLen = beforeTo - beforeFrom + 1; const afterLen = afterTo - afterFrom + 1; /* Current change: The lines beforeFrom..beforeTo (containing beforeLen lines) in the old doc have been replaced by the lines afterFrom..afterTo (containing afterLen lines) in the new doc. */ // The lines afterFrom..afterTo for which we want to // set an offset in lineOffsetsFromUnsavedChanges. for (let afterI = afterFrom; afterI <= afterTo; afterI++) { // Multiple changes can be made from the current transaction // as well as from previous transactions since the last document save. // Hence, we want to cumulate all offsets. let offset = prev.lineOffsetsFromUnsavedChanges.get(afterI) ?? 0; const isLastLine = afterTo === afterI; // positive = added lines, negative = removed lines. const changeInNumberOfLines = afterLen - beforeLen; if (isLastLine) offset += changeInNumberOfLines; prev.lineOffsetsFromUnsavedChanges.set(afterI, offset); } }); } return prev; } ================================================ FILE: src/editor/lineAuthor/view/cache.ts ================================================ import type { RangeSet } from "@codemirror/state"; import type { GutterMarker } from "@codemirror/view"; import { latestSettings } from "src/editor/lineAuthor/model"; import type { LineAuthoringGutter } from "src/editor/lineAuthor/view/gutter/gutter"; import { median } from "src/utils"; /* VIEW-CACHE This file contains temporarily cached information used in the view. They make it also possible to have unintrusive and soft UI updates, when the git line author information appears delayed. The caches here are evicted whenever the line author feature is disabled/refreshed. */ /** * Clears the cache. This should be called whenever the settings are changed. * * Currently, the entire feature is re-loaded, which is why it suffices this to be called * in the disabler in `lineAuthorIntegration.ts`. */ export function clearViewCache() { longestRenderedGutter = undefined; renderedAgeInDaysForAdaptiveInitialColoring = []; ageIdx = 0; gutterInstances.clear(); gutterMarkersRangeSet.clear(); attachedGutterElements.clear(); } /** * A cache containing the last maximally-sized encountered gutter together with its length and text. * * Whenever a longer gutter is encountered, it is saved via {@link conditionallyUpdateLongestRenderedGutter}. */ type LongestGutterCache = { gutter: LineAuthoringGutter; length: number; text: string; }; let longestRenderedGutter: LongestGutterCache | undefined = undefined; export const getLongestRenderedGutter = () => longestRenderedGutter; /** * Given a newly rendered gutter, update the {@link longestRenderedGutter} by comparing the * text lengths. * * If bigger, then update the global variable and persist the settings via {@link latestSettings.save} */ export function conditionallyUpdateLongestRenderedGutter( gutter: LineAuthoringGutter, text: string ) { const length = text.length; if (length < (longestRenderedGutter?.length ?? 0)) return; longestRenderedGutter = { gutter, length, text }; const settings = latestSettings.get(); if (length !== settings.gutterSpacingFallbackLength) { settings.gutterSpacingFallbackLength = length; latestSettings.save(settings); } } /** * When a new file is opened, we need to already render the line gutter even before we * know the true git line authoring - and their true colors. * * Simply rendering them with the background color initially is not good, as the * UI update, when the result is available, is distracting and flickering. * * Hence, we adapt the initial color shown when opening and switching panes. * * The initial color is computed from the distribution of ages of each line commit * (in days). Currently, we use {@link ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE}`=50` * elements and the `median` to compute the color. */ let renderedAgeInDaysForAdaptiveInitialColoring: number[] = []; const ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE = 15; let ageIdx = 0; export function recordRenderedAgeInDays(age: number) { renderedAgeInDaysForAdaptiveInitialColoring[ageIdx] = age; ageIdx = (ageIdx + 1) % ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE; } export function computeAdaptiveInitialColoringAgeInDays(): number | undefined { return median(renderedAgeInDaysForAdaptiveInitialColoring); } /** * Caches the {@link LineAuthoringGutter} instances created in `gutter.ts`. */ export const gutterInstances: Map = new Map(); /** * Caches the computation of {@link computeLineAuthoringGutterMarkersRangeSet}. * * Despite the computation of the document digest and line-blocks, the performance * was measured to be faster with the caching. */ export const gutterMarkersRangeSet: Map< string, RangeSet > = new Map(); /** * Stores all DOM-attached gutter elements so that they can be checked for being * under the mouse during a gutter context-menu event; */ export const attachedGutterElements: Set = new Set(); ================================================ FILE: src/editor/lineAuthor/view/contextMenu.ts ================================================ import type { Editor, MarkdownView, Menu } from "obsidian"; import { DEFAULT_SETTINGS } from "src/constants"; import type { LineAuthorSettings } from "src/editor/lineAuthor/model"; import { findGutterElementUnderMouse } from "src/editor/lineAuthor/view/gutter/gutterElementSearch"; import { pluginRef } from "src/pluginGlobalRef"; import type { BlameCommit } from "src/types"; import { impossibleBranch } from "src/utils"; type ContextMenuConfigurableSettingsKeys = | "showCommitHash" | "authorDisplay" | "dateTimeFormatOptions"; type CtxMenuCommitInfo = Pick & { isWaitingGutter: boolean; }; const COMMIT_ATTR = "data-commit"; export function handleContextMenu( menu: Menu, editor: Editor, _mdv: MarkdownView ) { // Click was inside text-editor with active cursor. Don't trigger there. if (editor.hasFocus()) return; const gutterElement = findGutterElementUnderMouse(); if (!gutterElement) return; const info = getCommitInfo(gutterElement); if (!info) return; // Zero-commit and waiting-for-result must not be copied if (!info.isZeroCommit && !info.isWaitingGutter) { addCopyHashMenuItem(info, menu); } addConfigurableLineAuthorSettings("showCommitHash", menu); addConfigurableLineAuthorSettings("authorDisplay", menu); addConfigurableLineAuthorSettings("dateTimeFormatOptions", menu); } function addCopyHashMenuItem(commit: CtxMenuCommitInfo, menu: Menu) { menu.addItem((item) => item .setTitle("Copy commit hash") .setIcon("copy") .setSection("obs-git-line-author-copy") .onClick((_e) => navigator.clipboard.writeText(commit.hash)) ); } function addConfigurableLineAuthorSettings( key: ContextMenuConfigurableSettingsKeys, menu: Menu ) { let title: string; let actionNewValue: LineAuthorSettings[typeof key]; const settings = pluginRef.plugin!.settings.lineAuthor; const currentValue = settings[key]; const currentlyShown = typeof currentValue === "boolean" ? currentValue : currentValue !== "hide"; const defaultValue = DEFAULT_SETTINGS.lineAuthor[key]; if (key === "showCommitHash") { title = "Show commit hash"; actionNewValue = currentValue; } else if (key === "authorDisplay") { const showOption = settings.lastShownAuthorDisplay ?? defaultValue; title = "Show author " + (currentlyShown ? currentValue : showOption); actionNewValue = currentlyShown ? "hide" : showOption; } else if (key === "dateTimeFormatOptions") { const showOption = settings.lastShownDateTimeFormatOptions ?? defaultValue; title = "Show " + (currentlyShown ? currentValue : showOption); title += !title.contains("date") ? " date" : ""; actionNewValue = currentlyShown ? "hide" : showOption; } else { impossibleBranch(key); } menu.addItem((item) => item .setTitle(title) .setSection("obs-git-line-author-configure") // group settings together .setChecked(currentlyShown) .onClick((_e) => pluginRef.plugin?.settingsTab?.lineAuthorSettingHandler( key, actionNewValue ) ) ); } export function enrichCommitInfoForContextMenu( commit: BlameCommit, isWaitingGutter: boolean, elt: HTMLElement ) { elt.setAttr( COMMIT_ATTR, JSON.stringify({ hash: commit.hash, isZeroCommit: commit.isZeroCommit, isWaitingGutter, }) ); } function getCommitInfo(elt: HTMLElement): CtxMenuCommitInfo | undefined { const commitInfoStr = elt.getAttr(COMMIT_ATTR); return commitInfoStr ? (JSON.parse(commitInfoStr) as CtxMenuCommitInfo) : undefined; } ================================================ FILE: src/editor/lineAuthor/view/gutter/coloring.ts ================================================ import type { App } from "obsidian"; import type { LineAuthorSettings } from "src/editor/lineAuthor/model"; import { maxAgeInDaysFromSettings } from "src/editor/lineAuthor/model"; import type { GitTimestamp } from "src/types"; /** * Given the settings, it computes the background gutter color for the * oldest and newest commit. */ export function previewColor( which: "oldest" | "newest", settings: LineAuthorSettings ) { return which === "oldest" ? coloringBasedOnCommitAge(0 /* epoch time: 1970 */, false, settings) .color : coloringBasedOnCommitAge(undefined, true, settings).color; } /** * Computes the `rgba(...)` color string describing the background color * for a commit timestamp {@link GitTimestamp} and the settings. * * It first computes the age x (from 0 to 1) of the commit where * 0 means now and 1 means maximum age (settings) or older. * * The zero commit gets the age 0. * * The coloring is then linearly interpolated between the two colors provided in the settings. * * Additional minor adjustments were made for dark/light mode, transparency, scaling * and also using more red-like colors near the newer ages. */ export function coloringBasedOnCommitAge( commitAuthorEpochSeonds: GitTimestamp["epochSeconds"] | undefined, isZeroCommit: boolean, settings: LineAuthorSettings ): { color: string; daysSinceCommit: number } { const maxAgeInDays = maxAgeInDaysFromSettings(settings); const epochSecondsNow = Date.now() / 1000; const authoringEpochSeconds = commitAuthorEpochSeonds ?? 0; const secondsSinceCommit = isZeroCommit ? 0 : epochSecondsNow - authoringEpochSeconds; const daysSinceCommit = secondsSinceCommit / 60 / 60 / 24; // 0 <= x <= 1, larger means older // use n-th-root to make recent changes more prnounced const x = Math.pow( Math.clamp(daysSinceCommit / maxAgeInDays, 0, 1), 1 / 2.3 ); const dark = isDarkMode(); const color0 = settings.colorNew; const color1 = settings.colorOld; const scaling = dark ? 0.4 : 1; const r = lin(color0.r, color1.r, x) * scaling; const g = lin(color0.g, color1.g, x) * scaling; const b = lin(color0.b, color1.b, x) * scaling; const a = dark ? 0.75 : 0.25; return { color: `rgba(${r},${g},${b},${a})`, daysSinceCommit }; } function lin(z0: number, z1: number, x: number): number { return z0 + (z1 - z0) * x; } function isDarkMode() { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access return ((window as any).app as App)?.getTheme() === "obsidian"; // light mode is "moonstone" } /** * Set the CSS variable `--obs-git-gutter-text` based on the configured * value in the line author settings. This is necessary for proper text coloring. */ export function setTextColorCssBasedOnSetting(settings: LineAuthorSettings) { document.body.style.setProperty( "--obs-git-gutter-text", settings.textColorCss ); } ================================================ FILE: src/editor/lineAuthor/view/gutter/commitChoice.ts ================================================ import type { LineAuthoring } from "src/editor/lineAuthor/model"; import type { BlameCommit } from "src/types"; /** * Chooses the newest commit from the {@link LineAuthoring} for the * lines {@link startLine} to {@link endLine} (inclusive). */ export function chooseNewestCommit( lineAuthoring: Exclude, startLine: number, endLine: number ): BlameCommit { let newest: BlameCommit = undefined!; for (let line = startLine; line <= endLine; line++) { const currentHash = lineAuthoring.hashPerLine[line]; const currentCommit = lineAuthoring.commits.get(currentHash)!; if ( !newest || currentCommit.isZeroCommit || isNewerThan(currentCommit, newest) ) { newest = currentCommit; } } return newest; } function isNewerThan(left: BlameCommit, right: BlameCommit): boolean { const l = left.author?.epochSeconds ?? 0; const r = right.author?.epochSeconds ?? 0; return l > r; } ================================================ FILE: src/editor/lineAuthor/view/gutter/gutter.ts ================================================ import { GutterMarker } from "@codemirror/view"; import { sha256 } from "js-sha256"; import { moment, setTooltip } from "obsidian"; import { DATE_FORMAT, DATE_TIME_FORMAT_MINUTES } from "src/constants"; import type { LineAuthorDateTimeFormatOptions, LineAuthorDisplay, LineAuthorSettings, LineAuthorTimezoneOption, LineAuthoring, } from "src/editor/lineAuthor/model"; import { latestSettings } from "src/editor/lineAuthor/model"; import { attachedGutterElements, conditionallyUpdateLongestRenderedGutter, getLongestRenderedGutter, gutterInstances, recordRenderedAgeInDays, } from "src/editor/lineAuthor/view/cache"; import { enrichCommitInfoForContextMenu } from "src/editor/lineAuthor/view/contextMenu"; import { coloringBasedOnCommitAge } from "src/editor/lineAuthor/view/gutter/coloring"; import { chooseNewestCommit } from "src/editor/lineAuthor/view/gutter/commitChoice"; import type { BlameCommit } from "src/types"; import { impossibleBranch, prefixOfLengthAsWhitespace, resizeToLength, strictDeepEqual, } from "src/utils"; const VALUE_NOT_FOUND_FALLBACK = "-"; const NEW_CHANGE_CHARACTER = "+"; const NEW_CHANGE_NUMBER_OF_CHARACTERS = 3; const DIFFERING_AUTHOR_COMMITTER_MARKER = "*"; const NON_WHITESPACE_REGEXP = /\S/g; const UNINTRUSIVE_CHARACTER_FOR_WAITING_RENDERING = "%"; /** * A simple text gutter used to hold space until the real results are available. */ export class TextGutter extends GutterMarker { constructor(public text: string) { super(); } eq(other: TextGutter): boolean { return other instanceof TextGutter && this.text === other.text; } toDOM() { return document.createTextNode(this.text); } destroy(dom: HTMLElement): void { if (!dom) { return; // sometimes, it doesn't exist anymore. } } } /** * Renders the given {@link LineAuthoring} for the lines {@link startLine} * to {@link endLine}. */ export class LineAuthoringGutter extends GutterMarker { private precomputedDomProvider?: () => HTMLElement; public readonly point = false; public readonly elementClass = "obs-git-blame-gutter"; /** * **This should only be called {@link lineAuthoringGutterMarker}!** * * We want to avoid creating the same instance multiple times for improved performance. */ constructor( public readonly lineAuthoring: Exclude, public readonly startLine: number, public readonly endLine: number, public readonly key: string, public readonly settings: LineAuthorSettings, public readonly options?: "waiting-for-result" ) { super(); } // Equality used by CodeMirror for optimisations public eq(other: GutterMarker): boolean { return ( this.key === (other)?.key && this.startLine === (other)?.startLine && this.endLine === (other)?.endLine && this?.options === (other)?.options ); } /** * Renders to a Html node. * * It choses the newest commit within the line-range, * renders it, makes adjustments for fake-commits and finally warps * it into HTML. * * The DOM is actually precomputed with {@link computeDom}, * which provides a finaliser to run before the DOM is handed over to CodeMirror. * This is done, because this method is called frequently. It is called, * whenever a gutter gets into the viewport and needs to be rendered. * * The age in days is recorded via {@link recordRenderedAgeInDays} to enable adaptive coloring. */ public toDOM() { this.precomputedDomProvider = this.precomputedDomProvider ?? this.computeDom(); return this.precomputedDomProvider(); } public destroy(dom: HTMLElement): void { if (!dom) { return; // sometimes, it doesn't exist anymore. } // this is called frequently, when the gutter moves outside of the view. if (!document.body.contains(dom)) { attachedGutterElements.delete(dom); } } /** * Prepares the DOM for this gutter. */ private computeDom() { const commit = chooseNewestCommit( this.lineAuthoring, this.startLine, this.endLine ); let toBeRenderedText = commit.isZeroCommit ? "" : this.renderNonZeroCommit(commit); const isTrueCommit = !commit.isZeroCommit && this.options !== "waiting-for-result"; if (isTrueCommit) { conditionallyUpdateLongestRenderedGutter(this, toBeRenderedText); } else { toBeRenderedText = this.adaptTextForFakeCommit( commit, toBeRenderedText, this.options ); } const domProvider = this.createHtmlNode( commit, toBeRenderedText, this.options === "waiting-for-result" ); return domProvider; } private createHtmlNode( commit: BlameCommit, text: string, isWaitingGutter: boolean ) { const templateElt = window.createDiv(); templateElt.setText(text); const { color, daysSinceCommit } = coloringBasedOnCommitAge( commit?.author?.epochSeconds, commit?.isZeroCommit, this.settings ); templateElt.style.backgroundColor = color; templateElt.setAttribute("data-author", commit?.author?.name ?? ""); templateElt.setAttribute( "data-author-email", commit?.author?.email ?? "" ); setTooltip(templateElt, commit?.summary ?? ""); enrichCommitInfoForContextMenu(commit, isWaitingGutter, templateElt); function prepareForDomAttachment(): HTMLElement { // clone node before attachment, as attached DOMs may get destroyed. const elt = templateElt.cloneNode(true) as HTMLElement; attachedGutterElements.add(elt); // only record real dates if (!isWaitingGutter) recordRenderedAgeInDays(daysSinceCommit); return elt; } return prepareForDomAttachment; } private renderNonZeroCommit(commit: BlameCommit) { const optionalShortHash = this.settings.showCommitHash ? this.renderHash(commit) : ""; const optionalAuthorName = this.settings.authorDisplay === "hide" ? "" : `${this.renderAuthorName( commit, this.settings.authorDisplay )}`; const optionalAuthoringDate = this.settings.dateTimeFormatOptions === "hide" ? "" : `${this.renderAuthoringDate( commit, this.settings.dateTimeFormatOptions, this.settings.dateTimeFormatCustomString, this.settings.dateTimeTimezone )}`; const parts = [ optionalShortHash, optionalAuthorName, optionalAuthoringDate, ]; return parts.filter((x) => x.length >= 1).join(" "); } private renderHash(nonZeroCommit: BlameCommit) { return nonZeroCommit.hash.substring(0, 6); } private renderAuthorName( nonZeroCommit: BlameCommit, authorDisplay: Exclude ): string { const name = nonZeroCommit?.author?.name ?? ""; const words = name.split(" ").filter((word) => word.length >= 1); // non-empty words let rendered; switch (authorDisplay) { case "initials": // take every words first letter captitalized rendered = words.map((word) => word[0].toUpperCase()).join(""); break; case "first name": rendered = words.first() ?? VALUE_NOT_FOUND_FALLBACK; break; case "last name": rendered = words.last() ?? VALUE_NOT_FOUND_FALLBACK; break; case "full": rendered = name; break; default: return impossibleBranch(authorDisplay); } // add trailing * if author and comitter are different. if (!strictDeepEqual(nonZeroCommit?.author, nonZeroCommit?.committer)) { rendered = rendered + DIFFERING_AUTHOR_COMMITTER_MARKER; } return rendered; } private renderAuthoringDate( nonZeroCommit: BlameCommit, dateTimeFormatOptions: Exclude, dateTimeFormatCustomString: string, dateTimeTimezone: LineAuthorTimezoneOption ) { const FALLBACK_COMMIT_DATE = "?"; if (nonZeroCommit?.author?.epochSeconds === undefined) return FALLBACK_COMMIT_DATE; let dateTimeFormatting: string | ((time: moment.Moment) => string); // adapt dateTimeFormatting based on the settings switch (dateTimeFormatOptions) { case "date": dateTimeFormatting = DATE_FORMAT; break; case "datetime": dateTimeFormatting = DATE_TIME_FORMAT_MINUTES; break; case "custom": dateTimeFormatting = dateTimeFormatCustomString; break; case "natural language": dateTimeFormatting = (time) => { const diff = time.diff(moment()); const addFluentSuffix = true; // 2 weeks -> 2 weeks ago return moment.duration(diff).humanize(addFluentSuffix); }; break; default: return impossibleBranch(dateTimeFormatOptions); } let authoringDate: moment.Moment = moment.unix( nonZeroCommit.author.epochSeconds ); // moment usually shows the above authoring date in the viewer local timezone. // when we want to show it in the absolute UTC time-zone, we'll need to provide // and adapt the utcOffset switch (dateTimeTimezone) { case "viewer-local": // moment uses local timezone by default. break; case "author-local": authoringDate = authoringDate.utcOffset( nonZeroCommit.author.tz ); if (typeof dateTimeFormatting === "string") dateTimeFormatting += " Z"; // add explicit time-zone, as this is not clear now. break; case "utc0000": authoringDate = authoringDate.utc(); // convert to utc if (typeof dateTimeFormatting === "string") dateTimeFormatting += "[Z]"; // add "Z" to indicate, that this is UTC+0000 time. break; default: return impossibleBranch(dateTimeTimezone); } // compute formatting based on dateTimeFormatting if (typeof dateTimeFormatting === "string") { return authoringDate.format(dateTimeFormatting); } else { return dateTimeFormatting(authoringDate); } } private adaptTextForFakeCommit( commit: BlameCommit, toBeRenderedText: string, options?: "waiting-for-result" ) { // attempt to use longest text as template for fake commit. const original = getLongestRenderedGutter()?.text ?? toBeRenderedText; // replace template with + or % depending on whether its a zero commit or waiting-for-result. // the % is used to make the UI update from % to the true characters unintrusive // waiting-for-result has higher priority than zero commit const fillCharacter = options !== "waiting-for-result" && commit.isZeroCommit ? NEW_CHANGE_CHARACTER : UNINTRUSIVE_CHARACTER_FOR_WAITING_RENDERING; toBeRenderedText = original.replace( NON_WHITESPACE_REGEXP, fillCharacter ); // Adapt the text to the same length as previously rendered gutters. // This ensures, that the frequent UI updates with differing line author lengths // don't frequently shift the gutter size - which would also cause distracting UI updates. const desiredTextLength = latestSettings.get()?.gutterSpacingFallbackLength ?? toBeRenderedText.length; toBeRenderedText = resizeToLength( toBeRenderedText, desiredTextLength, fillCharacter ); // For new changes, show only the a few + characters. if (options !== "waiting-for-result" && commit.isZeroCommit) { const numberOfLastCharactersToKeep = Math.min( desiredTextLength, NEW_CHANGE_NUMBER_OF_CHARACTERS ); toBeRenderedText = prefixOfLengthAsWhitespace( toBeRenderedText, desiredTextLength - numberOfLastCharactersToKeep ); } return toBeRenderedText; } } /** * Creates a {@link LineAuthoringGutter}. * * This function should be used instead of directly calling the constructor, * as we don't want to re-create the same instance multiple times, whenever the user * scrolls through a document. It simply stores the instances in the cache {@link gutterInstances}. */ export function lineAuthoringGutterMarker( la: Exclude, startLine: number, endLine: number, key: string, settings: LineAuthorSettings, options?: "waiting-for-result" ) { const digest = sha256.create(); digest.update(JSON.stringify(settings)); digest.update(`s${startLine}-e${endLine}-k${key}-o${options}`); const cacheKey = digest.hex(); const cached = gutterInstances.get(cacheKey); if (cached) return cached; const result = new LineAuthoringGutter( la, startLine, endLine, key, settings, options ); gutterInstances.set(cacheKey, result); return result; } ================================================ FILE: src/editor/lineAuthor/view/gutter/gutterElementSearch.ts ================================================ import { attachedGutterElements } from "src/editor/lineAuthor/view/cache"; const mouseXY = { x: -10, y: -10 }; // todo. According to a discord message from the Obsidian Team, the source bug // will be fixed in the next release. Then this hack should be removed. /** * Stores the last MouseDownEvent clientX and clientY position. * * This is part of the 'hack' to be able to detect the line author gutter element below * the mouse as part of the context menu. This is necessary, as I couldn't find * a way to retrieve the target gutter from the Obsidian "editor-menu" event. */ export function prepareGutterSearchForContextMenuHandling() { if (mouseXY.x === -10) { // event listener is not yet registered window.addEventListener("mousedown", (e) => { mouseXY.x = e.clientX; mouseXY.y = e.clientY; }); } } export function findGutterElementUnderMouse(): HTMLElement | undefined { for (const elt of attachedGutterElements) { if (contains(elt, mouseXY)) return elt; } } function contains(elt: HTMLElement, pt: { x: number; y: number }): boolean { const { x, y, width, height } = elt.getBoundingClientRect(); return x <= pt.x && pt.x <= x + width && y <= pt.y && pt.y <= y + height; } ================================================ FILE: src/editor/lineAuthor/view/gutter/initial.ts ================================================ import { moment } from "obsidian"; import { DEFAULT_SETTINGS } from "src/constants"; import type { LineAuthoring, LineAuthorSettings, } from "src/editor/lineAuthor/model"; import { latestSettings, maxAgeInDaysFromSettings, } from "src/editor/lineAuthor/model"; import { computeAdaptiveInitialColoringAgeInDays } from "src/editor/lineAuthor/view/cache"; import { lineAuthoringGutterMarker, TextGutter, } from "src/editor/lineAuthor/view/gutter/gutter"; import type { Blame, BlameCommit, GitTimestamp, UserEmail } from "src/types"; import { momentToEpochSeconds } from "src/utils"; /** * The gutter used to reserve the space used for the line authoring before it is loaded. * * Until the true length is known, it uses the last saved `gutterSpacingFallbackLength`. */ export function initialSpacingGutter() { const length = latestSettings.get()?.gutterSpacingFallbackLength ?? DEFAULT_SETTINGS.lineAuthor.gutterSpacingFallbackLength; return new TextGutter(Array(length).fill("-").join("")); } /** * Initial line authoring gutter with adaptive coloring for softer UI updates. * * **DO NOT CACHE THIS FUNCTION CALL, AS THE ADAPTIVE COLOR NEED TO BE FRESHLY CALCULATED.** */ export function initialLineAuthoringGutter(settings: LineAuthorSettings) { const { lineAuthoring, ageForInitialRender } = adaptiveInitialColoredWaitingLineAuthoring(settings); return lineAuthoringGutterMarker( lineAuthoring, 1, 1, "initialGutter" + ageForInitialRender, // use a age coloring based cache key settings, "waiting-for-result" ); } /** * Creates a line authoring with an adaptive initial color based on {@link computeAdaptiveInitialColoringAgeInDays} (previously recorded ages). * * If no such color is available, then it takes the 25% of the max age as the color. * e.g. for max age = 100 days, this means it'll use the color for the age of 25 days. * This case only happens on each (re-)start of Obsidian. * * We use a waiting-gutter, to have it rendered - so that we can use it's rendered text * and transform it into unintrusive placeholder characters. */ export function adaptiveInitialColoredWaitingLineAuthoring( settings: LineAuthorSettings ): { lineAuthoring: Exclude; ageForInitialRender: number; } { const ageForInitialRender: number = computeAdaptiveInitialColoringAgeInDays() ?? maxAgeInDaysFromSettings(settings) * 0.25; const slightlyOlderAgeForInitialRender: moment.Moment = moment().add( -ageForInitialRender, "days" ); const dummyAuthor = { name: "", epochSeconds: momentToEpochSeconds(slightlyOlderAgeForInitialRender), tz: "+0000", }; const dummyCommit = { hash: "waiting-for-result", author: dummyAuthor, committer: dummyAuthor, isZeroCommit: false, }; return { lineAuthoring: { hashPerLine: [undefined!, "waiting-for-result"], commits: new Map([["waiting-for-result", dummyCommit]]), }, ageForInitialRender, }; } ================================================ FILE: src/editor/lineAuthor/view/gutter/untrackedFile.ts ================================================ import { zeroCommit } from "src/gitManager/simpleGit"; import type { LineAuthorSettings } from "src/editor/lineAuthor/model"; import { lineAuthoringGutterMarker } from "src/editor/lineAuthor/view/gutter/gutter"; import type { Blame } from "src/types"; /** * The gutter to show on untracked files. */ export function newUntrackedFileGutter( key: string, settings: LineAuthorSettings ) { const dummyLineAuthoring = { hashPerLine: [undefined!, "000000"], commits: new Map([["000000", zeroCommit]]), }; return lineAuthoringGutterMarker(dummyLineAuthoring, 1, 1, key, settings); } ================================================ FILE: src/editor/lineAuthor/view/view.ts ================================================ import type { Extension, Range, Text } from "@codemirror/state"; import { RangeSet } from "@codemirror/state"; import type { EditorView, GutterMarker } from "@codemirror/view"; import { gutter } from "@codemirror/view"; import type { LineAuthoringWithChanges, LineAuthorSettings, } from "src/editor/lineAuthor/model"; import { laStateDigest, latestSettings, lineAuthorState, } from "src/editor/lineAuthor/model"; import { getLongestRenderedGutter, gutterMarkersRangeSet, } from "src/editor/lineAuthor/view/cache"; import { lineAuthoringGutterMarker, TextGutter, } from "src/editor/lineAuthor/view/gutter/gutter"; import { initialLineAuthoringGutter, initialSpacingGutter, } from "src/editor/lineAuthor/view/gutter/initial"; import { newUntrackedFileGutter } from "src/editor/lineAuthor/view/gutter/untrackedFile"; import { between } from "src/utils"; /* ================== VIEW ====================== Contains classes, variables and functions describing how the MODEL is rendered to a view. */ const UNDISPLAYED = new TextGutter(""); /** * The line author gutter as a Codemirror {@link Extension}. * * It simply renderes the line authoring state from the {@link lineAuthorState} state-field. */ export const lineAuthorGutter: Extension = gutter({ class: "line-author-gutter-container", markers(view) { // this is called a few times on every keystroke / cursor-move. Hence, it is efficient const lineAuthoring = view.state.field(lineAuthorState, false); return lineAuthoringGutterMarkersRangeSet(view, lineAuthoring); }, lineMarkerChange(update) { const newLineAuthoringId = laStateDigest( update.state.field(lineAuthorState) ); const oldLineAuthoringId = laStateDigest( update.startState.field(lineAuthorState) ); return oldLineAuthoringId !== newLineAuthoringId; }, renderEmptyElements: true, initialSpacer: (view) => { temporaryWorkaroundGutterSpacingForRenderedLineAuthoring(view); return initialSpacingGutter(); }, updateSpacer: (_sp, update) => { temporaryWorkaroundGutterSpacingForRenderedLineAuthoring(update.view); return getLongestRenderedGutter()?.gutter ?? initialSpacingGutter(); }, }); /** * Creates the gutter markers as a {@link RangeSet}. * * The computation result is cached for better performance via a SHA-256 `cacheKey`. * The actual computation happens in {@link computeLineAuthoringGutterMarkersRangeSet}. */ function lineAuthoringGutterMarkersRangeSet( view: EditorView, optLA?: LineAuthoringWithChanges ): RangeSet { const digest = laStateDigest(optLA); const doc = view.state.doc; // We don't digest this, even though it is used as an argument for the computation // This is because a change in the doc is only reflected in the line authoring // when the doc is saved. But saving changes the line authoring key anyways. // Each line is part of a block of 1 or more lines. Within a block only the newest // commit should be shown. Hence, we collect the start and end positions for each block here. const lineBlockEndPos: Map = new Map(); for (let line = 1; line <= doc.lines; line++) { const from = doc.line(line).from; const to = view.lineBlockAt(from).to; lineBlockEndPos.set(line, [from, to]); digest.update([from, to, 0]); } const laSettings = latestSettings.get(); digest.update("s" + Object.values(latestSettings).join(",")); const cacheKey = digest.hex(); const cached = gutterMarkersRangeSet.get(cacheKey); if (cached) return cached; // This is called infrequently enough to put the computation there. const { result, allowCache } = computeLineAuthoringGutterMarkersRangeSet( doc, lineBlockEndPos, laSettings, optLA ); if (allowCache) gutterMarkersRangeSet.set(cacheKey, result); return result; } function computeLineAuthoringGutterMarkersRangeSet( doc: Text, blocksPerLine: Map, settings: LineAuthorSettings, optLA?: LineAuthoringWithChanges ): { result: RangeSet; allowCache: boolean } { let allowCache = true; // invocations of initialLineAuthoringGutter shouldn't be cached const docLastLine = doc.lines; const ranges: Range[] = []; function add(from: number, to: number | undefined, gutter: GutterMarker) { return ranges.push(gutter.range(from, to)); } const lineFrom = computeLineMappingForUnsavedChanges(docLastLine, optLA); const emptyDoc = doc.length === 0; const lastLineIsEmpty = doc.iterLines(docLastLine, docLastLine + 1).next().value === ""; for (let startLine = 1; startLine <= docLastLine; startLine++) { const [from, to] = blocksPerLine.get(startLine)!; const endLine = doc.lineAt(to).number; if (emptyDoc) { add(from, to, UNDISPLAYED); continue; } if (startLine === docLastLine && lastLineIsEmpty) { add(from, to, UNDISPLAYED); continue; } if (optLA === undefined) { add(from, to, initialLineAuthoringGutter(settings)); allowCache = false; continue; } const { key, la } = optLA; if (la === "untracked") { add(from, to, newUntrackedFileGutter(la, settings)); continue; } const lastAuthorLine = la.hashPerLine.length - 1; const laStartLine = lineFrom[startLine]; const laEndLine = lineFrom[endLine]; if (laEndLine && laEndLine > lastAuthorLine) { add(from, to, UNDISPLAYED); } if ( laStartLine !== undefined && between(1, laStartLine, lastAuthorLine) && laEndLine !== undefined && between(1, laEndLine, lastAuthorLine) ) { add( from, to, lineAuthoringGutterMarker( la, laStartLine, laEndLine, key, settings ) ); continue; } // unsaved changes quick gutter update. scenario 1 if (lastAuthorLine < 1) { // file was empty, but now it's being written add(from, to, initialLineAuthoringGutter(settings)); allowCache = false; continue; } // unsaved changes quick gutter update. scenario 2 const start = Math.clamp(laStartLine ?? startLine, 1, lastAuthorLine); const end = Math.clamp(laEndLine ?? endLine, 1, lastAuthorLine); add( from, to, lineAuthoringGutterMarker( la, start, end, key + "computing", settings, "waiting-for-result" ) ); } return { result: RangeSet.of(ranges, /* sort = */ true), allowCache }; } // todo. explain. function computeLineMappingForUnsavedChanges( docLastLine: number, optLA: LineAuthoringWithChanges | undefined ): (number | undefined)[] { if (!optLA?.lineOffsetsFromUnsavedChanges) { return Array.from( new Array(docLastLine + 1), (ln) => ln ); } const lineFrom: (number | undefined)[] = [undefined]; let cumulativeLineOffset = 0; // may be negative for (let ln = 1; ln <= docLastLine; ln++) { const unsavedChanges = optLA.lineOffsetsFromUnsavedChanges.get(ln); cumulativeLineOffset += unsavedChanges ?? 0; // compute cumulative sum of line offsets // if no unsaved changes are there for the current line, then use // the cumulative offset, otherwise return undefined - which will be rendered as 'computing' lineFrom[ln] = unsavedChanges === undefined ? ln - cumulativeLineOffset : undefined; } return lineFrom; } /** * This applies a tempoary workaround for custom gutters for Obsidian v1.0. * * As of writing, the following problem exists: * * When the line authoring is rendered without anything else (i.e. line numbers) * the spacing is messed up and obscures the text. * * When the line authoring is shown together with the line numbers everything is fine. * * See the bug report: https://forum.obsidian.md/t/added-editor-gutter-overlaps-and-obscures-editor-content/45217 * * The conclusion of the analysis is, that we want to reset the `margin-left` style * property of the `.cm-gutters` container element **if and only if** the line authoring * is rendered. For this reason, the initialSpacer and updatesSpacer callbacks in * {@link lineAuthorGutter} call this function which reset the corresponding style. * * TODO: Remove this workaround, when this is fixed within Obsidian itself. */ function temporaryWorkaroundGutterSpacingForRenderedLineAuthoring( view: EditorView ) { const guttersContainers = view.dom.querySelectorAll(".cm-gutters"); guttersContainers.forEach((cont) => { if (!cont?.style) return; if (!cont.style.marginLeft) { cont.style.marginLeft = "unset"; } }); } ================================================ FILE: src/editor/signs/changesStatusBar.ts ================================================ import type ObsidianGit from "src/main"; import type { Hunk } from "./hunks"; import { MarkdownView, TFile } from "obsidian"; export class ChangesStatusBar { constructor( private statusBarEl: HTMLElement, private readonly plugin: ObsidianGit ) { statusBarEl.addClass("git-changes-status-bar"); if (plugin.settings.hunks.statusBar === "colored") { statusBarEl.addClass("git-changes-status-bar-colored"); } statusBarEl.setAttr("aria-label", "Git diff of the current editor"); this.statusBarEl.setAttribute("data-tooltip-position", "top"); plugin.app.workspace.on("active-leaf-change", (leaf) => { if ( !leaf || (leaf.getRoot() == plugin.app.workspace.rootSplit && !(leaf.view instanceof MarkdownView)) ) { this.statusBarEl.empty(); } }); } display(hunks: Hunk[], file: TFile | null): void { const mdView = this.plugin.app.workspace.getActiveViewOfType(MarkdownView); if (!mdView || mdView.file?.path !== file?.path) { return; } let added: number = 0, changed: number = 0, deleted: number = 0; for (const hunk of hunks) { added += Math.max(0, hunk.added.count - hunk.removed.count); changed += Math.min(hunk.added.count, hunk.removed.count); deleted += Math.max(0, hunk.removed.count - hunk.added.count); } this.statusBarEl.empty(); if (added > 0) { this.statusBarEl.createSpan({ text: `+${added} `, cls: "git-add", }); } if (changed > 0) { this.statusBarEl.createSpan({ text: `~${changed} `, cls: "git-change", }); } if (deleted > 0) { this.statusBarEl.createSpan({ text: `-${deleted}`, cls: "git-delete", }); } } remove() { this.statusBarEl.remove(); } } ================================================ FILE: src/editor/signs/diff.ts ================================================ import { Hunks, type Hunk } from "../signs/hunks"; import { Chunk } from "@codemirror/merge"; import { ChangeDesc, Text } from "@codemirror/state"; import { lineFromPos } from "./hunkState"; // function diffMatchPatch( // text1: string, // text2: string // ): diff.ChangeObject[] { // const toChars = linesToChars(text1, text2); // const lineText1 = toChars.chars1; // const lineText2 = toChars.chars2; // const lineArray = toChars.lineArray; // let diffs = makeDiff(lineText1, lineText2, { // checkLines: false, // }); // diffs = cleanupSemantic(diffs); // charsToLines(diffs, lineArray); // const res: diff.ChangeObject[] = []; // for (let i = 0; i < diffs.length; i++) { // res.push({ // added: diffs[i][0] == 1 ? true : false, // removed: diffs[i][0] == -1 ? true : false, // value: diffs[i][1], // count: diffs[i][1].split("\n").length - 1, // }); // } // // return res; // } type RawHunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; }; // export interface ChangeObject { // /** // * 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. // * 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. // */ // value: string; // /** // * true if the value was inserted into the new string, otherwise false // */ // added: boolean; // /** // * true if the value was removed from the old string, otherwise false // */ // removed: boolean; // } // // function changesToRawHunks(diff: ChangeObject[]): RawHunk[] { // diff.push({ value: "", added: false, removed: false }); // Append an empty value to make cleanup easier // // const hunks = []; // let oldRangeStart = 0, // newRangeStart = 0, // oldLine = 1, // newLine = 1; // for (let i = 0; i < diff.length; i++) { // const current = diff[i]; // const linesCount = // current.value.match(new RegExp(`\n`, "g"))?.length ?? 0; // // if (current.added || current.removed) { // // If we have previous context, start with that // if (!oldRangeStart) { // oldRangeStart = oldLine; // newRangeStart = newLine; // } // // // Track the updated file position // // If the change affects the last line of the document and does not // // end with '\n' increase the line count by 1 because the last line // // still needs to count. // if (current.added) { // newLine += linesCount; // if ( // !current.value.endsWith("\n") && // (i + 2 == diff.length || i + 3 == diff.length) // ) { // newLine += 1; // } // } else { // oldLine += linesCount; // if ( // !current.value.endsWith("\n") && // (i + 2 == diff.length || i + 3 == diff.length) // ) { // oldLine += 1; // } // } // } else { // if (oldRangeStart) { // // if (current.value.startsWith("\n")) { // // } // const hunk = { // oldStart: oldRangeStart, // oldLines: oldLine - oldRangeStart, // newStart: newRangeStart, // newLines: newLine - newRangeStart, // }; // hunks.push(hunk); // // // oldLine += 1; // // newLine += 1; // oldRangeStart = 0; // newRangeStart = 0; // // if (current.value.startsWith("\n")) { // // oldLine += linesCount - 1; // // newLine += linesCount - 1; // // } else { // oldLine += linesCount; // newLine += linesCount; // /* } */ // } else { // oldLine += linesCount; // newLine += linesCount; // } // } // } // return hunks; // } export function rawHunksToHunks( textA: string, textB: string, rawHunks: RawHunk[] ): Hunk[] { const hunks: Hunk[] = []; const linesA = textA.split("\n"); const linesB = textB.split("\n"); for (const rawHunk of rawHunks) { const { oldStart, oldLines, newStart, newLines } = rawHunk; const hunk = Hunks.createHunk(oldStart, oldLines, newStart, newLines); if (rawHunk.oldLines > 0) { for (let i = oldStart; i < oldStart + oldLines; i++) { hunk.removed.lines.push(linesA[i - 1]); } if (oldStart + oldLines > linesA.length && linesA.last() != "") { hunk.removed.no_nl_at_eof = true; } } if (rawHunk.newLines > 0) { for (let i = newStart; i < newStart + newLines; i++) { hunk.added.lines.push(linesB[i - 1]); } if (newStart + newLines > linesB.length && linesB.last() != "") { hunk.added.no_nl_at_eof = true; } } hunks.push(hunk); } return hunks; } export function rawHunkFromChunk( chunk: Chunk, aDoc: Text, bDoc: Text ): RawHunk { const oldStart = aDoc.lineAt(chunk.fromA).number; const oldLines = chunk.fromA == chunk.toA ? 0 : lineFromPos(aDoc, chunk.endA) - oldStart + 1; const newStart = bDoc.lineAt(chunk.fromB).number; const newLines = chunk.fromB == chunk.toB ? 0 : lineFromPos(bDoc, chunk.endB) - newStart + 1; const rawHunk = { oldStart, oldLines, newStart, newLines, }; if (rawHunk.oldLines == 0) { rawHunk.oldStart -= 1; } if (rawHunk.newLines == 0) { rawHunk.newStart -= 1; } return rawHunk; } const diffConfig = { scanLimit: 1000, timeout: 200, }; function diffViaCMMerge( textA: string, textB: string, chunks: readonly Chunk[] | undefined, changes: ChangeDesc | undefined ) { const aDoc = Text.of(textA.split("\n")); const bDoc = Text.of(textB.split("\n")); const newChunks = chunks && changes ? Chunk.updateB(chunks, aDoc, bDoc, changes, diffConfig) : Chunk.build(aDoc, bDoc, diffConfig); const rawHunks: RawHunk[] = []; for (let i = 0; i < newChunks.length; i++) { const chunk = newChunks[i]; const rawHunk = rawHunkFromChunk(chunk, aDoc, bDoc); rawHunks.push(rawHunk); } return { hunks: rawHunksToHunks(textA, textB, rawHunks), chunks: newChunks, }; } export function computeHunks( textA: string, textB: string, chunks: readonly Chunk[] | undefined, changes: ChangeDesc | undefined ): { hunks: Hunk[]; chunks: readonly Chunk[] } { const res = diffViaCMMerge(textA, textB, chunks, changes); return res; // const lineDiff = diff.diffLines(textA, textB, { // newlineIsToken: true, // }); // const lineDiff2 = diffLines( // prev.compareText, // transaction.newDoc.toString(), // { // newlineIsToken: false, // } // ); // console.log("lineDiff", lineDiff); // console.log("lineDiff2", lineDiff2); // const rawHunks = toRawHunks(lineDiff); // const rawHunks2 = structuredPatch( // "fileA", // "fileB", // prev.compareText, // transaction.newDoc.toString(), // "", // "", // { context: 0 } // ).hunks; // const linediff2 = diffMatchPatch(textA, textB); // const rawHunks = changesToRawHunks(linediff2); // console.log("rawHunks", rawHunks); // console.log("rawHunks3", rawHunks3); // console.log("rawHunks2", rawHunks2); // Adjust newStart for hunks which do not add any lines // This is more in the style of git diff // for (const hunk of rawHunks) { // if (hunk.newLines == 0) { // hunk.newStart -= 1; // } // if (hunk.oldLines == 0) { // hunk.oldStart -= 1; // } // } // console.log("linediff2", linediff2); // console.log("rawHunks", rawHunks); // const hunks = rawHunksToHunks(textA, textB, rawHunks); // console.log("hunks", hunks); // return hunks; } ================================================ FILE: src/editor/signs/gutter.ts ================================================ import { RangeSet, StateField, Transaction } from "@codemirror/state"; import { EditorView, gutter, GutterMarker } from "@codemirror/view"; import { Hunks, type Hunk, type SignType } from "./hunks"; import { DebouncedComputeHunksEffectType, GitCompareResultEffectType, hunksState, HunksStateHelper, } from "./hunkState"; import { togglePreviewHunk } from "./tooltip"; class GitGutterMarker extends GutterMarker { constructor( readonly type: SignType, readonly staged: boolean ) { super(); } toDOM(_: EditorView) { const marker = document.createElement("div"); marker.className = `git-gutter-marker git-${this.type} ${ this.staged ? "staged" : "unstaged" }`; if (this.type == "changedelete") { marker.setText("~"); } return marker; } } export const signsMarker = StateField.define({ create: () => RangeSet.empty, update: (rangeSet, tr) => { const data = tr.state.field(hunksState, false); if (!data) { return RangeSet.empty; } const newDebouncedHunks = tr.effects.some((effect) => effect.is(DebouncedComputeHunksEffectType) ); // Show new hunks for new compare results independent of a doc change const newCompareResult = tr.effects.some((effect) => effect.is(GitCompareResultEffectType) ); if ( newDebouncedHunks || newCompareResult || ((tr.docChanged || rangeSet.size == 0) && data.isDirty == false) ) { const linesWithSign = new Set(); const markers = getMarkers(tr, data.hunks, false, linesWithSign); const stagedMarkers = getMarkers( tr, data.stagedHunks, true, linesWithSign ); rangeSet = RangeSet.of([...markers, ...stagedMarkers], true); return rangeSet; } else if (tr.docChanged) { rangeSet = rangeSet.map(tr.changes); } return rangeSet; }, }); function getMarkers( tr: Transaction, hunks: Hunk[], staged: boolean, linesWithSign: Set ) { const signs = []; for (let i = 0; i < hunks.length; i++) { const prevHunk = i > 0 ? hunks[i - 1] : undefined; const nextHunk = i < hunks.length - 1 ? hunks[i + 1] : undefined; const hunk = hunks[i]; signs.push(...Hunks.calcSigns(prevHunk, hunk, nextHunk)); } const markers = []; for (const sign of signs) { if (linesWithSign.has(sign.lnum)) continue; const lineInfo = tr.state.doc.line(sign.lnum); linesWithSign.add(sign.lnum); markers.push( new GitGutterMarker(sign.type, staged).range( lineInfo.from, lineInfo.to ) ); } return markers; } export const signsGutter = gutter({ class: "git-signs-gutter", markers: (view) => view.state.field(signsMarker, false) ?? RangeSet.empty, initialSpacer: (_) => { return new GitGutterMarker("delete", false); }, domEventHandlers: { click: (view, line, event) => { const hunk = HunksStateHelper.getHunkAtPos(view.state, line.from, false) ?? HunksStateHelper.getHunkAtPos(view.state, line.from, true); if (!hunk) { return false; } togglePreviewHunk(view, line.from); event.preventDefault(); return false; }, }, }); ================================================ FILE: src/editor/signs/hunkActions.ts ================================================ import { editorInfoField, type Editor } from "obsidian"; import { HunksStateHelper } from "./hunkState"; import type { EditorView } from "codemirror"; import type ObsidianGit from "src/main"; import { Hunks } from "./hunks"; import type { SimpleGit } from "src/gitManager/simpleGit"; export class HunkActions { constructor(private readonly plugin: ObsidianGit) {} get editor(): { obEditor: Editor; editor: EditorView } | undefined { const obEditor = this.plugin.app.workspace.activeEditor?.editor; // @ts-expect-error, not typed const editor = obEditor?.cm as EditorView; if (!obEditor || !HunksStateHelper.hasHunksData(editor.state)) { return undefined; } return { editor, obEditor }; } private get gitManager(): SimpleGit { return this.plugin.gitManager as SimpleGit; } resetHunk(pos?: number): void { if (!this.editor) { return; } const { editor, obEditor } = this.editor; const hunk = HunksStateHelper.getHunk(editor.state, false, pos); if (hunk) { let lstart: number, lend: number; if (hunk.type === "delete") { lstart = hunk.added.start + 1; lend = hunk.added.start + 1; } else { lstart = hunk.added.start - 0; lend = hunk.added.start - 1 + hunk.added.count; } const from = editor.state.doc.line(lstart).from; const to = hunk.type === "delete" ? editor.state.doc.line(lend).from : editor.state.doc.line(lend).to + 1; let lines = hunk.removed.lines.join("\n"); if (hunk.removed.lines.length > 0 && !hunk.removed.no_nl_at_eof) { lines += "\n"; } obEditor.replaceRange( lines, obEditor.offsetToPos(from), obEditor.offsetToPos(to) ); obEditor.setSelection(obEditor.offsetToPos(from)); } } async stageHunk(pos?: number): Promise { if (!(await this.plugin.isAllInitialized())) { return; } if (!this.editor) { return; } const { editor } = this.editor; let hunk = HunksStateHelper.getHunk(editor.state, false, pos); let invert = false; if (!hunk) { hunk = HunksStateHelper.getHunk(editor.state, true, pos); invert = true; } if (!hunk) { return; } const filepath = editor.state.field(editorInfoField).file!.path; const patch = Hunks.createPatch(filepath, [hunk], "100644", invert).join("\n") + "\n"; await this.gitManager.applyPatch(patch); this.plugin.app.workspace.trigger("obsidian-git:refresh"); } goToHunk(direction: "first" | "last" | "next" | "prev"): void { if (!this.editor) { return; } const { editor, obEditor } = this.editor; const hunks = HunksStateHelper.getHunks(editor.state, false); const currentLine = obEditor.getCursor().line + 1; const hunkIndex = Hunks.findNearestHunk( currentLine, hunks, direction, true ); if (hunkIndex == undefined) { return; } const hunk = hunks[hunkIndex]; if (hunk) { const line = hunk.added.start - 1; obEditor.setCursor(line, 0); obEditor.scrollIntoView( { from: { line: line, ch: 0 }, to: { line: line + 1, ch: 0 }, }, true ); } } } ================================================ FILE: src/editor/signs/hunkState.ts ================================================ import { ChangeDesc, EditorState, StateEffect, StateField, Text, Transaction, } from "@codemirror/state"; import { Hunks, type Hunk } from "./hunks"; import { computeHunks } from "./diff"; import type { Chunk } from "@codemirror/merge"; import { pluginRef } from "src/pluginGlobalRef"; import { debounce, editorEditorField, editorInfoField, type Debouncer, } from "obsidian"; /** * Given a document and a position, return the corresponding line number in the * file. */ export function lineFromPos(doc: Text, pos: number): number { const lineData = doc.lineAt(pos); const no_nl_at_eof = !( lineData.text.length == 0 && lineData.number == doc.lines ); const fileLine = no_nl_at_eof ? lineData.number : lineData.number - 1; return fileLine; } export abstract class HunksStateHelper { static hasHunksData(state: EditorState): boolean { const data = state.field(hunksState, false); return !!data && !data.isDirty; } static getHunks(state: EditorState, staged: boolean): Hunk[] { const data = state.field(hunksState); if (!data) return []; return staged ? data.stagedHunks : data.hunks; } static getHunkAtPos( state: EditorState, pos: number, staged: boolean ): Hunk | undefined { const data = state.field(hunksState); if (!data) return undefined; const line = state.doc.lineAt(pos).number; const hunks = this.getHunks(state, staged); return Hunks.findHunk(line, hunks)[0]; } static getCursorHunk( state: EditorState, staged: boolean ): Hunk | undefined { const data = state.field(hunksState); if (!data) return undefined; const cursorLine = state.selection.main.head; return this.getHunkAtPos(state, cursorLine, staged); } static getHunk( state: EditorState, staged: boolean, pos?: number ): Hunk | undefined { if (pos != undefined) { return this.getHunkAtPos(state, pos, staged); } if (state.selection.main.empty) { return this.getCursorHunk(state, staged); } const from = state.selection.main.from; const to = state.selection.main.to; const fromLine = state.doc.lineAt(from).number; const toLine = lineFromPos(state.doc, to); const hunks = this.getHunks(state, staged); const hunk = Hunks.createPartialHunk(hunks, fromLine, toLine); if (!hunk) { return undefined; } const data = state.field(hunksState)!; if (staged) { let stagedTop = fromLine; let stagedBot = toLine; for (const h of data.hunks) { if (fromLine > h.vend) { stagedTop = stagedTop - (h.added.count - h.removed.count); } if (toLine > h.vend) { stagedBot = stagedBot - (h.added.count - h.removed.count); } } hunk.added.lines = data .compareText!.split("\n") .slice(stagedTop - 1, stagedBot); if (data.compareTextHead) { hunk.removed.lines = data.compareTextHead .split("\n") .slice( hunk.removed.start - 1, hunk.removed.start - 1 + hunk.removed.count ); } else { hunk.removed.lines = []; } } else { hunk.added.lines = state.doc .toString() .split("\n") .slice(fromLine - 1, toLine); const no_nl_at_eof = toLine === state.doc.lines && !state.doc.toString().endsWith("\n"); if (no_nl_at_eof) { hunk.added.no_nl_at_eof = true; } hunk.removed.lines = data .compareText!.split("\n") .slice( hunk.removed.start - 1, hunk.removed.start - 1 + hunk.removed.count ); if ( hunk.removed.start + hunk.removed.count - 1 === data.compareText!.split("\n").length && !data.compareText!.endsWith("\n") ) { hunk.removed.no_nl_at_eof = true; } } return hunk; } } export const hunksState: StateField = StateField.define< HunksData | undefined >({ create: (_state) => undefined, update: (previous, transaction) => { const hunksData: HunksData = previous ? { ...previous } : { maxDiffTimeMs: 0, hunks: [], stagedHunks: [], chunks: undefined, isDirty: false, }; let newCompare = false; for (const effect of transaction.effects) { if (effect.is(GitCompareResultEffectType)) { hunksData.compareText = effect.value.compareText; hunksData.compareTextHead = effect.value.compareTextHead; // Only issue new hunk computation if compareText has changed newCompare = previous?.compareText !== effect.value.compareText; if (newCompare) { hunksData.chunks = undefined; } } if (effect.is(DebouncedComputeHunksEffectType)) { applyHunkComputation( hunksData, effect.value, transaction.state ); } } if (hunksData.compareText !== undefined) { if (newCompare || transaction.docChanged) { hunksData.isDirty = true; const res = scheduleHunkComputation( transaction, hunksData.compareText, hunksData.chunks, hunksData.maxDiffTimeMs ); if (res) { applyHunkComputation(hunksData, res, transaction.state); } } } else { hunksData.compareText = undefined; hunksData.compareTextHead = undefined; hunksData.chunks = undefined; hunksData.hunks = []; hunksData.stagedHunks = []; hunksData.isDirty = false; } return hunksData; }, }); function applyHunkComputation( hunkData: HunksData, computeData: ComputedHunksData, state: EditorState ) { hunkData.hunks = computeData.hunks; hunkData.chunks = computeData.chunks; hunkData.isDirty = false; hunkData.maxDiffTimeMs = Math.max( 0.95 * hunkData.maxDiffTimeMs, computeData.diffDuration ); const file = state.field(editorInfoField).file; pluginRef.plugin?.editorIntegration.signsFeature.changeStatusBar?.display( hunkData.hunks, file ); } export const computeHunksDebouncerStateField = StateField.define<{ changeDesc?: ChangeDesc; debouncer: Debouncer< [ { state: EditorState; compareText: string; previousChunks: readonly Chunk[] | undefined; changeDesc: ChangeDesc | undefined; }, ], void >; }>({ create: () => { return { debouncer: debounce( (data) => { const { state, compareText, previousChunks, changeDesc } = data; const res = computeHunksTimed( state, compareText, previousChunks, changeDesc ); state.field(editorEditorField).dispatch({ effects: DebouncedComputeHunksEffectType.of(res), }); }, 1000, true ), maxDiffTimeMs: 0, }; }, update: (data, transaction) => { for (const effect of transaction.effects) { if (effect.is(DebouncedComputeHunksEffectType)) { data.changeDesc = undefined; return data; } } if (!data.changeDesc && transaction.changes) { data.changeDesc = transaction.changes; } else { data.changeDesc = data.changeDesc?.composeDesc(transaction.changes); } return data; }, }); function computeHunksTimed( state: EditorState, compareText: string, previousChunks: readonly Chunk[] | undefined, changeDesc: ChangeDesc | undefined ): ComputedHunksData { const editorText = state.doc.toString(); const startTime = performance.now(); const { hunks, chunks } = computeHunks( compareText, editorText, previousChunks, changeDesc ); const diffDuration = performance.now() - startTime; return { hunks, chunks, diffDuration }; } function scheduleHunkComputation( transaction: Transaction, compareText: string, previousChunks: readonly Chunk[] | undefined, maxDiffTimeMs: number ): ComputedHunksData | undefined { const state = transaction.state; const changeLength = Math.abs( transaction.changes.length - transaction.changes.newLength ); const debouncerField = state.field(computeHunksDebouncerStateField); // Debounce large changes or if a previous diff took long time if (changeLength > 1000 || maxDiffTimeMs > 16) { debouncerField.debouncer({ state, compareText, previousChunks, changeDesc: debouncerField.changeDesc, }); } else { // This technically breaks the immutability of the StateField, but I // think it's acceptable here. The debouncer itself is not very // immutable either way. debouncerField.changeDesc = undefined; return computeHunksTimed( state, compareText, previousChunks, transaction.changes ); } } export const GitCompareResultEffectType = StateEffect.define(); export const DebouncedComputeHunksEffectType = StateEffect.define(); export type ComputedHunksData = { hunks: Hunk[]; chunks: readonly Chunk[] | undefined; diffDuration: number; }; export type HunksData = { hunks: Hunk[]; stagedHunks: Hunk[]; chunks: readonly Chunk[] | undefined; isDirty: boolean; maxDiffTimeMs: number; } & GitCompareResult; export type GitCompareResult = { compareText?: string; compareTextHead?: string; }; export function newGitCompareResultAsTransaction( data: GitCompareResult, state: EditorState ): Transaction { return state.update({ effects: GitCompareResultEffectType.of(data), }); } ================================================ FILE: src/editor/signs/hunks.ts ================================================ /** * This file contains code translated from Lua to TypeScript. * Original Source: https://github.com/lewis6991/gitsigns.nvim/blob/main/lua/gitsigns/hunks.lua * Original Author: Lewis Russell * License: MIT * Original Copyright (c) 2020 Lewis Russell */ import type { GitCompareResult } from "./hunkState"; export type HunkType = "add" | "change" | "delete"; export interface HunkNode { start: number; count: number; lines: string[]; no_nl_at_eof?: true; } export interface Hunk { type: HunkType; head: string; added: HunkNode; removed: HunkNode; vend: number; } export type SignType = HunkType | "topdelete" | "changedelete" | "untracked"; export interface Sign { type: SignType; /// Number of lines added/removed. Only set on the first line of a hunk. count?: number; lnum: number; } export interface StatusObj { added: number; changed: number; removed: number; } export abstract class Hunks { static createHunk( oldStart: number, oldCount: number, newStart: number, newCount: number ): Hunk { return { removed: { start: oldStart, count: oldCount, lines: [] }, added: { start: newStart, count: newCount, lines: [] }, head: `@@ -${oldStart}${oldCount > 0 ? `,${oldCount}` : ""} ` + `+${newStart}${newCount > 0 ? `,${newCount}` : ""} @@`, vend: newStart + Math.max(newCount - 1, 0), type: newCount === 0 ? "delete" : oldCount === 0 ? "add" : "change", }; } static createPartialHunk( hunks: Hunk[], top: number, bot: number ): Hunk | undefined { let pretop = top; let precount = bot - top + 1; let unused = 0; for (const h of hunks) { const addedInHunk = h.added.count - h.removed.count; let addedInRange = 0; if (h.added.start >= top && h.vend <= bot) { addedInRange = addedInHunk; } else { const addedAboveBot = Math.max( 0, bot + 1 - (h.added.start + h.removed.count) ); const addedAboveTop = Math.max( 0, top - (h.added.start + h.removed.count) ); if (h.added.start >= top && h.added.start <= bot) { addedInRange = addedAboveBot; } else if (h.vend >= top && h.vend <= bot) { addedInRange = addedInHunk - addedAboveTop; pretop = pretop - addedAboveTop; } else if (h.added.start <= top && h.vend >= bot) { addedInRange = addedAboveBot - addedAboveTop; pretop = pretop - addedAboveTop; } else { unused++; } if (top > h.vend) { pretop = pretop - addedInHunk; } } precount = precount - addedInRange; } if (unused === hunks.length) { return undefined; } if (precount === 0) { pretop = pretop - 1; } return this.createHunk(pretop, precount, top, bot - top + 1); } patchLines(hunk: Hunk, stripCr: boolean = false): string[] { const lines: string[] = []; for (const l of hunk.removed.lines) { lines.push("-" + l); } for (const l of hunk.added.lines) { lines.push("+" + l); } if (stripCr) { return lines.map((l) => l.replace(/\r$/, "")); } return lines; } static parseDiffLine(line: string): Hunk { const parts = line.split("@@"); const diffkey = parts[1].trim(); // diffkey: "-xx,n +yy,m" const tokens = diffkey.split(" "); const pre = tokens[0].substring(1).split(","); const now = tokens[1].substring(1).split(","); const hunk = this.createHunk( parseInt(pre[0]), parseInt(pre[1] || "1"), parseInt(now[0]), parseInt(now[1] || "1") ); hunk.head = line; return hunk; } private static changeEnd(hunk: Hunk): number { if (hunk.added.count === 0) { return hunk.added.start; } else if (hunk.removed.count === 0) { return hunk.added.start + hunk.added.count - 1; } else { return ( hunk.added.start + Math.min(hunk.added.count, hunk.removed.count) - 1 ); } } static calcSigns( prevHunk: Hunk | undefined, hunk: Hunk, nextHunk: Hunk | undefined, minLnum: number = 1, maxLnum: number = Infinity, untracked?: boolean ): Sign[] { if (untracked && hunk.type !== "add") { console.error( `Invalid hunk with untracked=${untracked} hunk="${hunk.head}"` ); return []; } minLnum = Math.max(1, minLnum); const { start, added, removed } = { start: hunk.added.start, added: hunk.added.count, removed: hunk.removed.count, }; const cend = this.changeEnd(hunk); const topdelete = hunk.type === "delete" && (start === 0 || (prevHunk && this.changeEnd(prevHunk) === start)) && (!nextHunk || nextHunk.added.start !== start + 1); if (topdelete && minLnum === 1) { minLnum = 0; } const signs: Sign[] = []; for ( let lnum = Math.max(start, minLnum); lnum <= Math.min(cend, maxLnum); lnum++ ) { const changedelete = hunk.type === "change" && ((removed > added && lnum === cend) || (prevHunk && prevHunk.added.start === 0)); signs.push({ type: topdelete ? "topdelete" : changedelete ? "changedelete" : untracked ? "untracked" : hunk.type, count: lnum === start ? hunk.type === "add" ? added : removed : undefined, lnum: lnum + (topdelete ? 1 : 0), }); } if ( hunk.type === "change" && added > removed && hunk.vend >= minLnum && cend <= maxLnum ) { for ( let lnum = Math.max(cend, minLnum); lnum <= Math.min(hunk.vend, maxLnum); lnum++ ) { signs.push({ type: "add", count: lnum === hunk.vend ? added - removed : undefined, lnum, }); } } return signs; } static createPatch( relpath: string, hunks: Hunk[], modeBits: string, invert: boolean = false ): string[] { const results = [ `diff --git a/${relpath} b/${relpath}`, `index 000000..000000 ${modeBits}`, `--- a/${relpath}`, `+++ b/${relpath}`, ]; let offset = 0; hunks = structuredClone(hunks); for (const processHunk of hunks) { let start = processHunk.removed.start; let preCount = processHunk.removed.count; let nowCount = processHunk.added.count; if (processHunk.type === "add") { start = start + 1; } let preLines = processHunk.removed.lines; let nowLines = processHunk.added.lines; if (invert) { [preCount, nowCount] = [nowCount, preCount]; [preLines, nowLines] = [nowLines, preLines]; } results.push( `@@ -${start},${preCount} +${start + offset},${nowCount} @@` ); for (const l of preLines) { results.push("-" + l); } if ( (invert ? processHunk.added : processHunk.removed).no_nl_at_eof ) { results.push("\\ No newline at end of file"); } for (const l of nowLines) { results.push("+" + l); } if ( (invert ? processHunk.removed : processHunk.added).no_nl_at_eof ) { results.push("\\ No newline at end of file"); } processHunk.removed.start = start + offset; offset = offset + (nowCount - preCount); } return results; } getSummary(hunks: Hunk[]): StatusObj { const status: StatusObj = { added: 0, changed: 0, removed: 0 }; for (const hunk of hunks) { if (hunk.type === "add") { status.added += hunk.added.count; } else if (hunk.type === "delete") { status.removed += hunk.removed.count; } else if (hunk.type === "change") { const add = hunk.added.count; const remove = hunk.removed.count; const delta = Math.min(add, remove); status.changed += delta; status.added += add - delta; status.removed += remove - delta; } } return status; } static findHunk( lnum: number, hunks?: Hunk[] ): [Hunk, number] | [undefined, undefined] { if (!hunks) return [undefined, undefined]; for (let i = 0; i < hunks.length; i++) { const hunk = hunks[i]; if (lnum === 1 && hunk.added.start === 0 && hunk.vend === 0) { return [hunk, i]; } if (hunk.added.start <= lnum && hunk.vend >= lnum) { return [hunk, i]; } } return [undefined, undefined]; } static findNearestHunk( lnum: number, hunks: Hunk[], direction: "first" | "last" | "next" | "prev", wrap?: boolean ): number | undefined { if (hunks.length === 0) { return undefined; } else if (direction === "first") { return 0; } else if (direction === "last") { return hunks.length - 1; } else if (direction === "next") { if (hunks[0].added.start > lnum) { return 0; } for (let i = hunks.length - 1; i >= 0; i--) { if (hunks[i].added.start <= lnum) { if ( i + 1 < hunks.length && hunks[i + 1].added.start > lnum ) { return i + 1; } else if (wrap) { return 0; } } } } else if (direction === "prev") { if (Math.max(hunks[hunks.length - 1].vend) < lnum) { return hunks.length - 1; } for (let i = 0; i < hunks.length; i++) { if (lnum <= Math.max(hunks[i].vend, 1)) { if (i > 0 && Math.max(hunks[i - 1].vend, 1) < lnum) { return i - 1; } else if (wrap) { return hunks.length - 1; } } } } return undefined; } compareHeads(a?: Hunk[], b?: Hunk[]): boolean { if ((a === undefined) !== (b === undefined)) { return true; } else if (a && b && a.length !== b.length) { return true; } for (let i = 0; i < (a || []).length; i++) { if (b![i].head !== a![i].head) { return true; } } return false; } private static compare(a: Hunk, b: Hunk): boolean { if (a.added.start !== b.added.start) { return false; } if (a.added.count !== b.added.count) { return false; } for (let i = 0; i < a.added.count; i++) { if (a.added.lines[i] !== b.added.lines[i]) { return false; } } return true; } private static filterCommon(a?: Hunk[], b?: Hunk[]): Hunk[] | undefined { if (!a && !b) { return undefined; } a = a || []; b = b || []; let aI = 0; let bI = 0; const ret: Hunk[] = []; for (let _ = 0; _ < Math.max(a.length, b.length) + 1; _++) { const aH = a[aI]; const bH = b[bI]; // End of a if (!aH) { break; } // End of b and add remaining a if (!bH) { for (let i = aI; i < a.length; i++) { ret.push(a[i]); } break; } if (aH.added.start > bH.added.start) { bI++; } else if (aH.added.start < bH.added.start) { ret.push(aH); aI++; } else { if (!this.compare(aH, bH)) { // let topOffset = 0; // for (let j = 0; j < aH.added.count; j++) { // const lineA = aH.added.lines[j]; // const lineB = bH.added.lines[j]; // // if (!lineB) { // topOffset = j; // break; // } // // if (lineA !== lineB) { // topOffset = j; // } // } // // if (topOffset < aH.added.count) { // const newHunk: Hunk = { // head: aH.head, // type: aH.type, // added: { // start: aH.added.start + topOffset, // count: aH.added.count - topOffset, // lines: aH.added.lines.slice(topOffset), // no_nl_at_eof: aH.added.no_nl_at_eof, // }, // removed: { // start: aH.removed.start, // count: aH.removed.count, // lines: aH.removed.lines, // no_nl_at_eof: aH.removed.no_nl_at_eof, // }, // vend: aH.added.start + aH.added.count - 1, // }; // ret.push(newHunk); // } else { ret.push(aH); // } } aI++; bI++; } } return ret; } static computeStagedHunks( headHunks: Hunk[], hunks: Hunk[], // eslint-disable-next-line @typescript-eslint/no-unused-vars compare: GitCompareResult ): Hunk[] { const filteredHunks = Hunks.filterCommon(headHunks, hunks)!; // // // Update staged hunk added lines to match compareText and not // // include unstaged changes // const compareTextLines = compare.compareText!.split("\n"); // for (const hunk of filteredHunks) { // for (let i = 0; i < hunk.added.lines.length; i++) { // hunk.added.lines[i] = // compareTextLines[Math.max(0, hunk.removed.start - 1) + i]; // } // let offset = 0; // for ( // let i = 0; // i < // Math.min(hunk.added.lines.length, hunk.removed.lines.length); // i++ // ) { // if (hunk.added.lines[i] != hunk.removed.lines[i]) { // break; // } else { // offset++; // } // } // if (offset > 0) { // hunk.added.lines = hunk.added.lines.slice(offset); // hunk.removed.lines = hunk.removed.lines.slice(offset); // hunk.added.start += offset; // hunk.removed.start += offset; // hunk.added.count -= offset; // hunk.removed.count -= offset; // } // } return filteredHunks; } } ================================================ FILE: src/editor/signs/signsIntegration.ts ================================================ import type { Extension } from "@codemirror/state"; import type { EventRef, WorkspaceLeaf } from "obsidian"; import { MarkdownView, Platform, TFile } from "obsidian"; import { SimpleGit } from "src/gitManager/simpleGit"; import type ObsidianGit from "src/main"; import { enabledHunksExtensions, enabledSignsExtensions, SignsProvider, } from "./signsProvider"; import { eventsPerFilePathSingleton } from "../eventsPerFilepath"; import { ChangesStatusBar } from "./changesStatusBar"; /** * Manages the interaction between Obsidian (file-open event, modification event, etc.) * and the signs feature. It also manages the (de-) activation of the * signs functionality. */ export class SignsFeature { private signsProvider?: SignsProvider; private workspaceLeafChangeEvent?: EventRef; private fileRenameEvent?: EventRef; private intervalRefreshEvent?: number; private pluginRefreshedEvent?: EventRef; private gutterContextMenuEvent?: EventRef; private codeMirrorExtensions: Extension[] = []; public changeStatusBar?: ChangesStatusBar; constructor(private plg: ObsidianGit) {} // ========================= INIT and DE-INIT ========================== public onLoadPlugin() { this.plg.registerEditorExtension(this.codeMirrorExtensions); } public conditionallyActivateBySettings() { if ( this.plg.settings.hunks.showSigns || this.plg.settings.hunks.statusBar != "disabled" || this.plg.settings.hunks.hunkCommands ) { this.activateFeature(); } } public activateFeature() { try { if (!this.isAvailableOnCurrentPlatform().available) return; this.signsProvider = new SignsProvider(this.plg); this.createEventHandlers(); this.activateCodeMirrorExtensions(); if (this.plg.settings.hunks.statusBar != "disabled") { const statusBarEl = this.plg.addStatusBarItem(); this.changeStatusBar = new ChangesStatusBar( statusBarEl, this.plg ); } } catch (e) { console.warn("Git: Error while loading signs feature.", e); this.deactivateFeature(); } } /** * Deactivates the feature. This function is very defensive, as it is also * called to cleanup, if a critical error in the line authoring has occurred. */ public deactivateFeature() { this.destroyEventHandlers(); this.deactivateCodeMirrorExtensions(); this.signsProvider?.destroy(); this.signsProvider = undefined; this.changeStatusBar?.remove(); this.changeStatusBar = undefined; } public isAvailableOnCurrentPlatform(): { available: boolean; gitManager: SimpleGit; } { return { available: this.plg.useSimpleGit && Platform.isDesktopApp, gitManager: this.plg.gitManager instanceof SimpleGit ? this.plg.gitManager : undefined!, }; } // ========================= REFRESH ========================== public refresh() { if (this.plg.settings.hunks.showSigns) { this.plg.app.workspace.iterateAllLeaves(this.handleWorkspaceLeaf); } } // ========================= CODEMIRROR EXTENSIONS ========================== private activateCodeMirrorExtensions() { // Yes, we need to directly modify the array and notify the change to have // toggleable Codemirror extensions. this.codeMirrorExtensions.push(enabledHunksExtensions); if (this.plg.settings.hunks.showSigns) { this.codeMirrorExtensions.push(...enabledSignsExtensions); } this.plg.app.workspace.updateOptions(); // Handle all already opened files this.plg.app.workspace.iterateAllLeaves(this.handleWorkspaceLeaf); } private deactivateCodeMirrorExtensions() { // Yes, we need to directly modify the array and notify the change to have // toggleable Codemirror extensions. for (const ext of this.codeMirrorExtensions) { this.codeMirrorExtensions.remove(ext); } this.plg.app.workspace.updateOptions(); } // ========================= HANDLERS ========================== private createEventHandlers() { this.workspaceLeafChangeEvent = this.createWorkspaceLeafChangeEvent(); this.fileRenameEvent = this.createFileRenameEvent(); this.pluginRefreshedEvent = this.createPluginRefreshedEvent(); this.intervalRefreshEvent = this.createIntervalRefreshEvent(); this.plg.registerEvent(this.workspaceLeafChangeEvent); this.plg.registerEvent(this.fileRenameEvent); this.plg.registerEvent(this.pluginRefreshedEvent); this.plg.registerInterval(this.intervalRefreshEvent); } private destroyEventHandlers() { this.plg.app.workspace.offref(this.workspaceLeafChangeEvent!); this.plg.app.vault.offref(this.fileRenameEvent!); this.plg.app.workspace.offref(this.pluginRefreshedEvent!); this.plg.app.workspace.offref(this.gutterContextMenuEvent!); window.clearInterval(this.intervalRefreshEvent); } private handleWorkspaceLeaf = (leaf: WorkspaceLeaf) => { if (!this.signsProvider) { console.warn("Git: undefined signsProvider. Unexpected situation."); return; } const obsView = leaf?.view; if ( !(obsView instanceof MarkdownView) || obsView.file == null || obsView?.allowNoFile === true ) return; this.signsProvider.trackChanged(obsView.file).catch(console.error); }; private createWorkspaceLeafChangeEvent(): EventRef { return this.plg.app.workspace.on( "active-leaf-change", this.handleWorkspaceLeaf ); } private createFileRenameEvent(): EventRef { return this.plg.app.vault.on("rename", (file, _old) => { // Notify all subscribers of the old filepath to resubscribe to the new filepath eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers( _old, (subs) => { return subs.forEach((las) => { las.changeToNewFilepath(file.path); }); } ); return ( file instanceof TFile && this.signsProvider?.trackChanged(file) ); }); } private createPluginRefreshedEvent(): EventRef { return this.plg.app.workspace.on("obsidian-git:refresh", () => { this.refresh(); }); } private createIntervalRefreshEvent(): number { // Refresh every 10 seconds the active editor to account for external // git index changes return window.setInterval(() => { if (this.plg.app.workspace.activeEditor?.file) { this.signsProvider ?.trackChanged(this.plg.app.workspace.activeEditor.file) .catch(console.error); } }, 10 * 1000); } } ================================================ FILE: src/editor/signs/signsProvider.ts ================================================ import type { Extension } from "@codemirror/state"; import type { TFile } from "obsidian"; import { eventsPerFilePathSingleton } from "src/editor/eventsPerFilepath"; import type ObsidianGit from "src/main"; import { computeHunksDebouncerStateField, hunksState, type GitCompareResult, } from "../signs/hunkState"; import { signsGutter, signsMarker } from "../signs/gutter"; import { cursorTooltipBaseTheme, diffTooltipField, selectedHunksState, } from "./tooltip"; export { previewColor } from "src/editor/lineAuthor/view/gutter/coloring"; export class SignsProvider { constructor(private plugin: ObsidianGit) {} public async trackChanged(file: TFile) { return this.trackChangedHelper(file).catch((reason) => { console.warn("Git: Error in trackChanged." + reason); // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors return Promise.reject(reason); }); } private async trackChangedHelper(file: TFile) { if (!file) return; if (file.path === undefined) { console.warn( "Git: Attempted to track change of undefined filepath. Unforeseen situation." ); return; } return this.computeSigns(file.path); } public destroy() {} private async computeSigns(filepath: string) { const gitManager = this.plugin.editorIntegration.lineAuthoringFeature.isAvailableOnCurrentPlatform() .gitManager; // const headRevision = // await gitManager.submoduleAwareHeadRevisonInContainingDirectory( // filepath // ); const compareText = await gitManager .show("", filepath) .catch(() => undefined); // const compareTextHead = await gitManager // .show(headRevision, filepath) // .catch(() => undefined); const compareTextHead = undefined; this.notifySignComputationResultToSubscribers(filepath, { compareText, compareTextHead, }); } private notifySignComputationResultToSubscribers( filepath: string, data: GitCompareResult ) { eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers( filepath, (subs) => subs.forEach((sub) => sub.notifyGitCompare(data)) ); } } export const enabledSignsExtensions: Extension[] = [ diffTooltipField, cursorTooltipBaseTheme, signsGutter, signsMarker, selectedHunksState, ]; export const enabledHunksExtensions = [ hunksState, computeHunksDebouncerStateField, ]; ================================================ FILE: src/editor/signs/tooltip.ts ================================================ import { EditorState, StateEffect, StateField } from "@codemirror/state"; import { EditorView, showTooltip, type Tooltip, type TooltipView, } from "@codemirror/view"; import { GitCompareResultEffectType, hunksState } from "./hunkState"; import { Hunks, type Hunk } from "./hunks"; import { html } from "diff2html"; import { ColorSchemeType } from "diff2html/lib/types"; import { pluginRef } from "src/pluginGlobalRef"; import { editorEditorField, MarkdownView, setIcon } from "obsidian"; const selectHunkEffectType = StateEffect.define<{ pos: number; add: boolean; }>(); export function togglePreviewHunk(editor: EditorView, pos?: number) { const state = editor.state; const selectedHunks = state.field(selectedHunksState); const hunksData = state.field(hunksState); const line = state.doc.lineAt(pos ?? state.selection.main.head).number; const hunk = Hunks.findHunk(line, hunksData?.hunks)[0]; if (!hunk) return; const hunkStartPos = state.doc.line(Math.max(1, hunk.added.start)).from; const isSelected = selectedHunks.has(hunkStartPos); return state.field(editorEditorField).dispatch({ effects: selectHunkEffectType.of({ pos: hunkStartPos, add: !isSelected, }), }); } export const selectedHunksState = StateField.define>({ create: () => new Set(), update(value, transaction) { const newValue = new Set(); for (const effect of transaction.effects) { if (effect.is(selectHunkEffectType)) { if (effect.value.add) { value.add(effect.value.pos); } else { value.delete(effect.value.pos); } } } for (const pos of value) { newValue.add(transaction.changes.mapPos(pos)); } return newValue; }, }); export const diffTooltipField = StateField.define({ create: (state) => { return getTooltips(state); }, update(value, transaction) { if ( transaction.docChanged || transaction.effects.some( (e) => e.is(GitCompareResultEffectType) || e.is(selectHunkEffectType) ) ) { return getTooltips(transaction.state); } return value; }, provide: (f) => showTooltip.computeN([f], (state) => state.field(f)), }); export const cursorTooltipBaseTheme = EditorView.baseTheme({ ".cm-tooltip.git-diff-tooltip": { "z-index": "var(--layer-popover)", backgroundColor: "var(--background-primary-alt)", border: "var(--border-width) solid var(--background-primary-alt)", borderRadius: "var(--radius-s)", }, ".cm-tooltip.git-diff-tooltip .tooltip-toolbar": { display: "flex", padding: "var(--size-2-1)", }, }); function getTooltips(state: EditorState): Tooltip[] { const hunksData = state.field(hunksState); if (hunksData) { const selectedHunks = state.field(selectedHunksState); return [...selectedHunks] .map((selectedPos) => { const line = state.doc.lineAt(selectedPos); const hunk = Hunks.findHunk(line.number, hunksData.hunks)[0]; if (!hunk) return undefined; return { pos: selectedPos, above: false, arrow: false, strictSide: true, clip: false, create: () => { return createTooltip(hunk, state, selectedPos); }, }; }) .filter((tip) => tip !== undefined); } else { return []; } } function createTooltip( hunk: Hunk, state: EditorState, pos: number ): TooltipView { const patch = Hunks.createPatch("file", [hunk], "10064", false).join("\n") + "\n"; const patchHtml = html(patch, { colorScheme: ColorSchemeType.AUTO, diffStyle: "word", drawFileList: false, }); const diffEl = new DOMParser() .parseFromString(patchHtml, "text/html") .querySelector(".d2h-file-diff"); const contentEl = document.createElement("div"); // toolbar const toolbar = document.createElement("div"); toolbar.addClass("tooltip-toolbar"); const makeButton = (icon: string, label: string) => { const btn = document.createElement("div"); setIcon(btn, icon); btn.setAttr("aria-label", label); btn.addClass("clickable-icon"); return btn; }; const closeBtn = makeButton("x", "Close hunk"); const stageBtn = makeButton("plus", "Stage hunk"); const resetBtn = makeButton("undo", "Reset hunk"); toolbar.appendChild(closeBtn); toolbar.appendChild(stageBtn); toolbar.appendChild(resetBtn); // append toolbar and diff contentEl.appendChild(toolbar); contentEl.appendChild(diffEl!); contentEl.addClass("git-diff-tooltip", "git-diff"); const editor = state.field(editorEditorField); // handlers closeBtn.onclick = () => { togglePreviewHunk(editor, pos); }; stageBtn.onclick = () => { const plugin = pluginRef.plugin; if (!plugin) return; plugin.promiseQueue.addTask(() => plugin.hunkActions.stageHunk(pos)); togglePreviewHunk(editor, pos); }; resetBtn.onclick = () => { const plugin = pluginRef.plugin; if (!plugin) return; plugin.hunkActions.resetHunk(pos); togglePreviewHunk(editor, pos); }; const scope = pluginRef.plugin?.app.workspace.getActiveViewOfType( MarkdownView )?.scope; const eventHandler = scope?.register(null, "Escape", (_, __) => { // close on escape togglePreviewHunk(editor, pos); }); return { dom: contentEl, destroy: () => { if (eventHandler) { scope?.unregister(eventHandler); } }, update: (update) => { pos = update.changes.mapPos(pos); }, }; } ================================================ FILE: src/externalLibTypes.d.ts ================================================ declare module "css-color-converter" { /* The following list of type definitions is incomplete! */ class Color { toRgbaArray(): [number, number, number, number]; toRgbString(): string; toRgbaString(): string; toHslString(): string; toHslaString(): string; toHexString(): string; } function fromString(str: string): Color | null; } ================================================ FILE: src/gitManager/gitManager.ts ================================================ import { hostname as osHostname } from "os"; import { type App, moment, Platform } from "obsidian"; import type ObsidianGit from "../main"; import type { BranchInfo, DiffFile, FileStatusResult, LogEntry, Status, TreeItem, UnstagedFile, } from "../types"; export abstract class GitManager { readonly plugin: ObsidianGit; readonly app: App; constructor(plugin: ObsidianGit) { this.plugin = plugin; this.app = plugin.app; } abstract status(opts?: { path?: string }): Promise; abstract commitAll(_: { message: string; status?: Status; unstagedFiles?: UnstagedFile[]; amend?: boolean; }): Promise; abstract commit(_: { message: string; amend?: boolean; }): Promise; abstract stageAll(_: { dir?: string; status?: Status }): Promise; abstract unstageAll(_: { dir?: string; status?: Status }): Promise; abstract stage(filepath: string, relativeToVault: boolean): Promise; abstract unstage(filepath: string, relativeToVault: boolean): Promise; abstract discard(filepath: string): Promise; abstract discardAll(_: { dir?: string; status?: Status }): Promise; /** * Use this method instead of {@link GitManager.status} to delete untracked files, becase on native git * directories which only contain untracked files will only be listed by their directory name e.g. `thedir/` and not every file individually. * This allows for more efficient deletion of untracked files. * * @param path - The path to the directory to get untracked paths in. If not specified, the whole repository is used. */ abstract getUntrackedPaths(opts?: { path?: string; status?: Status; }): Promise; abstract pull(): Promise; /** * Pushes to the remote repository. * * @returns `numper`: number of pushed files * @returns `undefined` for other states, but a notification is done elsewhere * @returns `null` if push was successful, but changed files could not be determined */ abstract push(): Promise; abstract getUnpushedCommits(): Promise; abstract canPush(): Promise; abstract checkRequirements(): Promise< "valid" | "missing-repo" | "missing-git" >; abstract branchInfo(): Promise; abstract checkout(branch: string, remote?: string): Promise; abstract createBranch(branch: string): Promise; abstract deleteBranch(branch: string, force: boolean): Promise; abstract branchIsMerged(branch: string): Promise; abstract init(): Promise; abstract clone(url: string, dir: string, depth?: number): Promise; abstract setConfig( path: string, value: string | number | boolean | undefined ): Promise; abstract getConfig( path: string, scope?: string ): Promise; abstract fetch(remote?: string): Promise; abstract setRemote(name: string, url: string): Promise; abstract getRemotes(): Promise; abstract getRemoteUrl(remote: string): Promise; abstract log( file: string | undefined, relativeToVault?: boolean, limit?: number, ref?: string ): Promise; abstract getRemoteBranches(remote: string): Promise; abstract removeRemote(remoteName: string): Promise; abstract updateUpstreamBranch(remoteBranch: string): Promise; abstract updateGitPath(gitPath: string): Promise; abstract updateBasePath(basePath: string): Promise; abstract getDiffString( filePath: string, stagedChanges: boolean, hash?: string ): Promise; abstract getLastCommitTime(): Promise; // Constructs a path relative to the vault from a path relative to the git repository getRelativeVaultPath(path: string): string { if (this.plugin.settings.basePath) { return this.plugin.settings.basePath + "/" + path; } else { return path; } } // Constructs a path relative to the git repository from a path relative to the vault // // @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. getRelativeRepoPath( filePath: string, doConversion: boolean = true ): string { if (doConversion) { if (this.plugin.settings.basePath.length > 0) { //Expect the case that the git repository is located inside the vault on mobile platform currently. return filePath.substring( this.plugin.settings.basePath.length + 1 ); } } return filePath; } unload(): void {} private _getTreeStructure( children: (T & { path: string })[], beginLength = 0 ): TreeItem[] { const list: TreeItem[] = []; children = [...children]; while (children.length > 0) { const first = children.first()!; const restPath = first.path.substring(beginLength); if (restPath.contains("/")) { const title = restPath.substring(0, restPath.indexOf("/")); const childrenWithSameTitle = children.filter((item) => { return item.path .substring(beginLength) .startsWith(title + "/"); }); childrenWithSameTitle.forEach((item) => children.remove(item)); const path = first.path.substring( 0, restPath.indexOf("/") + beginLength ); list.push({ title: title, path: path, vaultPath: this.getRelativeVaultPath(path), children: this._getTreeStructure( childrenWithSameTitle, (beginLength > 0 ? beginLength + title.length : title.length) + 1 ), }); } else { list.push({ title: restPath, data: first, path: first.path, vaultPath: this.getRelativeVaultPath(first.path), }); children.remove(first); } } return list; } /* * Sorts the children and simplifies the title * If a node only contains another subdirectory, that subdirectory is moved up one level and integrated into the parent node */ private simplify(tree: TreeItem[]): TreeItem[] { for (const node of tree) { while (true) { const singleChild = node.children?.length == 1; const singleChildIsDir = node.children?.first()?.data == undefined; if ( !( node.children != undefined && singleChild && singleChildIsDir ) ) break; const child = node.children.first()!; node.title += "/" + child.title; node.data = child.data; node.path = child.path; node.vaultPath = child.vaultPath; node.children = child.children; } if (node.children != undefined) { this.simplify(node.children); } node.children?.sort((a, b) => { const dirCompare = (b.data == undefined ? 1 : 0) - (a.data == undefined ? 1 : 0); if (dirCompare != 0) { return dirCompare; } else { return a.title.localeCompare(b.title); } }); } return tree.sort((a, b) => { const dirCompare = (b.data == undefined ? 1 : 0) - (a.data == undefined ? 1 : 0); if (dirCompare != 0) { return dirCompare; } else { return a.title.localeCompare(b.title); } }); } getTreeStructure( children: (T & { path: string })[] ): TreeItem[] { const tree = this._getTreeStructure(children); const res = this.simplify(tree); return res; } async formatCommitMessage(template: string): Promise { let status: Status | undefined; if (template.includes("{{numFiles}}")) { status = await this.status(); const numFiles = status.staged.length; template = template.replace("{{numFiles}}", String(numFiles)); } if (template.includes("{{hostname}}")) { let hostname = this.plugin.localStorage.getHostname() || ""; if (!hostname && Platform.isDesktopApp) { hostname = osHostname(); } template = template.replace("{{hostname}}", hostname); } if (template.includes("{{files}}")) { status = status ?? (await this.status()); const changeset: { [key: string]: string[] } = {}; let files = ""; // If there are more than 100 files, we don't list them all if (status.staged.length < 100) { status.staged.forEach((value: FileStatusResult) => { if (value.index in changeset) { changeset[value.index].push(value.path); } else { changeset[value.index] = [value.path]; } }); const chunks = []; for (const [action, files] of Object.entries(changeset)) { chunks.push(action + " " + files.join(" ")); } files = chunks.join(", "); } else { files = "Too many files to list"; } template = template.replace("{{files}}", files); } template = template.replace( "{{date}}", moment().format(this.plugin.settings.commitDateFormat) ); if (this.plugin.settings.listChangedFilesInMessageBody) { const status2 = status ?? (await this.status()); let files = ""; // If there are more than 100 files, we don't list them all if (status2.staged.length < 100) { files = status2.staged.map((e) => e.path).join("\n"); } else { files = "Too many files to list"; } template = template + "\n\n" + "Affected files:" + "\n" + files; } return template; } } ================================================ FILE: src/gitManager/isomorphicGit.ts ================================================ import { createPatch } from "diff"; import type { AuthCallback, AuthFailureCallback, GitHttpRequest, GitHttpResponse, GitProgressEvent, HttpClient, Walker, WalkerMap, } from "isomorphic-git"; import git, { Errors, readBlob } from "isomorphic-git"; import { Notice, requestUrl } from "obsidian"; import type ObsidianGit from "../main"; import type { BranchInfo, FileStatusResult, LogEntry, Status, UnstagedFile, WalkDifference, } from "../types"; import { CurrentGitAction, type DiffFile } from "../types"; import { GeneralModal } from "../ui/modals/generalModal"; import { splitRemoteBranch, worthWalking } from "../utils"; import { GitManager } from "./gitManager"; import { MyAdapter } from "./myAdapter"; import diff3Merge from "diff3"; export class IsomorphicGit extends GitManager { private readonly FILE = 0; private readonly HEAD = 1; private readonly WORKDIR = 2; private readonly STAGE = 3; // Mapping from statusMatrix to git status codes based off git status --short // See: https://isomorphic-git.org/docs/en/statusMatrix private readonly status_mapping = { "000": " ", "003": "AD", "020": "??", "022": "A ", "023": "AM", "100": "D ", "101": " D", "103": "MD", "110": "DA", // Technically, two files: first one is deleted "D " and second one is untracked "??" "111": " ", "113": "MM", "120": "DA", // Same as "110" "121": " M", "122": "M ", "123": "MM", }; private readonly noticeLength = 999_999; private readonly fs = new MyAdapter(this.app.vault, this.plugin); constructor(plugin: ObsidianGit) { super(plugin); } getRepo(): { fs: MyAdapter; dir: string; gitdir?: string; onAuth: AuthCallback; onAuthFailure: AuthFailureCallback; http: HttpClient; } { return { fs: this.fs, dir: this.plugin.settings.basePath, gitdir: this.plugin.settings.gitDir || undefined, onAuth: () => { return { username: this.plugin.localStorage.getUsername() ?? undefined, password: this.plugin.localStorage.getPassword() ?? undefined, }; }, onAuthFailure: async () => { new Notice( "Authentication failed. Please try with different credentials" ); const username = await new GeneralModal(this.plugin, { placeholder: "Specify your username", }).openAndGetResult(); if (username) { const password = await new GeneralModal(this.plugin, { placeholder: "Specify your password/personal access token", obscure: true, }).openAndGetResult(); if (password) { this.plugin.localStorage.setUsername(username); this.plugin.localStorage.setPassword(password); return { username, password, }; } } return { cancel: true }; }, http: { async request({ url, method, headers, body, }: GitHttpRequest): Promise { // We can't stream yet, so collect body and set it to the ArrayBuffer // because that's what requestUrl expects let collectedBody: ArrayBuffer | undefined; if (body) { collectedBody = await asyncIteratorToArrayBuffer(body); } const res = await requestUrl({ url, method, headers, body: collectedBody, throw: false, }); return { url, method, headers: res.headers, body: arrayBufferToAsyncIterator(res.arrayBuffer), statusCode: res.status, statusMessage: res.status.toString(), }; }, }, }; } async wrapFS(call: Promise): Promise { try { const res = await call; await this.fs.saveAndClear(); return res; } catch (error) { await this.fs.saveAndClear(); throw error; } } async status(opts?: { path?: string }): Promise { let notice: Notice | undefined; const timeout = window.setTimeout(() => { notice = new Notice( "This takes longer: Getting status", this.noticeLength ); }, 20000); try { this.plugin.setPluginState({ gitAction: CurrentGitAction.status }); const statusOpts = { ...this.getRepo() } as Parameters< typeof git.statusMatrix >[0]; if (opts?.path != undefined) { statusOpts.filepaths = [`${opts.path}/`]; } const status = ( await this.wrapFS(git.statusMatrix(statusOpts)) ).map((row) => this.getFileStatusResult(row)); const changed: FileStatusResult[] = []; const staged: FileStatusResult[] = []; const all: FileStatusResult[] = []; for (const file of status) { if (file.workingDir !== " ") { changed.push(file); } if (file.index !== " " && file.index !== "U") { staged.push(file); } if (file.index != " " || file.workingDir != " ") { all.push(file); } } const conflicted: string[] = []; window.clearTimeout(timeout); notice?.hide(); return { all, changed, staged, conflicted }; } catch (error) { window.clearTimeout(timeout); notice?.hide(); this.plugin.displayError(error); throw error; } } async commitAll({ message, status, unstagedFiles, }: { message: string; status?: Status; unstagedFiles?: UnstagedFile[]; }): Promise { try { await this.checkAuthorInfo(); await this.stageAll({ status, unstagedFiles }); return this.commit({ message }); } catch (error) { this.plugin.displayError(error); throw error; } } async commit({ message, }: { message: string; amend?: boolean; }): Promise { try { await this.checkAuthorInfo(); this.plugin.setPluginState({ gitAction: CurrentGitAction.commit }); const formatMessage = await this.formatCommitMessage(message); const hadConflict = this.plugin.localStorage.getConflict(); let parent: string[] | undefined = undefined; if (hadConflict) { const branchInfo = await this.branchInfo(); parent = [branchInfo.current!, branchInfo.tracking!]; } await this.wrapFS( git.commit({ ...this.getRepo(), message: formatMessage, parent: parent, }) ); this.plugin.localStorage.setConflict(false); return; } catch (error) { this.plugin.displayError(error); throw error; } } async stage(filepath: string, relativeToVault: boolean): Promise { const gitPath = this.getRelativeRepoPath(filepath, relativeToVault); let vaultPath: string; if (relativeToVault) { vaultPath = filepath; } else { vaultPath = this.getRelativeVaultPath(filepath); } try { this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); if (await this.app.vault.adapter.exists(vaultPath)) { await this.wrapFS( git.add({ ...this.getRepo(), filepath: gitPath }) ); } else { await this.wrapFS( git.remove({ ...this.getRepo(), filepath: gitPath }) ); } } catch (error) { this.plugin.displayError(error); throw error; } } async stageAll({ dir, status, unstagedFiles, }: { dir?: string; status?: Status; unstagedFiles?: UnstagedFile[]; }): Promise { try { if (status) { await Promise.all( status.changed.map((file) => file.workingDir !== "D" ? this.wrapFS( git.add({ ...this.getRepo(), filepath: file.path, }) ) : git.remove({ ...this.getRepo(), filepath: file.path, }) ) ); } else { const filesToStage = unstagedFiles ?? (await this.getUnstagedFiles(dir ?? ".")); await Promise.all( filesToStage.map(({ path, type }) => type == "D" ? git.remove({ ...this.getRepo(), filepath: path }) : this.wrapFS( git.add({ ...this.getRepo(), filepath: path }) ) ) ); } } catch (error) { this.plugin.displayError(error); throw error; } } async unstage(filepath: string, relativeToVault: boolean): Promise { try { this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); filepath = this.getRelativeRepoPath(filepath, relativeToVault); await this.wrapFS( git.resetIndex({ ...this.getRepo(), filepath: filepath }) ); } catch (error) { this.plugin.displayError(error); throw error; } } async unstageAll({ dir, status, }: { dir?: string; status?: Status; }): Promise { try { let staged: string[]; if (status) { staged = status.staged.map((file) => file.path); } else { const res = await this.getStagedFiles(dir ?? "."); staged = res.map(({ path }) => path); } await this.wrapFS( Promise.all( staged.map((file) => git.resetIndex({ ...this.getRepo(), filepath: file }) ) ) ); } catch (error) { this.plugin.displayError(error); throw error; } } async discard(filepath: string): Promise { try { this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); await this.wrapFS( git.checkout({ ...this.getRepo(), filepaths: [filepath], force: true, }) ); } catch (error) { this.plugin.displayError(error); throw error; } } async discardAll({ dir, status, }: { dir?: string; status?: Status; }): Promise { let files: string[] = []; if (status) { if (dir != undefined) { files = status.changed .filter( (file) => file.workingDir != "U" && file.path.startsWith(dir) ) .map((file) => file.path); } else { files = status.changed .filter((file) => file.workingDir != "U") .map((file) => file.path); } } else { files = (await this.getUnstagedFiles(dir)) .filter((file) => file.type != "A") .map(({ path }) => path); } try { await this.wrapFS( git.checkout({ ...this.getRepo(), filepaths: files, force: true, }) ); } catch (error) { this.plugin.displayError(error); throw error; } } async getUntrackedPaths(opts: { path?: string; status?: Status; }): Promise { const untrackedPaths: string[] = []; if (opts.status) { for (const file of opts.status.changed) { if ( file.index == "U" && file.workingDir === "U" && file.path.startsWith( opts.path != undefined ? `${opts.path}/` : "" ) ) { untrackedPaths.push(file.path); } } } else { const status = await this.status({ path: opts?.path }); for (const file of status.changed) { if (file.index === "U" && file.workingDir === "U") { untrackedPaths.push(file.path); } } } return untrackedPaths; } getProgressText(action: string, event: GitProgressEvent): string { let out = `${action} progress:`; if (event.phase) { out = `${out} ${event.phase}:`; } if (event.loaded) { out = `${out} ${event.loaded}`; if (event.total) { out = `${out} of ${event.total}`; } } return out; } resolveRef(ref: string): Promise { return this.wrapFS(git.resolveRef({ ...this.getRepo(), ref })); } async pull(): Promise { const progressNotice = this.showNotice("Initializing pull"); try { this.plugin.setPluginState({ gitAction: CurrentGitAction.pull }); const localCommit = await this.resolveRef("HEAD"); await this.fetch(); const branchInfo = await this.branchInfo(); await this.checkAuthorInfo(); const mergeRes = await this.wrapFS( git.merge({ ...this.getRepo(), ours: branchInfo.current, theirs: branchInfo.tracking!, abortOnConflict: false, mergeDriver: this.plugin.settings.mergeStrategy !== "none" ? ({ contents }) => { const baseContent = contents[0]; const ourContent = contents[1]; const theirContent = contents[2]; const LINEBREAKS = /^.*(\r?\n|$)/gm; const ours = ourContent.match(LINEBREAKS) ?? []; const base = baseContent.match(LINEBREAKS) ?? []; const theirs = theirContent.match(LINEBREAKS) ?? []; const result = diff3Merge(ours, base, theirs); let mergedText = ""; for (const item of result) { if (item.ok) { mergedText += item.ok.join(""); } if (item.conflict) { mergedText += this.plugin.settings .mergeStrategy === "ours" ? item.conflict.a.join("") : item.conflict.b.join(""); } } return { cleanMerge: true, mergedText }; } : undefined, }) ); if (!mergeRes.alreadyMerged) { await this.wrapFS( git.checkout({ ...this.getRepo(), ref: branchInfo.current, onProgress: (progress) => { if (progressNotice !== undefined) { progressNotice.noticeEl.innerText = this.getProgressText("Checkout", progress); } }, remote: branchInfo.remote, }) ); } progressNotice?.hide(); const upstreamCommit = await this.resolveRef("HEAD"); const changedFiles = await this.getFileChangesCount( localCommit, upstreamCommit ); this.showNotice("Finished pull", false); return changedFiles.map((file) => ({ path: file.path, workingDir: "P", index: "P", vaultPath: this.getRelativeVaultPath(file.path), })); } catch (error) { progressNotice?.hide(); if (error instanceof Errors.MergeConflictError) { await this.plugin.handleConflict( error.data.filepaths.map((file) => this.getRelativeVaultPath(file) ) ); } this.plugin.displayError(error); throw error; } } async push(): Promise { if (!(await this.canPush())) { return 0; } const progressNotice = this.showNotice("Initializing push"); try { this.plugin.setPluginState({ gitAction: CurrentGitAction.status }); const status = await this.branchInfo(); const trackingBranch = status.tracking; const currentBranch = status.current; const numChangedFiles = ( await this.getFileChangesCount(currentBranch!, trackingBranch!) ).length; this.plugin.setPluginState({ gitAction: CurrentGitAction.push }); const remote = await this.getCurrentRemote(); await this.wrapFS( git.push({ ...this.getRepo(), remote, onProgress: (progress) => { if (progressNotice !== undefined) { progressNotice.noticeEl.innerText = this.getProgressText("Pushing", progress); } }, }) ); progressNotice?.hide(); return numChangedFiles; } catch (error) { progressNotice?.hide(); this.plugin.displayError(error); throw error; } } async getUnpushedCommits(): Promise { const status = await this.branchInfo(); const trackingBranch = status.tracking; const currentBranch = status.current; if (trackingBranch == null || currentBranch == null) { return 0; } const localCommit = await this.resolveRef(currentBranch); const upstreamCommit = await this.resolveRef(trackingBranch); const changedFiles = await this.getFileChangesCount( localCommit, upstreamCommit ); return changedFiles.length; } async canPush(): Promise { const status = await this.branchInfo(); const trackingBranch = status.tracking; const currentBranch = status.current; const current = await this.resolveRef(currentBranch!); const tracking = await this.resolveRef(trackingBranch!); return current != tracking; } async checkRequirements(): Promise<"valid" | "missing-repo"> { const headExists = await this.plugin.app.vault.adapter.exists( `${this.getRepo().dir}/.git/HEAD` ); return headExists ? "valid" : "missing-repo"; } async branchInfo(): Promise { try { const current = (await git.currentBranch(this.getRepo())) || ""; const branches = await git.listBranches(this.getRepo()); const remote = (await this.getConfig(`branch.${current}.remote`)) ?? "origin"; const trackingBranch = ( await this.getConfig(`branch.${current}.merge`) )?.split("refs/heads")[1]; const tracking = trackingBranch ? remote + trackingBranch : undefined; return { current: current, tracking: tracking, branches: branches, remote: remote, }; } catch (error) { this.plugin.displayError(error); throw error; } } async getCurrentRemote(): Promise { const current = (await git.currentBranch(this.getRepo())) || ""; const remote = (await this.getConfig(`branch.${current}.remote`)) ?? "origin"; return remote; } async checkout(branch: string, remote?: string): Promise { try { return this.wrapFS( git.checkout({ ...this.getRepo(), ref: branch, force: !!remote, remote, }) ); } catch (error) { this.plugin.displayError(error); throw error; } } async createBranch(branch: string): Promise { try { await this.wrapFS( git.branch({ ...this.getRepo(), ref: branch, checkout: true }) ); } catch (error) { this.plugin.displayError(error); throw error; } } async deleteBranch(branch: string): Promise { try { await this.wrapFS( git.deleteBranch({ ...this.getRepo(), ref: branch }) ); } catch (error) { this.plugin.displayError(error); throw error; } } branchIsMerged(_: string): Promise { return Promise.resolve(true); } async init(): Promise { try { await this.wrapFS(git.init(this.getRepo())); } catch (error) { this.plugin.displayError(error); throw error; } } async clone(url: string, dir: string, depth?: number): Promise { const progressNotice = this.showNotice("Initializing clone"); try { await this.wrapFS( git.clone({ ...this.getRepo(), dir: dir, url: url, depth: depth, onProgress: (progress) => { if (progressNotice !== undefined) { progressNotice.noticeEl.innerText = this.getProgressText("Cloning", progress); } }, }) ); progressNotice?.hide(); } catch (error) { progressNotice?.hide(); this.plugin.displayError(error); throw error; } } async setConfig( path: string, value: string | number | boolean | undefined ): Promise { try { return this.wrapFS( git.setConfig({ ...this.getRepo(), path: path, value: value, }) ); } catch (error) { this.plugin.displayError(error); throw error; } } async getConfig(path: string): Promise { try { return this.wrapFS( git.getConfig({ ...this.getRepo(), path: path, }) as Promise ); } catch (error) { this.plugin.displayError(error); throw error; } } async fetch(remote?: string): Promise { const progressNotice = this.showNotice("Initializing fetch"); try { const args = { ...this.getRepo(), onProgress: (progress: GitProgressEvent) => { if (progressNotice !== undefined) { progressNotice.noticeEl.innerText = this.getProgressText("Fetching", progress); } }, remote: remote ?? (await this.getCurrentRemote()), }; await this.wrapFS(git.fetch(args)); progressNotice?.hide(); } catch (error) { this.plugin.displayError(error); progressNotice?.hide(); throw error; } } async setRemote(name: string, url: string): Promise { try { await this.wrapFS( git.addRemote({ ...this.getRepo(), remote: name, url: url, force: true, }) ); } catch (error) { this.plugin.displayError(error); throw error; } } async getRemoteBranches(remote: string): Promise { let remoteBranches = []; remoteBranches.push( ...(await this.wrapFS( git.listBranches({ ...this.getRepo(), remote: remote }) )) ); remoteBranches.remove("HEAD"); //Align with simple-git remoteBranches = remoteBranches.map((e) => `${remote}/${e}`); return remoteBranches; } async getRemotes(): Promise { return (await this.wrapFS(git.listRemotes({ ...this.getRepo() }))).map( (remoteUrl) => remoteUrl.remote ); } async removeRemote(remoteName: string): Promise { await this.wrapFS( git.deleteRemote({ ...this.getRepo(), remote: remoteName }) ); } async getRemoteUrl(remote: string): Promise { return ( await this.wrapFS(git.listRemotes({ ...this.getRepo() })) ).filter((item) => item.remote == remote)[0]?.url; } async log( _?: string, __ = true, limit?: number, ref?: string ): Promise { const logs = await this.wrapFS( git.log({ ...this.getRepo(), depth: limit, ref: ref }) ); return Promise.all( logs.map(async (log) => { const completeMessage = log.commit.message.split("\n\n"); return { message: completeMessage[0], author: { name: log.commit.author.name, email: log.commit.author.email, }, body: completeMessage.slice(1).join("\n\n"), date: new Date( log.commit.committer.timestamp ).toDateString(), diff: { changed: 0, files: ( await this.getFileChangesCount( log.commit.parent.first()!, log.oid ) ).map((item) => { return { path: item.path, status: item.type, vaultPath: this.getRelativeVaultPath(item.path), hash: log.oid, }; }), }, hash: log.oid, refs: [], }; }) ); } updateBasePath(basePath: string): Promise { this.getRepo().dir = basePath; return Promise.resolve(); } async updateUpstreamBranch(remoteBranch: string): Promise { const [remote, branch] = splitRemoteBranch(remoteBranch); const branchInfo = await this.branchInfo(); await this.wrapFS( git.push({ ...this.getRepo(), remote: remote, remoteRef: branch, }) ); await this.setConfig( `branch.${branchInfo.current}.merge`, `refs/heads/${branch}` ); } updateGitPath(_: string): Promise { // isomorphic-git library has its own git client return Promise.resolve(); } async getFileChangesCount( commitHash1: string, commitHash2: string ): Promise { return this.walkDifference({ walkers: [ git.TREE({ ref: commitHash1 }), git.TREE({ ref: commitHash2 }), ], }); } async walkDifference({ walkers, dir: base, }: { walkers: Walker[]; dir?: string; }): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const res = await this.wrapFS( git.walk({ ...this.getRepo(), trees: walkers, map: async function (filepath, [A, B]) { if (!worthWalking(filepath, base)) { return null; } if ( (await A?.type()) === "tree" || (await B?.type()) === "tree" ) { return; } // generate ids const Aoid = await A?.oid(); const Boid = await B?.oid(); // determine modification type let type = "equal"; if (Aoid !== Boid) { type = "M"; } if (Aoid === undefined) { type = "A"; } if (Boid === undefined) { type = "D"; } if (Aoid === undefined && Boid === undefined) { console.log("Something weird happened:"); console.log(A); console.log(B); } if (type === "equal") { return; } return { path: filepath, type: type, }; }, }) ); return res as WalkDifference[]; } async getStagedFiles( dir = "." ): Promise<{ vaultPath: string; path: string }[]> { const res = await this.walkDifference({ walkers: [git.TREE({ ref: "HEAD" }), git.STAGE()], dir, }); return res.map((file) => { return { vaultPath: this.getRelativeVaultPath(file.path), path: file.path, }; }); } async getUnstagedFiles(base = "."): Promise { let notice: Notice | undefined; const timeout = window.setTimeout(() => { notice = new Notice( "This takes longer: Getting status", this.noticeLength ); }, 20000); try { const repo = this.getRepo(); const res = await this.wrapFS>( //Modified from `git.statusMatrix` // eslint-disable-next-line @typescript-eslint/no-unsafe-argument git.walk({ ...repo, trees: [git.WORKDIR(), git.STAGE()], map: async function ( filepath, [workdir, stage] ): Promise { // Ignore ignored files, but only if they are not already tracked. if (!stage && workdir) { const isIgnored = await git.isIgnored({ ...repo, filepath, }); if (isIgnored) { return null; } } // match against base path if (!worthWalking(filepath, base)) { return null; } // Late filter against file names // if (filter) { // if (!filter(filepath)) return; // } const [workdirType, stageType] = await Promise.all([ workdir && workdir.type(), stage && stage.type(), ]); const isBlob = [workdirType, stageType].includes( "blob" ); // For now, bail on directories unless the file is also a blob in another tree if ( (workdirType === "tree" || workdirType === "special") && !isBlob ) return; if (stageType === "commit") return null; if ( (stageType === "tree" || stageType === "special") && !isBlob ) return; // Figure out the oids for files, using the staged oid for the working dir oid if the stats match. const stageOid = stageType === "blob" ? await stage!.oid() : undefined; let workdirOid; if (workdirType === "blob" && stageType !== "blob") { // We don't actually NEED the sha. Any sha will do workdirOid = "42"; } else if (workdirType === "blob") { workdirOid = await workdir!.oid(); } if (!workdirOid) { return { path: filepath, type: "D", }; } if (!stageOid) { return { path: filepath, type: "A", }; } if (workdirOid !== stageOid) { return { path: filepath, type: "M", }; } return null; // const entry = [undefined, headOid, workdirOid, stageOid]; // const result = entry.map(value => entry.indexOf(value)); // result.shift(); // remove leading undefined entry // return [filepath, ...result]; }, }) ); window.clearTimeout(timeout); notice?.hide(); return res; } catch (error) { window.clearTimeout(timeout); notice?.hide(); this.plugin.displayError(error); throw error; } } async getDiffString( filePath: string, stagedChanges = false, hash?: string ): Promise { const vaultPath = this.getRelativeVaultPath(filePath); const map: WalkerMap = async (file, [A]) => { if (filePath == file) { const oid = await A!.oid(); const contents = await git.readBlob({ ...this.getRepo(), oid: oid, }); return contents.blob; } }; if (hash) { const commitContent = await readBlob({ ...this.getRepo(), filepath: filePath, oid: hash, }) .then((headBlob) => new TextDecoder().decode(headBlob.blob)) .catch((err) => { if (err instanceof git.Errors.NotFoundError) return undefined; throw err; }); const commit = await git.readCommit({ ...this.getRepo(), oid: hash, }); const previousContent = await readBlob({ ...this.getRepo(), filepath: filePath, oid: commit.commit.parent.first()!, }) .then((headBlob) => new TextDecoder().decode(headBlob.blob)) .catch((err) => { if (err instanceof git.Errors.NotFoundError) return undefined; throw err; }); const diff = createPatch( vaultPath, previousContent ?? "", commitContent ?? "" ); return diff; } const stagedBlob = ( (await git.walk({ ...this.getRepo(), trees: [git.STAGE()], map, })) as Uint8Array[] ).first(); const stagedContent = new TextDecoder().decode(stagedBlob); if (stagedChanges) { const headContent = await this.resolveRef("HEAD") .then((oid) => readBlob({ ...this.getRepo(), filepath: filePath, oid: oid, }) ) .then((headBlob) => new TextDecoder().decode(headBlob.blob)) .catch((err) => { if (err instanceof git.Errors.NotFoundError) return undefined; throw err; }); const diff = createPatch( vaultPath, headContent ?? "", stagedContent ); return diff; } else { let workdirContent: string; if (await this.app.vault.adapter.exists(vaultPath)) { workdirContent = await this.app.vault.adapter.read(vaultPath); } else { workdirContent = ""; } const diff = createPatch(vaultPath, stagedContent, workdirContent); return diff; } } async getLastCommitTime(): Promise { const repo = this.getRepo(); const oid = await this.resolveRef("HEAD"); const commit = await git.readCommit({ ...repo, oid: oid }); const date = commit.commit.committer.timestamp; return new Date(date * 1000); } private getFileStatusResult( row: [string, 0 | 1, 0 | 1 | 2, 0 | 1 | 2 | 3] ): FileStatusResult { // eslint-disable-next-line @typescript-eslint/no-explicit-any const status = (this.status_mapping as any)[ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access `${row[this.HEAD]}${row[this.WORKDIR]}${row[this.STAGE]}` ] as string; // status will always be two characters return { index: status[0] == "?" ? "U" : status[0], workingDir: status[1] == "?" ? "U" : status[1], path: row[this.FILE], vaultPath: this.getRelativeVaultPath(row[this.FILE]), }; } private async checkAuthorInfo(): Promise { const name = await this.getConfig("user.name"); const email = await this.getConfig("user.email"); if (!name || !email) { throw Error( "Git author name and email are not set. Please set both fields in the settings." ); } } private showNotice(message: string, infinity = true): Notice | undefined { if (!this.plugin.settings.disablePopups) { return new Notice( message, infinity ? this.noticeLength : undefined ); } } } // All because we can't use (for await)... // Convert a value to an Async Iterator // This will be easier with async generator functions. /*eslint-disable */ function fromValue(value: any) { let queue = [value]; return { next() { return Promise.resolve({ done: queue.length === 0, value: queue.pop(), }); }, return() { queue = []; return {}; }, [Symbol.asyncIterator]() { return this; }, }; } async function* arrayBufferToAsyncIterator( buffer: ArrayBuffer ): AsyncIterableIterator { yield new Uint8Array(buffer); } async function asyncIteratorToArrayBuffer( iterator: AsyncIterableIterator ): Promise { const stream = new ReadableStream({ async start(controller) { for await (const chunk of iterator) { controller.enqueue(chunk); } controller.close(); }, }); const response = new Response(stream); return await response.arrayBuffer(); } ================================================ FILE: src/gitManager/myAdapter.ts ================================================ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/only-throw-error */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { DataAdapter, Vault } from "obsidian"; import { normalizePath, TFile } from "obsidian"; import type ObsidianGit from "../main"; export class MyAdapter { promises: any = {}; adapter: DataAdapter; vault: Vault; index: ArrayBuffer | undefined; indexctime: number | undefined; indexmtime: number | undefined; lastBasePath: string | undefined; constructor( vault: Vault, private readonly plugin: ObsidianGit ) { this.adapter = vault.adapter; this.vault = vault; this.lastBasePath = this.plugin.settings.basePath; this.promises.readFile = this.readFile.bind(this); this.promises.writeFile = this.writeFile.bind(this); this.promises.readdir = this.readdir.bind(this); this.promises.mkdir = this.mkdir.bind(this); this.promises.rmdir = this.rmdir.bind(this); this.promises.stat = this.stat.bind(this); this.promises.unlink = this.unlink.bind(this); this.promises.lstat = this.lstat.bind(this); this.promises.readlink = this.readlink.bind(this); this.promises.symlink = this.symlink.bind(this); } async readFile(path: string, opts: any) { this.maybeLog("Read: " + path + JSON.stringify(opts)); if (opts == "utf8" || opts.encoding == "utf8") { const file = this.vault.getAbstractFileByPath(path); if (file instanceof TFile) { this.maybeLog("Reuse"); return this.vault.read(file); } else { return this.adapter.read(path); } } else { if (path.endsWith(this.gitDir + "/index")) { if (this.plugin.settings.basePath != this.lastBasePath) { this.clearIndex(); this.lastBasePath = this.plugin.settings.basePath; return this.adapter.readBinary(path); } return this.index ?? this.adapter.readBinary(path); } const file = this.vault.getAbstractFileByPath(path); if (file instanceof TFile) { this.maybeLog("Reuse"); return this.vault.readBinary(file); } else { return this.adapter.readBinary(path); } } } async writeFile(path: string, data: string | ArrayBuffer) { this.maybeLog("Write: " + path); if (typeof data === "string") { const file = this.vault.getAbstractFileByPath(path); if (file instanceof TFile) { return this.vault.modify(file, data); } else { return this.adapter.write(path, data); } } else { if (path.endsWith(this.gitDir + "/index")) { this.index = data; this.indexmtime = Date.now(); // this.adapter.writeBinary(path, data); } else { const file = this.vault.getAbstractFileByPath(path); if (file instanceof TFile) { return this.vault.modifyBinary(file, data); } else { return this.adapter.writeBinary(path, data); } } } } async readdir(path: string) { if (path === ".") path = "/"; const res = await this.adapter.list(path); const all = [...res.files, ...res.folders]; let formattedAll; if (path !== "/") { formattedAll = all.map((e) => normalizePath(e.substring(path.length)) ); } else { formattedAll = all; } return formattedAll; } async mkdir(path: string) { return this.adapter.mkdir(path); } async rmdir(path: string, opts: any) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return this.adapter.rmdir(path, opts?.options?.recursive ?? false); } async stat(path: string) { if (path.endsWith(this.gitDir + "/index")) { if ( this.index !== undefined && this.indexctime != undefined && this.indexmtime != undefined ) { return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false, size: this.index.byteLength, type: "file", ctimeMs: this.indexctime, mtimeMs: this.indexmtime, }; } else { const stat = await this.adapter.stat(path); if (stat == undefined) { throw { code: "ENOENT" }; } this.indexctime = stat.ctime; this.indexmtime = stat.mtime; return { ctimeMs: stat.ctime, mtimeMs: stat.mtime, size: stat.size, type: "file", isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false, }; } } if (path === ".") path = "/"; const file = this.vault.getAbstractFileByPath(path); this.maybeLog("Stat: " + path); if (file instanceof TFile) { this.maybeLog("Reuse stat"); return { ctimeMs: file.stat.ctime, mtimeMs: file.stat.mtime, size: file.stat.size, type: "file", isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false, }; } else { const stat = await this.adapter.stat(path); if (stat) { return { ctimeMs: stat.ctime, mtimeMs: stat.mtime, size: stat.size, type: stat.type === "folder" ? "directory" : stat.type, isFile: () => stat.type === "file", isDirectory: () => stat.type === "folder", isSymbolicLink: () => false, }; } else { // used to determine whether a file exists or not throw { code: "ENOENT" }; } } } async unlink(path: string) { return this.adapter.remove(path); } async lstat(path: string) { return this.stat(path); } async readlink(path: string) { throw new Error(`readlink of (${path}) is not implemented.`); } async symlink(path: string) { throw new Error(`symlink of (${path}) is not implemented.`); } async saveAndClear(): Promise { if (this.index !== undefined) { await this.adapter.writeBinary( this.plugin.gitManager.getRelativeVaultPath( this.gitDir + "/index" ), this.index, { ctime: this.indexctime, mtime: this.indexmtime, } ); } this.clearIndex(); } clearIndex() { this.index = undefined; this.indexctime = undefined; this.indexmtime = undefined; } private get gitDir(): string { return this.plugin.settings.gitDir || ".git"; } private maybeLog(_: string) { // console.log(text); } } ================================================ FILE: src/gitManager/simpleGit.ts ================================================ import debug from "debug"; import * as fsPromises from "fs/promises"; import type { FileSystemAdapter } from "obsidian"; import { normalizePath, Notice, Platform } from "obsidian"; import * as path from "path"; import { resolve, sep } from "path"; import type * as simple from "simple-git"; import simpleGit, { GitError, CleanOptions } from "simple-git"; import { ASK_PASS_INPUT_FILE, ASK_PASS_SCRIPT, ASK_PASS_SCRIPT_FILE, DEFAULT_WIN_GIT_PATH, GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH, } from "src/constants"; import type { LineAuthorFollowMovement } from "src/editor/lineAuthor/model"; import { GeneralModal } from "src/ui/modals/generalModal"; import type ObsidianGit from "../main"; import type { Blame, BlameCommit, BranchInfo, DiffFile, FileStatusResult, LogEntry, Status, } from "../types"; import { CurrentGitAction, NoNetworkError } from "../types"; import { impossibleBranch, spawnAsync, splitRemoteBranch } from "../utils"; import { GitManager } from "./gitManager"; export class SimpleGit extends GitManager { git: simple.SimpleGit; absoluteRepoPath: string; watchAbortController: AbortController | undefined; useDefaultWindowsGitPath: boolean = false; constructor(plugin: ObsidianGit) { super(plugin); } async setGitInstance(ignoreError = false): Promise { if (await this.isGitInstalled()) { const adapter = this.app.vault.adapter as FileSystemAdapter; const vaultBasePath = adapter.getBasePath(); let basePath = vaultBasePath; // Because the basePath setting is a relative path, a leading `/` must // be appended before concatenating with the path. if (this.plugin.settings.basePath) { const exists = await adapter.exists( normalizePath(this.plugin.settings.basePath) ); if (exists) { basePath = path.join( vaultBasePath, this.plugin.settings.basePath ); } else if (!ignoreError) { new Notice("ObsidianGit: Base path does not exist"); } } this.absoluteRepoPath = basePath; this.git = simpleGit({ baseDir: basePath, binary: this.plugin.localStorage.getGitPath() || (this.useDefaultWindowsGitPath ? DEFAULT_WIN_GIT_PATH : undefined), config: ["core.quotepath=off"], unsafe: { allowUnsafeCustomBinary: true, }, }); const pathPaths = this.plugin.localStorage.getPATHPaths(); const envVars = this.plugin.localStorage.getEnvVars(); const gitDir = this.plugin.settings.gitDir; const envs = { ...process.env }; if (pathPaths.length > 0) { const path = pathPaths.join(":") + ":" + envs["PATH"]; envs["PATH"] = path; } if (gitDir) { envs["GIT_DIR"] = gitDir; } for (const envVar of envVars) { const [key, value] = envVar.split("="); envs[key] = value; } const SIMPLE_GIT_NAMESPACE = "simple-git"; const NAMESPACE_SEPARATOR = ","; const currentDebug = (localStorage.debug ?? "") as string; const namespaces = currentDebug.split(NAMESPACE_SEPARATOR); if ( !namespaces.includes(SIMPLE_GIT_NAMESPACE) && !namespaces.includes(`-${SIMPLE_GIT_NAMESPACE}`) ) { namespaces.push(SIMPLE_GIT_NAMESPACE); debug.enable(namespaces.join(NAMESPACE_SEPARATOR)); } if (await this.git.checkIsRepo()) { // Resolve the relative root reported by git into an absolute path // in case git resides in a different filesystem (eg, WSL) const relativeRoot = await this.git.revparse("--show-cdup"); const absoluteRoot = resolve(basePath + sep + relativeRoot); this.absoluteRepoPath = absoluteRoot; await this.git.cwd(absoluteRoot); } const absolutePluginConfigPath = path.join( vaultBasePath, this.app.vault.configDir, "plugins", "obsidian-git" ); const askPassPath = path.join( absolutePluginConfigPath, ASK_PASS_SCRIPT_FILE ); if (envs["SSH_ASKPASS"] == undefined) { envs["SSH_ASKPASS"] = askPassPath; } // OpenSSH requires DISPLAY variable to be set for SSH_ASKPASS to // detect a graphical environment. This is not the case for e.g. // Windows. Setting SSH_ASKPASS_REQUIRE to "force" makes it use // SSH_ASKPASS even without DISPLAY, which allows the askpass script // to work on Windows as well. envs["SSH_ASKPASS_REQUIRE"] = "force"; envs["OBSIDIAN_GIT_CREDENTIALS_INPUT"] = path.join( absolutePluginConfigPath, ASK_PASS_INPUT_FILE ); if (envs["SSH_ASKPASS"] == askPassPath) { this.askpass().catch((e) => this.plugin.displayError(e)); } envs["OBSIDIAN_GIT"] = "1"; this.git = this.git.env(envs); } } // Constructs a path relative to the vault from a path relative to the git repository getRelativeVaultPath(filePath: string): string { const adapter = this.app.vault.adapter as FileSystemAdapter; const from = adapter.getBasePath(); const to = path.join(this.absoluteRepoPath, filePath); let res = path.relative(from, to); if (Platform.isWin) { res = res.replace(/\\/g, "/"); } return res; } // Constructs a path relative to the git repository from a path relative to the vault // // @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. getRelativeRepoPath( filePath: string, doConversion: boolean = true ): string { if (doConversion) { const adapter = this.plugin.app.vault.adapter as FileSystemAdapter; const vaultPath = adapter.getBasePath(); const from = this.absoluteRepoPath; const to = path.join(vaultPath, filePath); let res = path.relative(from, to); if (Platform.isWin) { res = res.replace(/\\/g, "/"); } return res; } return filePath; } private get absPluginConfigPath(): string { const adapter = this.app.vault.adapter as FileSystemAdapter; const vaultPath = adapter.getBasePath(); return path.join( vaultPath, this.app.vault.configDir, "plugins", "obsidian-git" ); } private get relPluginConfigPath(): string { return path.join(this.app.vault.configDir, "plugins", "obsidian-git"); } async askpass(): Promise { const adapter = this.app.vault.adapter as FileSystemAdapter; const relPluginConfigDir = this.app.vault.configDir + "/plugins/obsidian-git/"; await this.addAskPassScriptToExclude(); await fsPromises.writeFile( path.join(this.absPluginConfigPath, ASK_PASS_SCRIPT_FILE), ASK_PASS_SCRIPT ); await fsPromises.chmod( path.join(this.absPluginConfigPath, ASK_PASS_SCRIPT_FILE), 0o755 ); this.watchAbortController = new AbortController(); const { signal } = this.watchAbortController; try { const watcher = fsPromises.watch(this.absPluginConfigPath, { signal, }); for await (const event of watcher) { if (event.filename != ASK_PASS_INPUT_FILE) continue; const triggerFilePath = relPluginConfigDir + ASK_PASS_INPUT_FILE; // Wait a bit to ensure the file is fully removed await new Promise((res) => setTimeout(res, 200)); if (!(await adapter.exists(triggerFilePath))) continue; const data = await adapter.read(triggerFilePath); let notice: Notice | undefined; // The text is too long for the modal, so a notice is shown instead if (data.length > 60) { notice = new Notice(data, 999_999); } const response = await new GeneralModal(this.plugin, { allowEmpty: true, obscure: true, placeholder: data.length > 60 ? "Enter a response to the message." : data, }).openAndGetResult(); notice?.hide(); // Just in case the trigger file was removed while the modal was open if (await adapter.exists(triggerFilePath)) { await adapter.write( `${triggerFilePath}.response`, response ?? "" ); } } } catch (error) { this.plugin.displayError(error); await fsPromises.rm( path.join(this.absPluginConfigPath, ASK_PASS_SCRIPT_FILE), { force: true } ); await fsPromises.rm( path.join( this.absPluginConfigPath, `${ASK_PASS_SCRIPT_FILE}.response` ), { force: true } ); await new Promise((res) => setTimeout(res, 5000)); this.plugin.log("Retry watch for ask pass"); await this.askpass(); } } /** * Adds the askpass script to the exclude file of the git repository. * * This prevents the script from being tracked by git. This should be no * problem as the script does not contain any sensitive data, but may * cause issues with file permissions on other devices. * See https://github.com/Vinzent03/obsidian-git/issues/903 */ async addAskPassScriptToExclude(): Promise { try { if (!(await this.git.checkIsRepo())) { return; } const absoluteExcludeFilePath = await this.git.revparse([ "--path-format=absolute", "--git-path", "info/exclude", ]); const vaultRelativeAskPassScriptFile = path.join( this.app.vault.configDir, "plugins", "obsidian-git", ASK_PASS_SCRIPT_FILE ); const repoRelativeAskPassScriptFile = this.getRelativeRepoPath( vaultRelativeAskPassScriptFile, true ); const content = await fsPromises.readFile( absoluteExcludeFilePath, "utf-8" ); const lines = content.split("\n"); const contains = lines.some((line) => line.contains(repoRelativeAskPassScriptFile) ); if (!contains) { await fsPromises.appendFile( absoluteExcludeFilePath, repoRelativeAskPassScriptFile + "\n" ); } } catch (error) { // Catch any errors, because this is not critical console.error( "Error while adding askpass script to exclude file:", error ); } } unload(): void { this.watchAbortController?.abort(); } async status(opts?: { path?: string }): Promise { const dir = opts?.path; this.plugin.setPluginState({ gitAction: CurrentGitAction.status }); const status = await this.git.status( dir != undefined ? ["--", dir] : [] ); this.plugin.setPluginState({ gitAction: CurrentGitAction.idle }); const allFilesFormatted = status.files.map((e) => { const res = this.formatPath(e); return { path: res.path, from: res.from, index: e.index === "?" ? "U" : e.index, workingDir: e.working_dir === "?" ? "U" : e.working_dir, vaultPath: this.getRelativeVaultPath(res.path), }; }); return { all: allFilesFormatted, changed: allFilesFormatted.filter((e) => e.workingDir !== " "), staged: allFilesFormatted.filter( (e) => e.index !== " " && e.index != "U" ), conflicted: status.conflicted.map( (path) => this.formatPath({ path }).path ), }; } async submoduleAwareHeadRevisonInContainingDirectory( filepath: string ): Promise { const repoPath = this.getRelativeRepoPath(filepath); const containingDirectory = path.dirname(repoPath); const args = ["-C", containingDirectory, "rev-parse", "HEAD"]; const result = this.git.raw(args); result.catch((err) => console.warn("obsidian-git: rev-parse error:", err) ); return (await result).trim(); } async getSubmodulePaths(): Promise { return new Promise((resolve) => { this.git.outputHandler((_cmd, stdout, _stderr, args) => { // Do not run this handler on other commands if (!(args.contains("submodule") && args.contains("foreach"))) { return; } let body = ""; const root = ( this.app.vault.adapter as FileSystemAdapter ).getBasePath() + (this.plugin.settings.basePath ? "/" + this.plugin.settings.basePath : ""); stdout.on("data", (chunk: Buffer) => { body += chunk.toString("utf8"); }); stdout.on("end", () => { const submods = body.split("\n"); // Remove words like `Entering` in front of each line and filter empty lines const strippedSubmods: string[] = submods .map((i) => { const submod = i.match(/'([^']*)'/); if (submod != undefined) { return root + "/" + submod[1] + sep; } }) .filter((i): i is string => !!i); strippedSubmods.reverse(); resolve(strippedSubmods); }); }); this.git.subModule(["foreach", "--recursive", ""]).then( () => { this.git.outputHandler(() => {}); }, (e) => this.plugin.displayError(e) ); }); } //Remove wrong `"` like "My file.md" formatPath(path: { from?: string; path: string }): { path: string; from?: string; } { function format(path?: string): string | undefined { if (path == undefined) return undefined; if (path.startsWith('"') && path.endsWith('"')) { return path.substring(1, path.length - 1); } else { return path; } } if (path.from != undefined) { return { from: format(path.from), path: format(path.path)!, }; } else { return { path: format(path.path)!, }; } } async blame( path: string, trackMovement: LineAuthorFollowMovement, ignoreWhitespace: boolean ): Promise { path = this.getRelativeRepoPath(path); if (!(await this.isTracked(path))) return "untracked"; const inSubmodule = await this.getSubmoduleOfFile(path); const args = inSubmodule ? ["-C", inSubmodule.submodule] : []; const relativePath = inSubmodule ? inSubmodule.relativeFilepath : path; args.push("blame", "--porcelain"); if (ignoreWhitespace) args.push("-w"); const trackCArg = `-C${GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH}`; switch (trackMovement) { case "inactive": break; case "same-commit": args.push("-C", trackCArg); break; case "all-commits": args.push("-C", "-C", trackCArg); break; default: impossibleBranch(trackMovement); } args.push("--", relativePath); const rawBlame = await this.git.raw(args); return parseBlame(rawBlame); } async isTracked(path: string): Promise { const inSubmodule = await this.getSubmoduleOfFile(path); const args = inSubmodule ? ["-C", inSubmodule.submodule] : []; const relativePath = inSubmodule ? inSubmodule.relativeFilepath : path; args.push("ls-files", "--", relativePath); return this.git.raw(args).then((x) => x.trim() !== ""); } async commitAll({ message }: { message: string }): Promise { if (this.plugin.settings.updateSubmodules) { this.plugin.setPluginState({ gitAction: CurrentGitAction.commit }); const submodulePaths = await this.getSubmodulePaths(); for (const item of submodulePaths) { await this.git.cwd({ path: item, root: false }).add("-A"); await this.git .cwd({ path: item, root: false }) .commit(await this.formatCommitMessage(message)); } } this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); await this.git.add("-A"); this.plugin.setPluginState({ gitAction: CurrentGitAction.commit }); const res = await this.git.commit( await this.formatCommitMessage(message) ); this.app.workspace.trigger("obsidian-git:head-change"); return res.summary.changes; } async commit({ message, amend, }: { message: string; amend?: boolean; }): Promise { this.plugin.setPluginState({ gitAction: CurrentGitAction.commit }); const res = ( await this.git.commit( await this.formatCommitMessage(message), amend ? ["--amend"] : [] ) ).summary.changes; this.app.workspace.trigger("obsidian-git:head-change"); this.plugin.setPluginState({ gitAction: CurrentGitAction.idle }); return res; } async stage(path: string, relativeToVault: boolean): Promise { this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); path = this.getRelativeRepoPath(path, relativeToVault); await this.git.add(["--", path]); this.plugin.setPluginState({ gitAction: CurrentGitAction.idle }); } async stageAll({ dir }: { dir?: string }): Promise { this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); await this.git.add(dir ?? "-A"); this.plugin.setPluginState({ gitAction: CurrentGitAction.idle }); } async unstageAll({ dir }: { dir?: string }): Promise { this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); await this.git.reset(dir != undefined ? ["--", dir] : []); this.plugin.setPluginState({ gitAction: CurrentGitAction.idle }); } async unstage(path: string, relativeToVault: boolean): Promise { this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); path = this.getRelativeRepoPath(path, relativeToVault); await this.git.reset(["--", path]); this.plugin.setPluginState({ gitAction: CurrentGitAction.idle }); } async discard(filepath: string): Promise { this.plugin.setPluginState({ gitAction: CurrentGitAction.add }); if (await this.isTracked(filepath)) { await this.git.checkout(["--", filepath]); } this.plugin.setPluginState({ gitAction: CurrentGitAction.idle }); } async applyPatch(patch: string): Promise { const patchPath = path.join(this.relPluginConfigPath, "patch"); await this.app.vault.adapter.write(patchPath, patch); await this.git.applyPatch(patchPath, { "--cached": null, "--unidiff-zero": null, "--whitespace": "nowarn", }); await this.app.vault.adapter.remove(patchPath); } async getUntrackedPaths(opts: { path?: string }): Promise { const dir = opts?.path; this.plugin.setPluginState({ gitAction: CurrentGitAction.status }); const args = []; if (dir != undefined) { args.push("--", dir); } const untrackedFiles = await this.git.clean( CleanOptions.RECURSIVE + CleanOptions.DRY_RUN, args ); this.plugin.setPluginState({ gitAction: CurrentGitAction.idle }); return untrackedFiles.paths; } async hashObject(filepath: string): Promise { // Need to use raw command here to ensure filenames are literally used. // Perhaps we could file a PR? https://github.com/steveukx/git-js/blob/main/simple-git/src/lib/tasks/hash-object.ts filepath = this.getRelativeRepoPath(filepath); const inSubmodule = await this.getSubmoduleOfFile(filepath); const args = inSubmodule ? ["-C", inSubmodule.submodule] : []; const relativeFilepath = inSubmodule ? inSubmodule.relativeFilepath : filepath; args.push("hash-object", "--", relativeFilepath); const revision = this.git.raw(args); return revision; } async discardAll({ dir }: { dir?: string }): Promise { return this.discard(dir ?? "."); } async pull(): Promise { this.plugin.setPluginState({ gitAction: CurrentGitAction.pull }); try { if (this.plugin.settings.updateSubmodules) await this.git.subModule([ "update", "--remote", "--merge", "--recursive", ]); const branchInfo = await this.branchInfo(); const localCommit = await this.git.revparse([branchInfo.current!]); if (!branchInfo.tracking && this.plugin.settings.updateSubmodules) { this.plugin.log( "No tracking branch found. Ignoring pull of main repo and updating submodules only." ); return; } await this.git.fetch(); const upstreamCommit = await this.git.revparse([ branchInfo.tracking!, ]); if (localCommit !== upstreamCommit) { if ( this.plugin.settings.syncMethod === "merge" || this.plugin.settings.syncMethod === "rebase" ) { try { const args = [branchInfo.tracking!]; if (this.plugin.settings.mergeStrategy !== "none") { args.push( `--strategy-option=${this.plugin.settings.mergeStrategy}` ); } switch (this.plugin.settings.syncMethod) { case "merge": await this.git.merge(args); break; case "rebase": await this.git.rebase(args); } } catch (err) { this.plugin.displayError( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access `Pull failed (${this.plugin.settings.syncMethod}): ${"message" in err ? err.message : err}` ); return; } } else if (this.plugin.settings.syncMethod === "reset") { try { await this.git.raw([ "update-ref", `refs/heads/${branchInfo.current}`, upstreamCommit, ]); await this.unstageAll({}); } catch (err) { this.plugin.displayError( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access `Sync failed (${this.plugin.settings.syncMethod}): ${"message" in err ? err.message : err}` ); } } this.app.workspace.trigger("obsidian-git:head-change"); const afterMergeCommit = await this.git.revparse([ branchInfo.current!, ]); const filesChanged = await this.git.diff([ `${localCommit}..${afterMergeCommit}`, "--name-only", ]); return filesChanged .split(/\r\n|\r|\n/) .filter((value) => value.length > 0) .map((e) => { return { path: e, workingDir: "P", vaultPath: this.getRelativeVaultPath(e), }; }); } else { return []; } } catch (e) { this.convertErrors(e); } } async push(): Promise { this.plugin.setPluginState({ gitAction: CurrentGitAction.push }); try { if (this.plugin.settings.updateSubmodules) { const res = await this.git.subModule([ "foreach", "--recursive", `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`, ]); console.log(res); } const status = await this.git.status(); const trackingBranch = status.tracking; const currentBranch = status.current!; if (!trackingBranch && this.plugin.settings.updateSubmodules) { this.plugin.log( "No tracking branch found. Ignoring push of main repo and updating submodules only." ); return undefined; } let remoteChangedFiles: number | null = null; if (trackingBranch) { remoteChangedFiles = ( await this.git.diffSummary([ currentBranch, trackingBranch, "--", ]) ).changed; } await this.git.push(); return remoteChangedFiles; } catch (e) { this.convertErrors(e); } } async getUnpushedCommits(): Promise { const status = await this.git.status(); const trackingBranch = status.tracking; const currentBranch = status.current; if (trackingBranch == null || currentBranch == null) { return 0; } const [remote, _] = splitRemoteBranch(trackingBranch); const remoteBranches = await this.getRemoteBranches(remote); if (!remoteBranches.includes(trackingBranch)) { this.plugin.log( `Tracking branch ${trackingBranch} does not exist on remote ${remote}.` ); return 0; } const remoteChangedFiles = ( await this.git.diffSummary([currentBranch, trackingBranch, "--"]) ).changed; return remoteChangedFiles; } async canPush(): Promise { // allow pushing in submodules even if the root has no changes. if (this.plugin.settings.updateSubmodules === true) { return true; } const status = await this.git.status(); const trackingBranch = status.tracking; const currentBranch = status.current!; if (!trackingBranch) { return false; } const remoteChangedFiles = ( await this.git.diffSummary([currentBranch, trackingBranch, "--"]) ).changed; return remoteChangedFiles !== 0; } async checkRequirements(): Promise< "valid" | "missing-repo" | "missing-git" > { if (!(await this.isGitInstalled())) { return "missing-git"; } if (!(await this.git.checkIsRepo())) { return "missing-repo"; } return "valid"; } async branchInfo(): Promise { const status = await this.git.status(); const branches = await this.git.branch(["--no-color"]); return { current: status.current || undefined, tracking: status.tracking || undefined, branches: branches.all, }; } async getRemoteUrl(remote: string): Promise { try { return (await this.git.remote(["get-url", remote])) || undefined; } catch (error) { // Verify the error is at least not about git is not found or similar. Checks if the remote exists or not if (String(error).contains(remote)) { return undefined; } else { throw error; } } } // https://github.com/kometenstaub/obsidian-version-history-diff/issues/3 async log( file: string | undefined, relativeToVault = true, limit?: number, ref?: string ): Promise<(LogEntry & { fileName?: string })[]> { let path: string | undefined; if (file) { path = this.getRelativeRepoPath(file, relativeToVault); } const opts: Record = { file: path, maxCount: limit, // Ensures that the changed files are listed for merge commits as well and the commit is not repeated for each parent. // This only lists the changed files for the first parent. "--diff-merges": "first-parent", "--name-status": null, }; if (ref) { opts[ref] = null; } const res = await this.git.log(opts); return res.all.map((e) => ({ ...e, author: { name: e.author_name, email: e.author_email, }, refs: e.refs.split(", ").filter((e) => e.length > 0), diff: { ...e.diff!, files: e.diff?.files.map( (f: simple.DiffResultNameStatusFile) => ({ ...f, status: f.status!, path: f.file, hash: e.hash, vaultPath: this.getRelativeVaultPath(f.file), fromPath: f.from, fromVaultPath: f.from != undefined ? this.getRelativeVaultPath(f.from) : undefined, binary: f.binary, }) ) ?? [], }, fileName: e.diff?.files.first()?.file, })); } async show( commitHash: string, file: string, relativeToVault = true ): Promise { const path = this.getRelativeRepoPath(file, relativeToVault); return this.git.show([commitHash + ":" + path]); } async checkout(branch: string, remote?: string): Promise { if (remote) { branch = `${remote}/${branch}`; } await this.git.checkout(branch); if (this.plugin.settings.submoduleRecurseCheckout) { const submodulePaths = await this.getSubmodulePaths(); for (const submodulePath of submodulePaths) { const branchSummary = await this.git .cwd({ path: submodulePath, root: false }) .branch(); if (Object.keys(branchSummary.branches).includes(branch)) { await this.git .cwd({ path: submodulePath, root: false }) .checkout(branch); } } } } async createBranch(branch: string): Promise { await this.git.checkout(["-b", branch]); } async deleteBranch(branch: string, force: boolean): Promise { await this.git.branch([force ? "-D" : "-d", branch]); } async branchIsMerged(branch: string): Promise { const notMergedBranches = await this.git.branch(["--no-merged"]); return !notMergedBranches.all.contains(branch); } async init(): Promise { await this.git.init(false); } async clone(url: string, dir: string, depth?: number): Promise { await this.git.clone( url, path.join( (this.app.vault.adapter as FileSystemAdapter).getBasePath(), dir ), depth ? ["--depth", `${depth}`] : [] ); // Set required attributes like `absoluteRepoPath` and add the script to the exclude file if needed. await this.setGitInstance(); } async setConfig(path: string, value: string | undefined): Promise { if (value == undefined) { await this.git.raw(["config", "--local", "--unset", path]); } else { await this.git.addConfig(path, value); } } async getConfig( path: string, scope: "local" | "global" | "all" = "local" ): Promise { const res = await this.git.getConfig( path.toLowerCase(), scope == "all" ? undefined : scope ); return res.value ?? undefined; } async fetch(remote?: string): Promise { await this.git.fetch(remote != undefined ? [remote] : []); } async setRemote(name: string, url: string): Promise { if ((await this.getRemotes()).includes(name)) await this.git.remote(["set-url", name, url]); else { await this.git.remote(["add", name, url]); } } async getRemoteBranches(remote: string): Promise { const res = await this.git.branch(["-r", "--list", `${remote}*`]); const list = []; for (const item in res.branches) { list.push(res.branches[item].name); } return list; } async getRemotes() { const res = await this.git.remote([]); if (res) { return res.trim().split("\n"); } else { return []; } } async removeRemote(remoteName: string) { await this.git.removeRemote(remoteName); } /** * @param remoteBranch - The remote branch to set as upstream, in the format "remote/branch" */ async updateUpstreamBranch(remoteBranch: string) { try { // git 1.8+ await this.git.branch(["--set-upstream-to", remoteBranch]); } catch { try { // git 1.7 - 1.8 await this.git.branch(["--set-upstream", remoteBranch]); } catch { // fallback for when setting upstream branch to a branch that does not exist on the remote yet. Setting it with push instead. const [remote, remoteBranchName] = splitRemoteBranch(remoteBranch); const branchInfo = await this.branchInfo(); await this.git.push([ "--set-upstream", remote, `${branchInfo.current}:${remoteBranchName}`, ]); } } } updateGitPath(_: string): Promise { return this.setGitInstance(); } updateBasePath(_: string): Promise { return this.setGitInstance(true); } async getDiffString( filePath: string, stagedChanges = false, hash?: string ): Promise { if (stagedChanges) return await this.git.diff(["--cached", "--", filePath]); if (hash) return await this.git.show([`${hash}`, "--", filePath]); else return await this.git.diff(["--", filePath]); } async diff( file: string, commit1: string, commit2: string ): Promise { return await this.git.diff([`${commit1}..${commit2}`, "--", file]); } async rawCommand(command: string): Promise { const parts = command.split(" "); // Very simple parsing, may need string-argv const res = await this.git.raw(parts[0], ...parts.slice(1)); return res; } async getSubmoduleOfFile( repositoryRelativeFile: string ): Promise<{ submodule: string; relativeFilepath: string } | undefined> { // Documentation: https://git-scm.com/docs/git-rev-parse if ( !(await this.app.vault.adapter.exists( path.dirname(repositoryRelativeFile) )) ) { return undefined; } // git -C rev-parse --show-toplevel // returns the submodules repository root as an absolute path let submoduleRoot = await this.git.raw( [ "-C", path.dirname(repositoryRelativeFile), "rev-parse", "--show-toplevel", ], (err) => err && console.warn("get-submodule-of-file", err?.message) ); submoduleRoot = submoduleRoot.trim(); // git -C rev-parse --show-superproject-working-tree // returns the parent git repository, if the file is in a submodule - otherwise empty. const superProject = await this.git.raw( [ "-C", path.dirname(repositoryRelativeFile), "rev-parse", "--show-superproject-working-tree", ], (err) => err && console.warn("get-submodule-of-file", err?.message) ); if (superProject.trim() === "") { return undefined; // not in submodule } const fsAdapter = this.app.vault.adapter as FileSystemAdapter; const absolutePath = fsAdapter.getFullPath( path.normalize(repositoryRelativeFile) ); const newRelativePath = path.relative(submoduleRoot, absolutePath); return { submodule: submoduleRoot, relativeFilepath: newRelativePath }; } async getLastCommitTime(): Promise { try { const res = await this.git.log({ n: 1 }); if (res != null && res.latest != null) { return new Date(res.latest.date); } } catch (error) { if (error instanceof GitError) { if (error.message.contains("does not have any commits yet")) { return undefined; } } else { throw error; } } } private async isGitInstalled(): Promise { // https://github.com/steveukx/git-js/issues/402 const gitPath = this.plugin.localStorage.getGitPath(); const command = await spawnAsync(gitPath || "git", ["--version"], {}); if (command.error) { if (Platform.isWin && !gitPath) { this.plugin.log( `Git not found in PATH. Checking standard installation path(${DEFAULT_WIN_GIT_PATH}) of Git for Windows.` ); const command = await spawnAsync(DEFAULT_WIN_GIT_PATH, [ "--version", ]); if (command.error) { console.error(command.error); return false; } else { this.useDefaultWindowsGitPath = true; } } else { console.error(command.error); return false; } } else { this.useDefaultWindowsGitPath = false; } return true; } private convertErrors(error: unknown): never { if (error instanceof GitError) { const message = String(error.message); const networkFailure = message.contains("Could not resolve host") || message.contains("Unable to resolve host") || message.contains("Unable to open connection") || message.match( /ssh: connect to host .*? port .*?: Operation timed out/ ) != null || message.match( /ssh: connect to host .*? port .*?: Network is unreachable/ ) != null || message.match( /ssh: connect to host .*? port .*?: Undefined error: 0/ ) != null; if (networkFailure) { throw new NoNetworkError(message); } } throw error; } async isFileTrackedByLFS(filePath: string): Promise { try { // Checks if Gits filter attribute is set to lfs for the file, which means it is (or will be) tracked by LFS. const result = await this.git.raw([ "check-attr", "filter", filePath, ]); return result.includes("filter: lfs"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.plugin.displayError( `Error checking LFS status: ${errorMessage}` ); return false; } } } export const zeroCommit: BlameCommit = { hash: "000000", isZeroCommit: true, summary: "", }; // Parse git blame porcelain format: https://git-scm.com/docs/git-blame#_the_porcelain_format function parseBlame(blameOutputUnnormalized: string): Blame { const blameOutput = blameOutputUnnormalized.replace("\r\n", "\n"); const blameLines = blameOutput.split("\n"); const result: Blame = { commits: new Map(), hashPerLine: [undefined!], // one-based indices originalFileLineNrPerLine: [undefined!], finalFileLineNrPerLine: [undefined!], groupSizePerStartingLine: new Map(), }; let line = 1; for (let bi = 0; bi < blameLines.length; ) { if (startsWithNonWhitespace(blameLines[bi])) { const lineInfo = blameLines[bi].split(" "); const commitHash = parseLineInfoInto(lineInfo, line, result); bi++; // parse header values until a tab is encountered for (; startsWithNonWhitespace(blameLines[bi]); bi++) { const spaceSeparatedHeaderValues = blameLines[bi].split(" "); parseHeaderInto(spaceSeparatedHeaderValues, result, line); } finalizeBlameCommitInfo(result.commits.get(commitHash)!); // skip tab prefixed line line += 1; } else if (blameLines[bi] === "" && bi === blameLines.length - 1) { // EOF } else { throw Error( `Expected non-whitespace line or EOF, but found: ${blameLines[bi]}` ); } bi++; } return result; } function parseLineInfoInto(lineInfo: string[], line: number, result: Blame) { const hash = lineInfo[0]; result.hashPerLine.push(hash); result.originalFileLineNrPerLine.push(parseInt(lineInfo[1])); result.finalFileLineNrPerLine.push(parseInt(lineInfo[2])); if (lineInfo.length >= 4) result.groupSizePerStartingLine.set(line, parseInt(lineInfo[3])); if (parseInt(lineInfo[2]) !== line) { throw Error( `git-blame output is out of order: ${line} vs ${lineInfo[2]}` ); } return hash; } function parseHeaderInto(header: string[], out: Blame, line: number) { const key = header[0]; const value = header.slice(1).join(" "); const commitHash = out.hashPerLine[line]; const commit = out.commits.get(commitHash) || { hash: commitHash, author: {}, committer: {}, previous: {}, }; switch (key) { case "summary": commit.summary = value; break; case "author": commit.author!.name = value; break; case "author-mail": commit.author!.email = removeEmailBrackets(value); break; case "author-time": commit.author!.epochSeconds = parseInt(value); break; case "author-tz": commit.author!.tz = value; break; case "committer": commit.committer!.name = value; break; case "committer-mail": commit.committer!.email = removeEmailBrackets(value); break; case "committer-time": commit.committer!.epochSeconds = parseInt(value); break; case "committer-tz": commit.committer!.tz = value; break; case "previous": commit.previous!.commitHash = value; break; case "filename": commit.previous!.filename = value; break; } out.commits.set(commitHash, commit); } function finalizeBlameCommitInfo(commit: BlameCommit) { if (commit.summary === undefined) { throw Error(`Summary not provided for commit: ${commit.hash}`); } if (isUndefinedOrEmptyObject(commit.author)) { commit.author = undefined; } if (isUndefinedOrEmptyObject(commit.committer)) { commit.committer = undefined; } if (isUndefinedOrEmptyObject(commit.previous)) { commit.previous = undefined; } commit.isZeroCommit = Boolean(commit.hash.match(/^0*$/)); } function isUndefinedOrEmptyObject(obj: object | undefined | null): boolean { return !obj || Object.keys(obj).length === 0; } function startsWithNonWhitespace(str: string): boolean { return str.length > 0 && str[0].trim() === str[0]; } function removeEmailBrackets(gitEmail: string) { const prefixCleaned = gitEmail.startsWith("<") ? gitEmail.substring(1) : gitEmail; return prefixCleaned.endsWith(">") ? prefixCleaned.substring(0, prefixCleaned.length - 1) : prefixCleaned; } ================================================ FILE: src/main.ts ================================================ import { Errors } from "isomorphic-git"; import type { Debouncer, Menu, TAbstractFile, WorkspaceLeaf } from "obsidian"; import { debounce, FileSystemAdapter, MarkdownView, normalizePath, Notice, Platform, Plugin, TFile, TFolder, moment, } from "obsidian"; import * as path from "path"; import { pluginRef } from "src/pluginGlobalRef"; import { PromiseQueue } from "src/promiseQueue"; import { ObsidianGitSettingsTab } from "src/setting/settings"; import { StatusBar } from "src/statusBar"; import { CustomMessageModal } from "src/ui/modals/customMessageModal"; import AutomaticsManager from "./automaticsManager"; import { addCommmands } from "./commands"; import { CONFLICT_OUTPUT_FILE, DEFAULT_SETTINGS, DIFF_VIEW_CONFIG, HISTORY_VIEW_CONFIG, SOURCE_CONTROL_VIEW_CONFIG, SPLIT_DIFF_VIEW_CONFIG, } from "./constants"; import type { GitManager } from "./gitManager/gitManager"; import { IsomorphicGit } from "./gitManager/isomorphicGit"; import { SimpleGit } from "./gitManager/simpleGit"; import { LocalStorageSettings } from "./setting/localStorageSettings"; import Tools from "./tools"; import type { FileStatusResult, ObsidianGitSettings, PluginState, Status, UnstagedFile, } from "./types"; import { CurrentGitAction, mergeSettingsByPriority, NoNetworkError, } from "./types"; import DiffView from "./ui/diff/diffView"; import SplitDiffView from "./ui/diff/splitDiffView"; import HistoryView from "./ui/history/historyView"; import { BranchModal } from "./ui/modals/branchModal"; import { GeneralModal } from "./ui/modals/generalModal"; import GitView from "./ui/sourceControl/sourceControl"; import { BranchStatusBar } from "./ui/statusBar/branchStatusBar"; import { assertNever, convertPathToAbsoluteGitignoreRule, formatRemoteUrl, spawnAsync, splitRemoteBranch, } from "./utils"; import { DiscardModal, type DiscardResult } from "./ui/modals/discardModal"; import { HunkActions } from "./editor/signs/hunkActions"; import { EditorIntegration } from "./editor/editorIntegration"; export default class ObsidianGit extends Plugin { gitManager: GitManager; automaticsManager = new AutomaticsManager(this); tools = new Tools(this); localStorage = new LocalStorageSettings(this); settings: ObsidianGitSettings; settingsTab?: ObsidianGitSettingsTab; statusBar?: StatusBar; branchBar?: BranchStatusBar; state: PluginState = { gitAction: CurrentGitAction.idle, offlineMode: false, }; lastPulledFiles: FileStatusResult[]; gitReady = false; promiseQueue: PromiseQueue = new PromiseQueue(this); /** * Debouncer for the auto commit after file changes. */ autoCommitDebouncer: Debouncer<[], void> | undefined; cachedStatus: Status | undefined; // Used to store the path of the file that is currently shown in the diff view. lastDiffViewState: Record | undefined; intervalsToClear: number[] = []; editorIntegration: EditorIntegration = new EditorIntegration(this); hunkActions = new HunkActions(this); /** * Debouncer for the refresh of the git status for the source control view after file changes. */ debRefresh: Debouncer<[], void>; setPluginState(state: Partial): void { this.state = Object.assign(this.state, state); this.statusBar?.display(); } async updateCachedStatus(): Promise { this.app.workspace.trigger("obsidian-git:loading-status"); this.cachedStatus = await this.gitManager.status(); if (this.cachedStatus.conflicted.length > 0) { this.localStorage.setConflict(true); await this.branchBar?.display(); } else { this.localStorage.setConflict(false); await this.branchBar?.display(); } this.app.workspace.trigger( "obsidian-git:status-changed", this.cachedStatus ); return this.cachedStatus; } async refresh() { if (!this.gitReady) return; const gitViews = this.app.workspace.getLeavesOfType( SOURCE_CONTROL_VIEW_CONFIG.type ); const historyViews = this.app.workspace.getLeavesOfType( HISTORY_VIEW_CONFIG.type ); if ( this.settings.changedFilesInStatusBar || gitViews.some((leaf) => !(leaf.isDeferred ?? false)) || historyViews.some((leaf) => !(leaf.isDeferred ?? false)) ) { await this.updateCachedStatus().catch((e) => this.displayError(e)); } this.app.workspace.trigger("obsidian-git:refreshed"); // We don't put a line authoring refresh here, as it would force a re-loading // of the line authoring feature - which would lead to a jumpy editor-view in the // ui after every rename event. } refreshUpdatedHead() {} async onload() { console.log( "loading " + this.manifest.name + " plugin: v" + this.manifest.version ); pluginRef.plugin = this; this.localStorage.migrate(); await this.loadSettings(); await this.migrateSettings(); this.settingsTab = new ObsidianGitSettingsTab(this.app, this); this.addSettingTab(this.settingsTab); if (!this.localStorage.getPluginDisabled()) { this.registerStuff(); this.app.workspace.onLayoutReady(() => this.init({ fromReload: false }).catch((e) => this.displayError(e) ) ); } } onExternalSettingsChange() { this.reloadSettings().catch((e) => this.displayError(e)); } /** Reloads the settings from disk and applies them by unloading the plugin * and initializing it again. */ async reloadSettings(): Promise { const previousSettings = JSON.stringify(this.settings); await this.loadSettings(); const newSettings = JSON.stringify(this.settings); // Only reload plugin if the settings have actually changed if (previousSettings !== newSettings) { this.log("Reloading settings"); this.unloadPlugin(); await this.init({ fromReload: true }); this.app.workspace .getLeavesOfType(SOURCE_CONTROL_VIEW_CONFIG.type) .forEach((leaf) => { if (!(leaf.isDeferred ?? false)) return (leaf.view as GitView).reload(); }); this.app.workspace .getLeavesOfType(HISTORY_VIEW_CONFIG.type) .forEach((leaf) => { if (!(leaf.isDeferred ?? false)) return (leaf.view as HistoryView).reload(); }); } } /** This method only registers events, views, commands and more. * * This only needs to be called once since the registered events are * unregistered when the plugin is unloaded. * * This mustn't depend on the plugin's settings. */ registerStuff(): void { this.registerEvent( this.app.workspace.on("obsidian-git:refresh", () => { this.refresh().catch((e) => this.displayError(e)); }) ); this.registerEvent( this.app.workspace.on("obsidian-git:head-change", () => { this.refreshUpdatedHead(); }) ); this.registerEvent( this.app.workspace.on("file-menu", (menu, file, source) => { this.handleFileMenu(menu, file, source, "file-manu"); }) ); this.registerEvent( this.app.workspace.on("obsidian-git:menu", (menu, path, source) => { this.handleFileMenu(menu, path, source, "obsidian-git:menu"); }) ); this.registerEvent( this.app.workspace.on("active-leaf-change", (leaf) => { this.onActiveLeafChange(leaf); }) ); this.registerEvent( this.app.vault.on("modify", () => { this.debRefresh(); this.autoCommitDebouncer?.(); }) ); this.registerEvent( this.app.vault.on("delete", () => { this.debRefresh(); this.autoCommitDebouncer?.(); }) ); this.registerEvent( this.app.vault.on("create", () => { this.debRefresh(); this.autoCommitDebouncer?.(); }) ); this.registerEvent( this.app.vault.on("rename", () => { this.debRefresh(); this.autoCommitDebouncer?.(); }) ); this.registerView(SOURCE_CONTROL_VIEW_CONFIG.type, (leaf) => { return new GitView(leaf, this); }); this.registerView(HISTORY_VIEW_CONFIG.type, (leaf) => { return new HistoryView(leaf, this); }); this.registerView(DIFF_VIEW_CONFIG.type, (leaf) => { return new DiffView(leaf, this); }); this.registerView(SPLIT_DIFF_VIEW_CONFIG.type, (leaf) => { return new SplitDiffView(leaf, this); }); this.addRibbonIcon( "git-pull-request", "Open Git source control", async () => { const leafs = this.app.workspace.getLeavesOfType( SOURCE_CONTROL_VIEW_CONFIG.type ); let leaf: WorkspaceLeaf; if (leafs.length === 0) { leaf = this.app.workspace.getRightLeaf(false) ?? this.app.workspace.getLeaf(); await leaf.setViewState({ type: SOURCE_CONTROL_VIEW_CONFIG.type, }); } else { leaf = leafs.first()!; } await this.app.workspace.revealLeaf(leaf); } ); this.registerHoverLinkSource(SOURCE_CONTROL_VIEW_CONFIG.type, { display: "Git View", defaultMod: true, }); this.editorIntegration.onLoadPlugin(); this.setRefreshDebouncer(); addCommmands(this); } setRefreshDebouncer(): void { this.debRefresh?.cancel(); this.debRefresh = debounce( () => { if (this.settings.refreshSourceControl) { this.refresh().catch(console.error); } }, this.settings.refreshSourceControlTimer, true ); } async addFileToGitignore( filePath: string, isFolder?: boolean ): Promise { const gitRelativePath = this.gitManager.getRelativeRepoPath( filePath, true ); // Define an absolute rule that can apply only for this item. const gitignoreRule = convertPathToAbsoluteGitignoreRule({ isFolder, gitRelativePath, }); await this.app.vault.adapter.append( this.gitManager.getRelativeVaultPath(".gitignore"), "\n" + gitignoreRule ); this.app.workspace.trigger("obsidian-git:refresh"); } handleFileMenu( menu: Menu, file: TAbstractFile | string, source: string, type: "file-manu" | "obsidian-git:menu" ): void { if (!this.gitReady) return; if (!this.settings.showFileMenu) return; if (!file) return; let filePath: string; if (typeof file === "string") { filePath = file; } else { filePath = file.path; } if (source == "file-explorer-context-menu") { menu.addItem((item) => { item.setTitle(`Git: Stage`) .setIcon("plus-circle") .setSection("action") .onClick((_) => { this.promiseQueue.addTask(async () => { if (file instanceof TFile) { await this.stageFile(file); } else { await this.gitManager.stageAll({ dir: this.gitManager.getRelativeRepoPath( filePath, true ), }); this.app.workspace.trigger( "obsidian-git:refresh" ); } }); }); }); menu.addItem((item) => { item.setTitle(`Git: Unstage`) .setIcon("minus-circle") .setSection("action") .onClick((_) => { this.promiseQueue.addTask(async () => { if (file instanceof TFile) { await this.unstageFile(file); } else { await this.gitManager.unstageAll({ dir: this.gitManager.getRelativeRepoPath( filePath, true ), }); this.app.workspace.trigger( "obsidian-git:refresh" ); } }); }); }); menu.addItem((item) => { item.setTitle(`Git: Add to .gitignore`) .setIcon("file-x") .setSection("action") .onClick((_) => { this.addFileToGitignore( filePath, file instanceof TFolder ).catch((e) => this.displayError(e)); }); }); } if (source == "git-source-control") { menu.addItem((item) => { item.setTitle(`Git: Add to .gitignore`) .setIcon("file-x") .setSection("action") .onClick((_) => { this.addFileToGitignore( filePath, file instanceof TFolder ).catch((e) => this.displayError(e)); }); }); const gitManager = this.app.vault.adapter; if ( type === "obsidian-git:menu" && gitManager instanceof FileSystemAdapter ) { menu.addItem((item) => { item.setTitle("Open in default app") .setIcon("arrow-up-right") .setSection("action") .onClick((_) => { this.app.openWithDefaultApp(filePath); }); }); menu.addItem((item) => { item.setTitle("Show in system explorer") .setIcon("arrow-up-right") .setSection("action") .onClick((_) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access (window as any).electron.shell.showItemInFolder( path.join(gitManager.getBasePath(), filePath) ); }); }); } } } async migrateSettings(): Promise { if (this.settings.mergeOnPull != undefined) { this.settings.syncMethod = this.settings.mergeOnPull ? "merge" : "rebase"; this.settings.mergeOnPull = undefined; await this.saveSettings(); } if (this.settings.autoCommitMessage === undefined) { this.settings.autoCommitMessage = this.settings.commitMessage; await this.saveSettings(); } if (this.settings.gitPath != undefined) { this.localStorage.setGitPath(this.settings.gitPath); this.settings.gitPath = undefined; await this.saveSettings(); } if (this.settings.username != undefined) { this.localStorage.setPassword(this.settings.username); this.settings.username = undefined; await this.saveSettings(); } } unloadPlugin() { this.gitReady = false; this.editorIntegration.onUnloadPlugin(); this.automaticsManager.unload(); this.branchBar?.remove(); this.statusBar?.remove(); this.statusBar = undefined; this.branchBar = undefined; this.gitManager.unload(); this.promiseQueue.clear(); for (const interval of this.intervalsToClear) { window.clearInterval(interval); } this.intervalsToClear = []; this.debRefresh.cancel(); } onunload() { this.unloadPlugin(); console.log("unloading " + this.manifest.name + " plugin"); } async loadSettings() { // At first startup, `data` is `null` because data.json does not exist. let data = (await this.loadData()) as ObsidianGitSettings | null; //Check for existing settings if (data == undefined) { data = { showedMobileNotice: true }; } this.settings = mergeSettingsByPriority(DEFAULT_SETTINGS, data); } async saveSettings() { this.settingsTab?.beforeSaveSettings(); await this.saveData(this.settings); } get useSimpleGit(): boolean { return Platform.isDesktopApp; } async init({ fromReload = false }): Promise { if (this.settings.showStatusBar && !this.statusBar) { const statusBarEl = this.addStatusBarItem(); this.statusBar = new StatusBar(statusBarEl, this); this.intervalsToClear.push( window.setInterval(() => this.statusBar?.display(), 1000) ); } try { if (this.useSimpleGit) { this.gitManager = new SimpleGit(this); await (this.gitManager as SimpleGit).setGitInstance(); } else { this.gitManager = new IsomorphicGit(this); } const result = await this.gitManager.checkRequirements(); const pausedAutomatics = this.localStorage.getPausedAutomatics(); switch (result) { case "missing-git": this.displayError( `Cannot run git command. Trying to run: '${this.localStorage.getGitPath() || "git"}' .` ); break; case "missing-repo": new Notice( "Can't find a valid git repository. Please create one via the given command or clone an existing repo.", 10000 ); break; case "valid": this.gitReady = true; this.setPluginState({ gitAction: CurrentGitAction.idle }); if ( Platform.isDesktop && this.settings.showBranchStatusBar && !this.branchBar ) { const branchStatusBarEl = this.addStatusBarItem(); this.branchBar = new BranchStatusBar( branchStatusBarEl, this ); this.intervalsToClear.push( window.setInterval( () => void this.branchBar ?.display() .catch(console.error), 60000 ) ); } await this.branchBar?.display(); this.editorIntegration.onReady(); this.app.workspace.trigger("obsidian-git:refresh"); /// Among other things, this notifies the history view that git is ready this.app.workspace.trigger("obsidian-git:head-change"); if ( !fromReload && this.settings.autoPullOnBoot && !pausedAutomatics ) { this.promiseQueue.addTask(() => this.pullChangesFromRemote() ); } if (!pausedAutomatics) { await this.automaticsManager.init(); } if (pausedAutomatics) { new Notice("Automatic routines are currently paused."); } break; default: this.log( "Something weird happened. The 'checkRequirements' result is " + /* eslint-disable-next-line @typescript-eslint/restrict-plus-operands */ result ); } } catch (error) { this.displayError(error); console.error(error); } } async createNewRepo() { try { await this.gitManager.init(); new Notice("Initialized new repo"); await this.init({ fromReload: true }); } catch (e) { this.displayError(e); } } async cloneNewRepo() { const modal = new GeneralModal(this, { placeholder: "Enter remote URL", }); const url = await modal.openAndGetResult(); if (url) { const confirmOption = "Vault Root"; let dir = await new GeneralModal(this, { options: this.gitManager instanceof IsomorphicGit ? [confirmOption] : [], placeholder: "Enter directory for clone. It needs to be empty or not existent.", allowEmpty: this.gitManager instanceof IsomorphicGit, }).openAndGetResult(); if (dir == undefined) return; if (dir === confirmOption) { dir = "."; } dir = normalizePath(dir); if (dir === "/") { dir = "."; } if (dir === ".") { const modal = new GeneralModal(this, { options: ["NO", "YES"], placeholder: `Does your remote repo contain a ${this.app.vault.configDir} directory at the root?`, onlySelection: true, }); const containsConflictDir = await modal.openAndGetResult(); if (containsConflictDir === undefined) { new Notice("Aborted clone"); return; } else if (containsConflictDir === "YES") { const confirmOption = "DELETE ALL YOUR LOCAL CONFIG AND PLUGINS"; const modal = new GeneralModal(this, { options: ["Abort clone", confirmOption], placeholder: `To avoid conflicts, the local ${this.app.vault.configDir} directory needs to be deleted.`, onlySelection: true, }); const shouldDelete = (await modal.openAndGetResult()) === confirmOption; if (shouldDelete) { await this.app.vault.adapter.rmdir( this.app.vault.configDir, true ); } else { new Notice("Aborted clone"); return; } } } const depth = await new GeneralModal(this, { placeholder: "Specify depth of clone. Leave empty for full clone.", allowEmpty: true, }).openAndGetResult(); let depthInt = undefined; if (depth === undefined) { new Notice("Aborted clone"); return; } if (depth !== "") { depthInt = parseInt(depth); if (isNaN(depthInt)) { new Notice("Invalid depth. Aborting clone."); return; } } new Notice(`Cloning new repo into "${dir}"`); const oldBase = this.settings.basePath; const customDir = dir && dir !== "."; //Set new base path before clone to ensure proper .git/index file location in isomorphic-git if (customDir) { this.settings.basePath = dir; } try { await this.gitManager.clone( formatRemoteUrl(url), dir, depthInt ); new Notice("Cloned new repo."); new Notice("Please restart Obsidian"); if (customDir) { await this.saveSettings(); } } catch (error) { this.displayError(error); this.settings.basePath = oldBase; await this.saveSettings(); } } } /** * Retries to call `this.init()` if necessary, otherwise returns directly * @returns true if `this.gitManager` is ready to be used, false if not. */ async isAllInitialized(): Promise { if (!this.gitReady) { await this.init({ fromReload: true }); } return this.gitReady; } ///Used for command async pullChangesFromRemote(): Promise { if (!(await this.isAllInitialized())) return; const filesUpdated = await this.pull(); if (filesUpdated === false) { return; } if (!filesUpdated) { this.displayMessage("Pull: Everything is up-to-date"); } if (this.gitManager instanceof SimpleGit) { const status = await this.updateCachedStatus(); if (status.conflicted.length > 0) { this.displayError( `You have conflicts in ${status.conflicted.length} ${ status.conflicted.length == 1 ? "file" : "files" }` ); await this.handleConflict(status.conflicted); } } this.app.workspace.trigger("obsidian-git:refresh"); this.setPluginState({ gitAction: CurrentGitAction.idle }); } async commitAndSync({ fromAutoBackup, requestCustomMessage = false, commitMessage, onlyStaged = false, }: { fromAutoBackup: boolean; requestCustomMessage?: boolean; commitMessage?: string; onlyStaged?: boolean; }): Promise { if (!(await this.isAllInitialized())) return; if ( this.settings.syncMethod == "reset" && this.settings.pullBeforePush ) { await this.pull(); } const commitSuccessful = await this.commit({ fromAuto: fromAutoBackup, requestCustomMessage, commitMessage, onlyStaged, }); if (!commitSuccessful) { return; } if ( this.settings.syncMethod != "reset" && this.settings.pullBeforePush ) { await this.pull(); } if (!this.settings.disablePush) { // Prevent trying to push every time. Only if unpushed commits are present if ( (await this.remotesAreSet()) && (await this.gitManager.canPush()) ) { await this.push(); } else { this.displayMessage("No commits to push"); } } this.setPluginState({ gitAction: CurrentGitAction.idle }); } // Returns true if commit was successfully async commit({ fromAuto, requestCustomMessage = false, onlyStaged = false, commitMessage, amend = false, }: { fromAuto: boolean; requestCustomMessage?: boolean; onlyStaged?: boolean; commitMessage?: string; amend?: boolean; }): Promise { if (!(await this.isAllInitialized())) return false; try { let hadConflict = this.localStorage.getConflict(); let status: Status | undefined; let stagedFiles: { vaultPath: string; path: string }[] = []; let unstagedFiles: (UnstagedFile & { vaultPath: string })[] = []; if (this.gitManager instanceof SimpleGit) { await this.mayDeleteConflictFile(); status = await this.updateCachedStatus(); //Should not be necessary, but just in case if (status.conflicted.length == 0) { hadConflict = false; } // check for conflict files on auto backup if (fromAuto && status.conflicted.length > 0) { this.displayError( `Did not commit, because you have conflicts in ${ status.conflicted.length } ${ status.conflicted.length == 1 ? "file" : "files" }. Please resolve them and commit per command.` ); await this.handleConflict(status.conflicted); return false; } stagedFiles = status.staged; // This typecast is only needed to hide the fact that `type` is missing, but that is only needed for isomorphic-git unstagedFiles = status.changed as unknown as (UnstagedFile & { vaultPath: string; })[]; } else { // isomorphic-git section if (fromAuto && hadConflict) { // isomorphic-git doesn't have a way to detect current // conflicts, they are only detected on commit // // Conflicts should only be resolved by manually committing. this.displayError( `Did not commit, because you have conflicts. Please resolve them and commit per command.` ); return false; } else { if (hadConflict) { await this.mayDeleteConflictFile(); } const gitManager = this.gitManager as IsomorphicGit; if (onlyStaged) { stagedFiles = await gitManager.getStagedFiles(); } else { const res = await gitManager.getUnstagedFiles(); unstagedFiles = res.map(({ path, type }) => ({ vaultPath: this.gitManager.getRelativeVaultPath(path), path, type, })); } } } if ( await this.tools.hasTooBigFiles( onlyStaged ? stagedFiles : [...stagedFiles, ...unstagedFiles] ) ) { this.setPluginState({ gitAction: CurrentGitAction.idle }); return false; } if ( unstagedFiles.length + stagedFiles.length !== 0 || hadConflict ) { // The commit message from settings or previously set in the // source control view let cmtMessage = (commitMessage ??= fromAuto ? this.settings.autoCommitMessage : this.settings.commitMessage); // Optionally ask the user via a modal for a commit message if ( (fromAuto && this.settings.customMessageOnAutoBackup) || requestCustomMessage ) { if (!this.settings.disablePopups && fromAuto) { new Notice( "Auto backup: Please enter a custom commit message. Leave empty to abort" ); } const modalMessage = await new CustomMessageModal( this ).openAndGetResult(); if ( modalMessage != undefined && modalMessage != "" && modalMessage != "..." ) { cmtMessage = modalMessage; } else { this.setPluginState({ gitAction: CurrentGitAction.idle, }); return false; } // On desktop may run a script to get the commit message } else if ( this.gitManager instanceof SimpleGit && this.settings.commitMessageScript ) { const templateScript = this.settings.commitMessageScript; const hostname = this.localStorage.getHostname() || ""; let formattedScript = templateScript.replace( "{{hostname}}", hostname ); formattedScript = formattedScript.replace( "{{date}}", moment().format(this.settings.commitDateFormat) ); const res = await spawnAsync( "sh", ["-c", formattedScript], { cwd: this.gitManager.absoluteRepoPath } ); if (res.code != 0) { this.displayError(res.stderr); } else if (res.stdout.trim().length == 0) { this.displayMessage( "Stdout from commit message script is empty. Using default message." ); } else { cmtMessage = res.stdout; } } // Check if commit message is empty after all processing if (!cmtMessage || cmtMessage.trim() === "") { new Notice("Commit aborted: No commit message provided"); this.setPluginState({ gitAction: CurrentGitAction.idle, }); return false; } let committedFiles: number | undefined; if (onlyStaged) { committedFiles = await this.gitManager.commit({ message: cmtMessage, amend, }); } else { committedFiles = await this.gitManager.commitAll({ message: cmtMessage, status, unstagedFiles, amend, }); } // Handle eventually resolved conflicts if (this.gitManager instanceof SimpleGit) { await this.updateCachedStatus(); } let roughly = false; if (committedFiles === undefined) { roughly = true; committedFiles = unstagedFiles.length + stagedFiles.length || 0; } this.displayMessage( `Committed${roughly ? " approx." : ""} ${committedFiles} ${ committedFiles == 1 ? "file" : "files" }` ); } else { this.displayMessage("No changes to commit"); } this.app.workspace.trigger("obsidian-git:refresh"); return true; } catch (error) { this.displayError(error); return false; } } /* * Returns true if push was successful */ async push(): Promise { if (!(await this.isAllInitialized())) return false; if (!(await this.remotesAreSet())) { return false; } const hadConflict = this.localStorage.getConflict(); try { if (this.gitManager instanceof SimpleGit) await this.mayDeleteConflictFile(); // Refresh because of pull let status: Status; if ( this.gitManager instanceof SimpleGit && (status = await this.updateCachedStatus()).conflicted.length > 0 ) { this.displayError( `Cannot push. You have conflicts in ${ status.conflicted.length } ${status.conflicted.length == 1 ? "file" : "files"}` ); await this.handleConflict(status.conflicted); return false; } else if ( this.gitManager instanceof IsomorphicGit && hadConflict ) { this.displayError(`Cannot push. You have conflicts`); return false; } this.log("Pushing...."); const pushedFiles = await this.gitManager.push(); if (pushedFiles !== undefined) { if (pushedFiles === null) { this.displayMessage(`Pushed to remote`); } else if (pushedFiles > 0) { this.displayMessage( `Pushed ${pushedFiles} ${ pushedFiles == 1 ? "file" : "files" } to remote` ); } else { this.displayMessage(`No commits to push`); } } this.setPluginState({ offlineMode: false }); this.app.workspace.trigger("obsidian-git:refresh"); return true; } catch (e) { if (e instanceof NoNetworkError) { this.handleNoNetworkError(e); } else { this.displayError(e); } return false; } } /** Used for internals * Returns whether the pull added a commit or not. * * See {@link pullChangesFromRemote} for the command version. */ async pull(): Promise { if (!(await this.remotesAreSet())) { return false; } try { this.log("Pulling...."); const pulledFiles = (await this.gitManager.pull()) || []; this.setPluginState({ offlineMode: false }); if (pulledFiles.length > 0) { this.displayMessage( `Pulled ${pulledFiles.length} ${ pulledFiles.length == 1 ? "file" : "files" } from remote` ); this.lastPulledFiles = pulledFiles; } return pulledFiles.length; } catch (e) { this.displayError(e); return false; } } async fetch(): Promise { if (!(await this.remotesAreSet())) { return; } try { await this.gitManager.fetch(); this.displayMessage(`Fetched from remote`); this.setPluginState({ offlineMode: false }); this.app.workspace.trigger("obsidian-git:refresh"); } catch (error) { this.displayError(error); } } async mayDeleteConflictFile(): Promise { const file = this.app.vault.getAbstractFileByPath(CONFLICT_OUTPUT_FILE); if (file) { this.app.workspace.iterateAllLeaves((leaf) => { if ( leaf.view instanceof MarkdownView && leaf.view.file?.path == file.path ) { leaf.detach(); } }); await this.app.vault.delete(file); } } async stageFile(file: TFile): Promise { if (!(await this.isAllInitialized())) return false; await this.gitManager.stage(file.path, true); this.app.workspace.trigger("obsidian-git:refresh"); this.setPluginState({ gitAction: CurrentGitAction.idle }); return true; } async unstageFile(file: TFile): Promise { if (!(await this.isAllInitialized())) return false; await this.gitManager.unstage(file.path, true); this.app.workspace.trigger("obsidian-git:refresh"); this.setPluginState({ gitAction: CurrentGitAction.idle }); return true; } async switchBranch(): Promise { if (!(await this.isAllInitialized())) return; const branchInfo = await this.gitManager.branchInfo(); const selectedBranch = await new BranchModal( this, branchInfo.branches ).openAndGetReslt(); if (selectedBranch != undefined) { await this.gitManager.checkout(selectedBranch); this.displayMessage(`Switched to ${selectedBranch}`); this.app.workspace.trigger("obsidian-git:refresh"); await this.branchBar?.display(); return selectedBranch; } } async switchRemoteBranch(): Promise { if (!(await this.isAllInitialized())) return; const selectedBranch = (await this.selectRemoteBranch()) || ""; const [remote, branch] = splitRemoteBranch(selectedBranch); if (branch != undefined && remote != undefined) { await this.gitManager.checkout(branch, remote); this.displayMessage(`Switched to ${selectedBranch}`); await this.branchBar?.display(); return selectedBranch; } } async createBranch(): Promise { if (!(await this.isAllInitialized())) return; const newBranch = await new GeneralModal(this, { placeholder: "Create new branch", }).openAndGetResult(); if (newBranch != undefined) { await this.gitManager.createBranch(newBranch); this.displayMessage(`Created new branch ${newBranch}`); await this.branchBar?.display(); return newBranch; } } async deleteBranch(): Promise { if (!(await this.isAllInitialized())) return; const branchInfo = await this.gitManager.branchInfo(); if (branchInfo.current) branchInfo.branches.remove(branchInfo.current); const branch = await new GeneralModal(this, { options: branchInfo.branches, placeholder: "Delete branch", onlySelection: true, }).openAndGetResult(); if (branch != undefined) { let force = false; const merged = await this.gitManager.branchIsMerged(branch); // Using await inside IF throws exception if (!merged) { const forceAnswer = await new GeneralModal(this, { options: ["YES", "NO"], placeholder: "This branch isn't merged into HEAD. Force delete?", onlySelection: true, }).openAndGetResult(); if (forceAnswer !== "YES") { return; } force = forceAnswer === "YES"; } await this.gitManager.deleteBranch(branch, force); this.displayMessage(`Deleted branch ${branch}`); await this.branchBar?.display(); return branch; } } /** Ensures that the upstream branch is set. * If not, it will prompt the user to set it. * * An exception is when the user has submodules enabled. * In this case, the upstream branch is not required, * to allow pulling/pushing only the submodules and not the outer repo. */ async remotesAreSet(): Promise { if (this.settings.updateSubmodules) { return true; } if ( this.gitManager instanceof SimpleGit && (await this.gitManager.getConfig("push.autoSetupRemote", "all")) == "true" ) { return true; } if (!(await this.gitManager.branchInfo()).tracking) { new Notice("No upstream branch is set. Please select one."); return await this.setUpstreamBranch(); } return true; } async setUpstreamBranch(): Promise { const remoteBranch = await this.selectRemoteBranch(); if (remoteBranch == undefined) { this.displayError("Aborted. No upstream-branch is set!", 10000); this.setPluginState({ gitAction: CurrentGitAction.idle }); return false; } else { await this.gitManager.updateUpstreamBranch(remoteBranch); this.displayMessage(`Set upstream branch to ${remoteBranch}`); this.setPluginState({ gitAction: CurrentGitAction.idle }); return true; } } async discardAll(path?: string): Promise { if (!(await this.isAllInitialized())) return false; const status = await this.gitManager.status({ path }); let filesToDeleteCount = 0; let filesToDiscardCount = 0; for (const file of status.changed) { if (file.workingDir == "U") { filesToDeleteCount++; } else { filesToDiscardCount++; } } if (filesToDeleteCount + filesToDiscardCount == 0) { return false; } const result = await new DiscardModal({ app: this.app, filesToDeleteCount, filesToDiscardCount, path: path ?? "", }).openAndGetResult(); switch (result) { case false: return result; case "discard": await this.gitManager.discardAll({ dir: path, status: this.cachedStatus, }); break; case "delete": { await this.gitManager.discardAll({ dir: path, status: this.cachedStatus, }); const untrackedPaths = await this.gitManager.getUntrackedPaths({ path, status: this.cachedStatus, }); for (const file of untrackedPaths) { const vaultPath = this.gitManager.getRelativeVaultPath(file); const tFile = this.app.vault.getAbstractFileByPath(vaultPath); if (tFile) { await this.app.fileManager.trashFile(tFile); } else { if (file.endsWith("/")) { await this.app.vault.adapter.rmdir(vaultPath, true); } else { await this.app.vault.adapter.remove(vaultPath); } } } break; } default: assertNever(result); } this.app.workspace.trigger("obsidian-git:refresh"); return result; } async handleConflict(conflicted?: string[]): Promise { this.localStorage.setConflict(true); let lines: string[] | undefined; if (conflicted !== undefined) { lines = [ "# Conflicts", "Please resolve them and commit them using the commands `Git: Commit all changes` followed by `Git: Push`", "(This file will automatically be deleted before commit)", "[[#Additional Instructions]] available below file list", "", ...conflicted.map((e) => { const file = this.app.vault.getAbstractFileByPath(e); if (file instanceof TFile) { const link = this.app.metadataCache.fileToLinktext( file, "/" ); return `- [[${link}]]`; } else { return `- Not a file: ${e}`; } }), ` # Additional Instructions I 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. \`\`\`diff <<<<<<< HEAD File changes in local repository ======= File changes in remote repository >>>>>>> origin/main \`\`\``, ]; } await this.tools.writeAndOpenFile(lines?.join("\n")); } async editRemotes(): Promise { if (!(await this.isAllInitialized())) return; const remotes = await this.gitManager.getRemotes(); const nameModal = new GeneralModal(this, { options: remotes, placeholder: "Select or create a new remote by typing its name and selecting it", }); const remoteName = await nameModal.openAndGetResult(); if (remoteName) { const oldUrl = await this.gitManager.getRemoteUrl(remoteName); const urlModal = new GeneralModal(this, { initialValue: oldUrl, placeholder: "Enter remote URL", }); // urlModal.inputEl.setText(oldUrl ?? ""); const remoteURL = await urlModal.openAndGetResult(); if (remoteURL) { await this.gitManager.setRemote( remoteName, formatRemoteUrl(remoteURL) ); return remoteName; } } } async selectRemoteBranch(): Promise { let remotes = await this.gitManager.getRemotes(); let selectedRemote: string | undefined; if (remotes.length === 0) { selectedRemote = await this.editRemotes(); if (selectedRemote == undefined) { remotes = await this.gitManager.getRemotes(); } } const nameModal = new GeneralModal(this, { options: remotes, placeholder: "Select or create a new remote by typing its name and selecting it", }); const remoteName = selectedRemote ?? (await nameModal.openAndGetResult()); if (remoteName) { this.displayMessage("Fetching remote branches"); await this.gitManager.fetch(remoteName); const branches = await this.gitManager.getRemoteBranches(remoteName); const branchModal = new GeneralModal(this, { options: branches, placeholder: "Select or create a new remote branch by typing its name and selecting it", }); const branch = await branchModal.openAndGetResult(); if (branch == undefined) return; if (!branch.startsWith(remoteName + "/")) { // If the branch does not start with the remote name, prepend it return `${remoteName}/${branch}`; } return branch; // Already in the correct format } } async removeRemote() { if (!(await this.isAllInitialized())) return; const remotes = await this.gitManager.getRemotes(); const nameModal = new GeneralModal(this, { options: remotes, placeholder: "Select a remote", }); const remoteName = await nameModal.openAndGetResult(); if (remoteName) { await this.gitManager.removeRemote(remoteName); } } onActiveLeafChange(leaf: WorkspaceLeaf | null): void { const view = leaf?.view; // Prevent removing focus when switching to other panes than file panes like search or GitView if ( !view?.getState().file && !(view instanceof DiffView || view instanceof SplitDiffView) ) return; const sourceControlLeaf = this.app.workspace .getLeavesOfType(SOURCE_CONTROL_VIEW_CONFIG.type) .first(); const historyLeaf = this.app.workspace .getLeavesOfType(HISTORY_VIEW_CONFIG.type) .first(); // Clear existing active state sourceControlLeaf?.view.containerEl .querySelector(`div.tree-item-self.is-active`) ?.removeClass("is-active"); historyLeaf?.view.containerEl .querySelector(`div.tree-item-self.is-active`) ?.removeClass("is-active"); if ( leaf?.view instanceof DiffView || leaf?.view instanceof SplitDiffView ) { const path = leaf.view.state.bFile; const escapedPath = path.replace(/["\\]/g, "\\$&"); this.lastDiffViewState = leaf.view.getState(); let el: Element | undefined | null; if (sourceControlLeaf && leaf.view.state.aRef == "HEAD") { el = sourceControlLeaf.view.containerEl.querySelector( `div.staged div.tree-item-self[data-path="${escapedPath}"]` ); } else if (sourceControlLeaf && leaf.view.state.aRef == "") { el = sourceControlLeaf.view.containerEl.querySelector( `div.changes div.tree-item-self[data-path="${escapedPath}"]` ); } else if (historyLeaf) { el = historyLeaf.view.containerEl.querySelector( `div.tree-item-self[data-path='${escapedPath}']` ); } el?.addClass("is-active"); } else { this.lastDiffViewState = undefined; } } handleNoNetworkError(_: NoNetworkError): void { if (!this.state.offlineMode) { this.displayError( "Git: Going into offline mode. Future network errors will no longer be displayed.", 2000 ); } else { this.log("Encountered network error, but already in offline mode"); } this.setPluginState({ gitAction: CurrentGitAction.idle, offlineMode: true, }); } // region: displaying / formatting messages displayMessage(message: string, timeout: number = 4 * 1000): void { this.statusBar?.displayMessage(message.toLowerCase(), timeout); if (!this.settings.disablePopups) { if ( !this.settings.disablePopupsForNoChanges || !message.startsWith("No changes") ) { new Notice(message, 5 * 1000); } } this.log(message); } displayError(data: unknown, timeout: number = 10 * 1000): void { if (data instanceof Errors.UserCanceledError) { new Notice("Aborted"); return; } let error: Error; if (data instanceof Error) { error = data; } else { error = new Error(String(data)); } this.setPluginState({ gitAction: CurrentGitAction.idle }); if (this.settings.showErrorNotices) { new Notice(error.message, timeout); } console.error(`${this.manifest.id}:`, error.stack); this.statusBar?.displayMessage(error.message.toLowerCase(), timeout); } log(...data: unknown[]) { console.log(`${this.manifest.id}:`, ...data); } } ================================================ FILE: src/openInGitHub.ts ================================================ import type { Editor, TFile } from "obsidian"; import { Notice } from "obsidian"; import type { GitManager } from "./gitManager/gitManager"; import { SimpleGit } from "./gitManager/simpleGit"; export async function openLineInGitHub( editor: Editor, file: TFile, manager: GitManager ) { const data = await getData(file, manager); if (data.result === "failure") { new Notice(data.reason); return; } const { isGitHub, branch, repo, user, filePath } = data; if (isGitHub) { const from = editor.getCursor("from").line + 1; const to = editor.getCursor("to").line + 1; if (from === to) { window.open( `https://github.com/${user}/${repo}/blob/${branch}/${filePath}?plain=1#L${from}` ); } else { window.open( `https://github.com/${user}/${repo}/blob/${branch}/${filePath}?plain=1#L${from}-L${to}` ); } } else { new Notice("It seems like you are not using GitHub"); } } export async function openHistoryInGitHub(file: TFile, manager: GitManager) { const data = await getData(file, manager); if (data.result === "failure") { new Notice(data.reason); return; } const { isGitHub, branch, repo, user, filePath } = data; if (isGitHub) { window.open( `https://github.com/${user}/${repo}/commits/${branch}/${filePath}` ); } else { new Notice("It seems like you are not using GitHub"); } } async function getData( file: TFile, manager: GitManager ): Promise< | { result: "success"; isGitHub: boolean; user: string; repo: string; branch: string; filePath: string; } | { result: "failure"; reason: string } > { const branchInfo = await manager.branchInfo(); let remoteBranch = branchInfo.tracking; let branch = branchInfo.current; let remoteUrl: string | undefined = undefined; let filePath = manager.getRelativeRepoPath(file.path); if (manager instanceof SimpleGit) { const submodule = await manager.getSubmoduleOfFile( manager.getRelativeRepoPath(file.path) ); if (submodule) { filePath = submodule.relativeFilepath; const status = await manager.git .cwd({ path: submodule.submodule, root: false, }) .status(); remoteBranch = status.tracking || undefined; branch = status.current || undefined; if (remoteBranch) { const remote = remoteBranch.substring( 0, remoteBranch.indexOf("/") ); const config = await manager.git .cwd({ path: submodule.submodule, root: false, }) .getConfig(`remote.${remote}.url`, "local"); if (config.value != null) { remoteUrl = config.value; } else { return { result: "failure", reason: "Failed to get remote url of submodule", }; } } } } if (remoteBranch == null) { return { result: "failure", reason: "Remote branch is not configured", }; } if (branch == null) { return { result: "failure", reason: "Failed to get current branch name", }; } if (remoteUrl == null) { const remote = remoteBranch.substring(0, remoteBranch.indexOf("/")); remoteUrl = await manager.getConfig(`remote.${remote}.url`); if (remoteUrl == null) { return { result: "failure", reason: "Failed to get remote url", }; } } const res = remoteUrl.match( /(?:^https:\/\/github\.com\/(.+)\/(.+?)(?:\.git)?$)|(?:^[a-zA-Z]+@github\.com:(.+)\/(.+?)(?:\.git)?$)/ ); if (res == null) { return { result: "failure", reason: "Could not parse remote url", }; } else { const [isGitHub, httpsUser, httpsRepo, sshUser, sshRepo] = res; return { result: "success", isGitHub: !!isGitHub, repo: httpsRepo || sshRepo, user: httpsUser || sshUser, branch: branch, filePath: filePath, }; } } ================================================ FILE: src/pluginGlobalRef.ts ================================================ import type ObsidianGit from "src/main"; /** * Store the reference to the {@link ObsidianGit} plugin globally, so that * the line author gutter context menu can access it for quick configuration. */ export const pluginRef: { plugin?: ObsidianGit } = {}; ================================================ FILE: src/promiseQueue.ts ================================================ import type ObsidianGit from "./main"; export class PromiseQueue { private tasks: { task: () => Promise; onFinished: (res: unknown) => void; }[] = []; constructor(private readonly plugin: ObsidianGit) {} /** * Add a task to the queue. * * @param task The task to add. * @param onFinished A callback that is called when the task is finished. Both on success and on error. */ addTask( task: () => Promise, onFinished?: (res: T | undefined) => void ): void { this.tasks.push({ task, onFinished: onFinished ?? (() => {}) }); if (this.tasks.length === 1) { this.handleTask(); } } private handleTask(): void { if (this.tasks.length > 0) { const item = this.tasks[0]; item.task().then( (res) => { item.onFinished(res); this.tasks.shift(); this.handleTask(); }, (e) => { this.plugin.displayError(e); item.onFinished(undefined); this.tasks.shift(); this.handleTask(); } ); } } clear(): void { this.tasks = []; } } ================================================ FILE: src/setting/localStorageSettings.ts ================================================ import type { App } from "obsidian"; import type ObsidianGit from "../main"; export class LocalStorageSettings { private prefix: string; private app: App; constructor(private readonly plugin: ObsidianGit) { this.prefix = this.plugin.manifest.id + ":"; this.app = plugin.app; } migrate(): void { const keys = [ "password", "hostname", "conflict", "lastAutoPull", "lastAutoBackup", "lastAutoPush", "gitPath", "pluginDisabled", ]; for (const key of keys) { const old = localStorage.getItem(this.prefix + key); if ( this.app.loadLocalStorage(this.prefix + key) == null && old != null ) { if (old != null) { this.app.saveLocalStorage(this.prefix + key, old); localStorage.removeItem(this.prefix + key); } } } } getPassword(): string | null { return this.app.loadLocalStorage(this.prefix + "password"); } setPassword(value: string): void { return this.app.saveLocalStorage(this.prefix + "password", value); } getUsername(): string | null { return this.app.loadLocalStorage(this.prefix + "username"); } setUsername(value: string): void { return this.app.saveLocalStorage(this.prefix + "username", value); } getHostname(): string | null { return this.app.loadLocalStorage(this.prefix + "hostname"); } setHostname(value: string): void { return this.app.saveLocalStorage(this.prefix + "hostname", value); } getConflict(): boolean { return this.app.loadLocalStorage(this.prefix + "conflict") == "true"; } setConflict(value: boolean): void { return this.app.saveLocalStorage(this.prefix + "conflict", `${value}`); } getLastAutoPull(): string | null { return this.app.loadLocalStorage(this.prefix + "lastAutoPull"); } setLastAutoPull(value: string): void { return this.app.saveLocalStorage(this.prefix + "lastAutoPull", value); } getLastAutoBackup(): string | null { return this.app.loadLocalStorage(this.prefix + "lastAutoBackup"); } setLastAutoBackup(value: string): void { return this.app.saveLocalStorage(this.prefix + "lastAutoBackup", value); } getLastAutoPush(): string | null { return this.app.loadLocalStorage(this.prefix + "lastAutoPush"); } setLastAutoPush(value: string): void { return this.app.saveLocalStorage(this.prefix + "lastAutoPush", value); } getGitPath(): string | null { return this.app.loadLocalStorage(this.prefix + "gitPath"); } setGitPath(value: string): void { return this.app.saveLocalStorage(this.prefix + "gitPath", value); } getPATHPaths(): string[] { return ( this.app.loadLocalStorage(this.prefix + "PATHPaths")?.split(":") ?? [] ); } setPATHPaths(value: string[]): void { return this.app.saveLocalStorage( this.prefix + "PATHPaths", value.join(":") ); } getEnvVars(): string[] { return JSON.parse( this.app.loadLocalStorage(this.prefix + "envVars") ?? "[]" ) as string[]; } setEnvVars(value: string[]): void { return this.app.saveLocalStorage( this.prefix + "envVars", JSON.stringify(value) ); } getPluginDisabled(): boolean { return ( this.app.loadLocalStorage(this.prefix + "pluginDisabled") == "true" ); } setPluginDisabled(value: boolean): void { return this.app.saveLocalStorage( this.prefix + "pluginDisabled", `${value}` ); } /** * Whether automatic routines are currently paused. * New timers should not be started when this is true. */ getPausedAutomatics(): boolean { return ( this.app.loadLocalStorage(this.prefix + "pausedAutomatics") == "true" ); } setPausedAutomatics(value: boolean): void { return this.app.saveLocalStorage( this.prefix + "pausedAutomatics", `${value}` ); } } ================================================ FILE: src/setting/settings.ts ================================================ import type { App, RGB, TextComponent } from "obsidian"; import { moment, Notice, Platform, PluginSettingTab, Setting, TextAreaComponent, } from "obsidian"; import { DATE_TIME_FORMAT_SECONDS, DEFAULT_SETTINGS, GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH, } from "src/constants"; import { IsomorphicGit } from "src/gitManager/isomorphicGit"; import { SimpleGit } from "src/gitManager/simpleGit"; import { previewColor } from "src/editor/lineAuthor/lineAuthorProvider"; import type { LineAuthorDateTimeFormatOptions, LineAuthorDisplay, LineAuthorFollowMovement, LineAuthorSettings, LineAuthorTimezoneOption, } from "src/editor/lineAuthor/model"; import type ObsidianGit from "src/main"; import type { ObsidianGitSettings, MergeStrategy, ShowAuthorInHistoryView, SyncMethod, } from "src/types"; import { convertToRgb, formatMinutes, rgbToString } from "src/utils"; const FORMAT_STRING_REFERENCE_URL = "https://momentjs.com/docs/#/parsing/string-format/"; const LINE_AUTHOR_FEATURE_WIKI_LINK = "https://publish.obsidian.md/git-doc/Line+Authoring"; export class ObsidianGitSettingsTab extends PluginSettingTab { lineAuthorColorSettings: Map<"oldest" | "newest", Setting> = new Map(); constructor( app: App, private plugin: ObsidianGit ) { super(app, plugin); } icon = "git-pull-request"; private get settings() { return this.plugin.settings; } display(): void { const { containerEl } = this; const plugin: ObsidianGit = this.plugin; let commitOrSync: string; if (plugin.settings.differentIntervalCommitAndPush) { commitOrSync = "commit"; } else { commitOrSync = "commit-and-sync"; } const gitReady = plugin.gitReady; containerEl.empty(); if (!gitReady) { containerEl.createEl("p", { text: "Git is not ready. When all settings are correct you can configure commit-sync, etc.", }); containerEl.createEl("br"); } let setting: Setting; if (gitReady) { new Setting(containerEl).setName("Automatic").setHeading(); new Setting(containerEl) .setName("Split timers for automatic commit and sync") .setDesc( "Enable to use one interval for commit and another for sync." ) .addToggle((toggle) => toggle .setValue( plugin.settings.differentIntervalCommitAndPush ) .onChange(async (value) => { plugin.settings.differentIntervalCommitAndPush = value; await plugin.saveSettings(); plugin.automaticsManager.reload("commit", "push"); this.refreshDisplayWithDelay(); }) ); new Setting(containerEl) .setName(`Auto ${commitOrSync} interval (minutes)`) .setDesc( `${ plugin.settings.differentIntervalCommitAndPush ? "Commit" : "Commit and sync" } changes every X minutes. Set to 0 (default) to disable. (See below setting for further configuration!)` ) .addText((text) => { text.inputEl.type = "number"; this.setNonDefaultValue({ text, settingsProperty: "autoSaveInterval", }); text.setPlaceholder( String(DEFAULT_SETTINGS.autoSaveInterval) ); text.onChange(async (value) => { if (value !== "") { plugin.settings.autoSaveInterval = Number(value); } else { plugin.settings.autoSaveInterval = DEFAULT_SETTINGS.autoSaveInterval; } await plugin.saveSettings(); plugin.automaticsManager.reload("commit"); }); }); setting = new Setting(containerEl) .setName(`Auto ${commitOrSync} after stopping file edits`) .setDesc( `Requires the ${commitOrSync} interval not to be 0. If turned on, do auto ${commitOrSync} every ${formatMinutes( plugin.settings.autoSaveInterval )} after stopping file edits. This also prevents auto ${commitOrSync} while editing a file. If turned off, it's independent from the last file edit.` ) .addToggle((toggle) => toggle .setValue(plugin.settings.autoBackupAfterFileChange) .onChange(async (value) => { plugin.settings.autoBackupAfterFileChange = value; this.refreshDisplayWithDelay(); await plugin.saveSettings(); plugin.automaticsManager.reload("commit"); }) ); this.mayDisableSetting( setting, plugin.settings.setLastSaveToLastCommit ); setting = new Setting(containerEl) .setName(`Auto ${commitOrSync} after latest commit`) .setDesc( `If turned on, sets last auto ${commitOrSync} timestamp to the latest commit timestamp. This reduces the frequency of auto ${commitOrSync} when doing manual commits.` ) .addToggle((toggle) => toggle .setValue(plugin.settings.setLastSaveToLastCommit) .onChange(async (value) => { plugin.settings.setLastSaveToLastCommit = value; await plugin.saveSettings(); plugin.automaticsManager.reload("commit"); this.refreshDisplayWithDelay(); }) ); this.mayDisableSetting( setting, plugin.settings.autoBackupAfterFileChange ); setting = new Setting(containerEl) .setName(`Auto push interval (minutes)`) .setDesc( "Push commits every X minutes. Set to 0 (default) to disable." ) .addText((text) => { text.inputEl.type = "number"; this.setNonDefaultValue({ text, settingsProperty: "autoPushInterval", }); text.setPlaceholder( String(DEFAULT_SETTINGS.autoPushInterval) ); text.onChange(async (value) => { if (value !== "") { plugin.settings.autoPushInterval = Number(value); } else { plugin.settings.autoPushInterval = DEFAULT_SETTINGS.autoPushInterval; } await plugin.saveSettings(); plugin.automaticsManager.reload("push"); }); }); this.mayDisableSetting( setting, !plugin.settings.differentIntervalCommitAndPush ); new Setting(containerEl) .setName("Auto pull interval (minutes)") .setDesc( "Pull changes every X minutes. Set to 0 (default) to disable." ) .addText((text) => { text.inputEl.type = "number"; this.setNonDefaultValue({ text, settingsProperty: "autoPullInterval", }); text.setPlaceholder( String(DEFAULT_SETTINGS.autoPullInterval) ); text.onChange(async (value) => { if (value !== "") { plugin.settings.autoPullInterval = Number(value); } else { plugin.settings.autoPullInterval = DEFAULT_SETTINGS.autoPullInterval; } await plugin.saveSettings(); plugin.automaticsManager.reload("pull"); }); }); new Setting(containerEl) .setName(`Auto ${commitOrSync} only staged files`) .setDesc( `If turned on, only staged files are committed on ${commitOrSync}. If turned off, all changed files are committed.` ) .addToggle((toggle) => toggle .setValue(plugin.settings.autoCommitOnlyStaged) .onChange(async (value) => { plugin.settings.autoCommitOnlyStaged = value; await plugin.saveSettings(); }) ); new Setting(containerEl) .setName( `Specify custom commit message on auto ${commitOrSync}` ) .setDesc("You will get a pop up to specify your message.") .addToggle((toggle) => toggle .setValue(plugin.settings.customMessageOnAutoBackup) .onChange(async (value) => { plugin.settings.customMessageOnAutoBackup = value; await plugin.saveSettings(); this.refreshDisplayWithDelay(); }) ); setting = new Setting(containerEl) .setName(`Commit message on auto ${commitOrSync}`) .setDesc( "Available placeholders: {{date}}" + " (see below), {{hostname}} (see below), {{numFiles}} (number of changed files in the commit) and {{files}} (changed files in commit message)." ) .addTextArea((text) => { text.setPlaceholder( DEFAULT_SETTINGS.autoCommitMessage ).onChange(async (value) => { if (value === "") { plugin.settings.autoCommitMessage = DEFAULT_SETTINGS.autoCommitMessage; } else { plugin.settings.autoCommitMessage = value; } await plugin.saveSettings(); }); this.setNonDefaultValue({ text, settingsProperty: "autoCommitMessage", }); }); this.mayDisableSetting( setting, plugin.settings.customMessageOnAutoBackup ); new Setting(containerEl).setName("Commit message").setHeading(); const manualCommitMessageSetting = new Setting(containerEl) .setName("Commit message on manual commit") .setDesc( "Available placeholders: {{date}}" + " (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." ); manualCommitMessageSetting.addTextArea((text) => { manualCommitMessageSetting.addButton((button) => { button .setIcon("reset") .setTooltip( `Set to default: "${DEFAULT_SETTINGS.commitMessage}"` ) .onClick(() => { text.setValue(DEFAULT_SETTINGS.commitMessage); text.onChanged(); }); }); text.setValue(plugin.settings.commitMessage); text.onChange(async (value) => { plugin.settings.commitMessage = value; await plugin.saveSettings(); }); }); new Setting(containerEl) .setName("Commit message script") .setDesc( "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}}." ) .addText((text) => { text.onChange(async (value) => { if (value === "") { plugin.settings.commitMessageScript = DEFAULT_SETTINGS.commitMessageScript; } else { plugin.settings.commitMessageScript = value; } await plugin.saveSettings(); }); this.setNonDefaultValue({ text, settingsProperty: "commitMessageScript", }); }); const datePlaceholderSetting = new Setting(containerEl) .setName("{{date}} placeholder format") .addMomentFormat((text) => text .setDefaultFormat(plugin.settings.commitDateFormat) .setValue(plugin.settings.commitDateFormat) .onChange(async (value) => { plugin.settings.commitDateFormat = value; await plugin.saveSettings(); }) ); datePlaceholderSetting.descEl.innerHTML = ` Specify custom date format. E.g. "${DATE_TIME_FORMAT_SECONDS}. See Moment.js for more formats.`; new Setting(containerEl) .setName("{{hostname}} placeholder replacement") .setDesc( "Specify custom hostname for every device. Defaults to the OS hostname if not set on desktop." ) .addText((text) => text .setValue(plugin.localStorage.getHostname() ?? "") .onChange((value) => { plugin.localStorage.setHostname(value); }) ); new Setting(containerEl) .setName("Preview commit message") .addButton((button) => button.setButtonText("Preview").onClick(async () => { const commitMessagePreview = await plugin.gitManager.formatCommitMessage( plugin.settings.commitMessage ); new Notice(`${commitMessagePreview}`); }) ); new Setting(containerEl) .setName("List filenames affected by commit in the commit body") .addToggle((toggle) => toggle .setValue(plugin.settings.listChangedFilesInMessageBody) .onChange(async (value) => { plugin.settings.listChangedFilesInMessageBody = value; await plugin.saveSettings(); }) ); new Setting(containerEl).setName("Pull").setHeading(); if (plugin.gitManager instanceof SimpleGit) new Setting(containerEl) .setName("Merge strategy") .setDesc( "Decide how to integrate commits from your remote branch into your local branch." ) .addDropdown((dropdown) => { const options: Record = { merge: "Merge", rebase: "Rebase", reset: "Other sync service (Only updates the HEAD without touching the working directory)", }; dropdown.addOptions(options); dropdown.setValue(plugin.settings.syncMethod); dropdown.onChange(async (option: SyncMethod) => { plugin.settings.syncMethod = option; await plugin.saveSettings(); }); }); new Setting(containerEl) .setName("Merge strategy on conflicts") .setDesc( "Decide how to solve conflicts when pulling remote changes. This can be used to favor your local changes or the remote changes automatically." ) .addDropdown((dropdown) => { const options: Record = { none: "None (git default)", ours: "Our changes", theirs: "Their changes", }; dropdown.addOptions(options); dropdown.setValue(plugin.settings.mergeStrategy); dropdown.onChange(async (option: MergeStrategy) => { plugin.settings.mergeStrategy = option; await plugin.saveSettings(); }); }); new Setting(containerEl) .setName("Pull on startup") .setDesc("Automatically pull commits when Obsidian starts.") .addToggle((toggle) => toggle .setValue(plugin.settings.autoPullOnBoot) .onChange(async (value) => { plugin.settings.autoPullOnBoot = value; await plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Commit-and-sync") .setDesc( "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." ) .setHeading(); setting = new Setting(containerEl) .setName("Push on commit-and-sync") .setDesc( `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.` ) .addToggle((toggle) => toggle .setValue(!plugin.settings.disablePush) .onChange(async (value) => { plugin.settings.disablePush = !value; this.refreshDisplayWithDelay(); await plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Pull on commit-and-sync") .setDesc( `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.` ) .addToggle((toggle) => toggle .setValue(plugin.settings.pullBeforePush) .onChange(async (value) => { plugin.settings.pullBeforePush = value; this.refreshDisplayWithDelay(); await plugin.saveSettings(); }) ); if (plugin.gitManager instanceof SimpleGit) { new Setting(containerEl) .setName("Hunk management") .setDesc( "Hunks are sections of grouped line changes right in your editor." ) .setHeading(); new Setting(containerEl) .setName("Signs") .setDesc( "This allows you to see your changes right in your editor via colored markers and stage/reset/preview individual hunks." ) .addToggle((toggle) => toggle .setValue(plugin.settings.hunks.showSigns) .onChange(async (value) => { plugin.settings.hunks.showSigns = value; await plugin.saveSettings(); plugin.editorIntegration.refreshSignsSettings(); }) ); new Setting(containerEl) .setName("Hunk commands") .setDesc( "Adds commands to stage/reset individual Git diff hunks and navigate between them via 'Go to next/prev hunk' commands." ) .addToggle((toggle) => toggle .setValue(plugin.settings.hunks.hunkCommands) .onChange(async (value) => { plugin.settings.hunks.hunkCommands = value; await plugin.saveSettings(); plugin.editorIntegration.refreshSignsSettings(); }) ); new Setting(containerEl) .setName("Status bar with summary of line changes") .addDropdown((toggle) => toggle .addOptions({ disabled: "Disabled", colored: "Colored", monochrome: "Monochrome", }) .setValue(plugin.settings.hunks.statusBar) .onChange( async ( option: ObsidianGitSettings["hunks"]["statusBar"] ) => { plugin.settings.hunks.statusBar = option; await plugin.saveSettings(); plugin.editorIntegration.refreshSignsSettings(); } ) ); new Setting(containerEl) .setName("Line author information") .setHeading(); this.addLineAuthorInfoSettings(); } } new Setting(containerEl).setName("History view").setHeading(); new Setting(containerEl) .setName("Show Author") .setDesc("Show the author of the commit in the history view.") .addDropdown((dropdown) => { const options: Record = { hide: "Hide", full: "Full", initials: "Initials", }; dropdown.addOptions(options); dropdown.setValue(plugin.settings.authorInHistoryView); dropdown.onChange(async (option: ShowAuthorInHistoryView) => { plugin.settings.authorInHistoryView = option; await plugin.saveSettings(); await plugin.refresh(); }); }); new Setting(containerEl) .setName("Show Date") .setDesc( "Show the date of the commit in the history view. The {{date}} placeholder format is used to display the date." ) .addToggle((toggle) => toggle .setValue(plugin.settings.dateInHistoryView) .onChange(async (value) => { plugin.settings.dateInHistoryView = value; await plugin.saveSettings(); await plugin.refresh(); }) ); new Setting(containerEl).setName("Source control view").setHeading(); new Setting(containerEl) .setName( "Automatically refresh source control view on file changes" ) .setDesc( "On slower machines this may cause lags. If so, just disable this option." ) .addToggle((toggle) => toggle .setValue(plugin.settings.refreshSourceControl) .onChange(async (value) => { plugin.settings.refreshSourceControl = value; await plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Source control view refresh interval") .setDesc( "Milliseconds to wait after file change before refreshing the Source Control View." ) .addText((text) => { const MIN_SOURCE_CONTROL_REFRESH_INTERVAL = 500; text.inputEl.type = "number"; this.setNonDefaultValue({ text, settingsProperty: "refreshSourceControlTimer", }); text.setPlaceholder( String(DEFAULT_SETTINGS.refreshSourceControlTimer) ); text.onChange(async (value) => { // 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. if (value !== "" && Number.isInteger(Number(value))) { plugin.settings.refreshSourceControlTimer = Math.max( Number(value), MIN_SOURCE_CONTROL_REFRESH_INTERVAL ); } else { plugin.settings.refreshSourceControlTimer = DEFAULT_SETTINGS.refreshSourceControlTimer; } await plugin.saveSettings(); plugin.setRefreshDebouncer(); }); }); new Setting(containerEl).setName("Miscellaneous").setHeading(); if (plugin.gitManager instanceof SimpleGit) { new Setting(containerEl) .setName("Diff view style") .setDesc( '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.' ) .addDropdown((dropdown) => { const options: Record< ObsidianGitSettings["diffStyle"], string > = { split: "Split", git_unified: "Unified", }; dropdown.addOptions(options); dropdown.setValue(plugin.settings.diffStyle); dropdown.onChange( async (option: ObsidianGitSettings["diffStyle"]) => { plugin.settings.diffStyle = option; await plugin.saveSettings(); } ); }); } new Setting(containerEl) .setName("Disable informative notifications") .setDesc( "Disable informative notifications for git operations to minimize distraction (refer to status bar for updates)." ) .addToggle((toggle) => toggle .setValue(plugin.settings.disablePopups) .onChange(async (value) => { plugin.settings.disablePopups = value; this.refreshDisplayWithDelay(); await plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Disable error notifications") .setDesc( "Disable error notifications of any kind to minimize distraction (refer to status bar for updates)." ) .addToggle((toggle) => toggle .setValue(!plugin.settings.showErrorNotices) .onChange(async (value) => { plugin.settings.showErrorNotices = !value; await plugin.saveSettings(); }) ); if (!plugin.settings.disablePopups) new Setting(containerEl) .setName("Hide notifications for no changes") .setDesc( "Don't show notifications when there are no changes to commit or push." ) .addToggle((toggle) => toggle .setValue(plugin.settings.disablePopupsForNoChanges) .onChange(async (value) => { plugin.settings.disablePopupsForNoChanges = value; await plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Show status bar") .setDesc( "Obsidian must be restarted for the changes to take affect." ) .addToggle((toggle) => toggle .setValue(plugin.settings.showStatusBar) .onChange(async (value) => { plugin.settings.showStatusBar = value; await plugin.saveSettings(); }) ); new Setting(containerEl) .setName("File menu integration") .setDesc( `Add "Stage", "Unstage" and "Add to .gitignore" actions to the file menu.` ) .addToggle((toggle) => toggle .setValue(plugin.settings.showFileMenu) .onChange(async (value) => { plugin.settings.showFileMenu = value; await plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Show branch status bar") .setDesc( "Obsidian must be restarted for the changes to take affect." ) .addToggle((toggle) => toggle .setValue(plugin.settings.showBranchStatusBar) .onChange(async (value) => { plugin.settings.showBranchStatusBar = value; await plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Show the count of modified files in the status bar") .addToggle((toggle) => toggle .setValue(plugin.settings.changedFilesInStatusBar) .onChange(async (value) => { plugin.settings.changedFilesInStatusBar = value; await plugin.saveSettings(); }) ); if (plugin.gitManager instanceof IsomorphicGit) { new Setting(containerEl) .setName("Authentication/commit author") .setHeading(); } else { new Setting(containerEl).setName("Commit author").setHeading(); } if (plugin.gitManager instanceof IsomorphicGit) new Setting(containerEl) .setName( "Username on your git server. E.g. your username on GitHub" ) .addText((cb) => { cb.setValue(plugin.localStorage.getUsername() ?? ""); cb.onChange((value) => { plugin.localStorage.setUsername(value); }); }); if (plugin.gitManager instanceof IsomorphicGit) new Setting(containerEl) .setName("Password/Personal access token") .setDesc( "Type in your password. You won't be able to see it again." ) .addText((cb) => { cb.inputEl.autocapitalize = "off"; cb.inputEl.autocomplete = "off"; cb.inputEl.spellcheck = false; cb.onChange((value) => { plugin.localStorage.setPassword(value); }); }); if (plugin.gitReady) new Setting(containerEl) .setName("Author name for commit") .addText(async (cb) => { cb.setValue( (await plugin.gitManager.getConfig("user.name")) ?? "" ); cb.onChange(async (value) => { await plugin.gitManager.setConfig( "user.name", value == "" ? undefined : value ); }); }); if (plugin.gitReady) new Setting(containerEl) .setName("Author email for commit") .addText(async (cb) => { cb.setValue( (await plugin.gitManager.getConfig("user.email")) ?? "" ); cb.onChange(async (value) => { await plugin.gitManager.setConfig( "user.email", value == "" ? undefined : value ); }); }); new Setting(containerEl) .setName("Advanced") .setDesc( "These settings usually don't need to be changed, but may be required for special setups." ) .setHeading(); if (plugin.gitManager instanceof SimpleGit) { new Setting(containerEl) .setName("Update submodules") .setDesc( '"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.' ) .addToggle((toggle) => toggle .setValue(plugin.settings.updateSubmodules) .onChange(async (value) => { plugin.settings.updateSubmodules = value; await plugin.saveSettings(); }) ); if (plugin.settings.updateSubmodules) { new Setting(containerEl) .setName("Submodule recurse checkout/switch") .setDesc( "Whenever a checkout happens on the root repository, recurse the checkout on the submodules (if the branches exist)." ) .addToggle((toggle) => toggle .setValue(plugin.settings.submoduleRecurseCheckout) .onChange(async (value) => { plugin.settings.submoduleRecurseCheckout = value; await plugin.saveSettings(); }) ); } } if (plugin.gitManager instanceof SimpleGit) new Setting(containerEl) .setName("Custom Git binary path") .setDesc( "Specify the path to the Git binary/executable. Git should already be in your PATH. Should only be necessary for a custom Git installation." ) .addText((cb) => { cb.setValue(plugin.localStorage.getGitPath() ?? ""); cb.setPlaceholder("git"); cb.onChange((value) => { plugin.localStorage.setGitPath(value); plugin.gitManager .updateGitPath(value || "git") .catch((e) => plugin.displayError(e)); }); }); if (plugin.gitManager instanceof SimpleGit) new Setting(containerEl) .setName("Additional environment variables") .setDesc( "Use each line for a new environment variable in the format KEY=VALUE ." ) .addTextArea((cb) => { cb.setPlaceholder("GIT_DIR=/path/to/git/dir"); cb.setValue(plugin.localStorage.getEnvVars().join("\n")); cb.onChange((value) => { plugin.localStorage.setEnvVars(value.split("\n")); }); }); if (plugin.gitManager instanceof SimpleGit) new Setting(containerEl) .setName("Additional PATH environment variable paths") .setDesc("Use each line for one path") .addTextArea((cb) => { cb.setValue(plugin.localStorage.getPATHPaths().join("\n")); cb.onChange((value) => { plugin.localStorage.setPATHPaths(value.split("\n")); }); }); if (plugin.gitManager instanceof SimpleGit) new Setting(containerEl) .setName("Reload with new environment variables") .setDesc( "Removing previously added environment variables will not take effect until Obsidian is restarted." ) .addButton((cb) => { cb.setButtonText("Reload"); cb.setCta(); cb.onClick(async () => { await (plugin.gitManager as SimpleGit).setGitInstance(); }); }); new Setting(containerEl) .setName("Custom base path (Git repository path)") .setDesc( ` Sets the relative path to the vault from which the Git binary should be executed. 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. ` ) .addText((cb) => { cb.setValue(plugin.settings.basePath); cb.setPlaceholder("directory/directory-with-git-repo"); cb.onChange(async (value) => { plugin.settings.basePath = value; await plugin.saveSettings(); plugin.gitManager .updateBasePath(value || "") .catch((e) => plugin.displayError(e)); }); }); new Setting(containerEl) .setName("Custom Git directory path (Instead of '.git')") .setDesc( `Corresponds to the GIT_DIR environment variable. Requires restart of Obsidian to take effect. Use "\\" instead of "/" on Windows.` ) .addText((cb) => { cb.setValue(plugin.settings.gitDir); cb.setPlaceholder(".git"); cb.onChange(async (value) => { plugin.settings.gitDir = value; await plugin.saveSettings(); }); }); new Setting(containerEl) .setName("Disable on this device") .setDesc( "Disables the plugin on this device. This setting is not synced." ) .addToggle((toggle) => toggle .setValue(plugin.localStorage.getPluginDisabled()) .onChange((value) => { plugin.localStorage.setPluginDisabled(value); if (value) { plugin.unloadPlugin(); } else { plugin .init({ fromReload: true }) .catch((e) => plugin.displayError(e)); } new Notice( "Obsidian must be restarted for the changes to take affect." ); }) ); new Setting(containerEl).setName("Support").setHeading(); new Setting(containerEl) .setName("Donate") .setDesc( "If you like this Plugin, consider donating to support continued development." ) .addButton((bt) => { bt.buttonEl.outerHTML = "Buy Me a Coffee at ko-fi.com"; }); const debugDiv = containerEl.createDiv(); debugDiv.setAttr("align", "center"); debugDiv.setAttr("style", "margin: var(--size-4-2)"); const debugButton = debugDiv.createEl("button"); debugButton.setText("Copy Debug Information"); debugButton.onclick = async () => { await window.navigator.clipboard.writeText( JSON.stringify( { settings: this.plugin.settings, pluginVersion: this.plugin.manifest.version, }, null, 4 ) ); new Notice( "Debug information copied to clipboard. May contain sensitive information!" ); }; if (Platform.isDesktopApp) { const info = containerEl.createDiv(); info.setAttr("align", "center"); info.setText( "Debugging and logging:\nYou can always see the logs of this and every other plugin by opening the console with" ); const keys = containerEl.createDiv(); keys.setAttr("align", "center"); keys.addClass("obsidian-git-shortcuts"); if (Platform.isMacOS === true) { keys.createEl("kbd", { text: "CMD (⌘) + OPTION (⌥) + I" }); } else { keys.createEl("kbd", { text: "CTRL + SHIFT + I" }); } } } mayDisableSetting(setting: Setting, disable: boolean) { if (disable) { setting.setDisabled(disable); setting.setClass("obsidian-git-disabled"); } } public configureLineAuthorShowStatus(show: boolean) { this.settings.lineAuthor.show = show; void this.plugin.saveSettings(); if (show) this.plugin.editorIntegration.activateLineAuthoring(); else this.plugin.editorIntegration.deactiveLineAuthoring(); } /** * Persists the setting {@link key} with value {@link value} and * refreshes the line author info views. */ public async lineAuthorSettingHandler< K extends keyof ObsidianGitSettings["lineAuthor"], >(key: K, value: ObsidianGitSettings["lineAuthor"][K]): Promise { this.settings.lineAuthor[key] = value; await this.plugin.saveSettings(); this.plugin.editorIntegration.lineAuthoringFeature.refreshLineAuthorViews(); } /** * Ensure, that certain last shown values are persistent in the settings. * * Necessary for the line author info gutter context menus. */ public beforeSaveSettings() { const laSettings = this.settings.lineAuthor; if (laSettings.authorDisplay !== "hide") { laSettings.lastShownAuthorDisplay = laSettings.authorDisplay; } if (laSettings.dateTimeFormatOptions !== "hide") { laSettings.lastShownDateTimeFormatOptions = laSettings.dateTimeFormatOptions; } } private addLineAuthorInfoSettings() { const baseLineAuthorInfoSetting = new Setting(this.containerEl).setName( "Show commit authoring information next to each line" ); if ( !this.plugin.editorIntegration.lineAuthoringFeature.isAvailableOnCurrentPlatform() ) { baseLineAuthorInfoSetting .setDesc("Only available on desktop currently.") .setDisabled(true); } baseLineAuthorInfoSetting.descEl.innerHTML = ` Feature guide and quick examples
The commit hash, author name and authoring date can all be individually toggled.
Hide everything, to only show the age-colored sidebar.`; baseLineAuthorInfoSetting.addToggle((toggle) => toggle.setValue(this.settings.lineAuthor.show).onChange((value) => { this.configureLineAuthorShowStatus(value); this.refreshDisplayWithDelay(); }) ); if (this.settings.lineAuthor.show) { const trackMovement = new Setting(this.containerEl) .setName("Follow movement and copies across files and commits") .setDesc("") .addDropdown((dropdown) => { dropdown.addOptions(< Record >{ inactive: "Do not follow (default)", "same-commit": "Follow within same commit", "all-commits": "Follow within all commits (maybe slow)", }); dropdown.setValue(this.settings.lineAuthor.followMovement); dropdown.onChange((value: LineAuthorFollowMovement) => this.lineAuthorSettingHandler("followMovement", value) ); }); trackMovement.descEl.innerHTML = ` By default (deactivated), each line only shows the newest commit where it was changed.
With same commit, cut-copy-paste-ing of text is followed within the same commit and the original commit of authoring will be shown.
With all commits, cut-copy-paste-ing text inbetween multiple commits will be detected.
It uses git-blame and for matches (at least ${GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH} characters) within the same (or all) commit(s), the originating commit's information is shown.`; new Setting(this.containerEl) .setName("Show commit hash") .addToggle((tgl) => { tgl.setValue(this.settings.lineAuthor.showCommitHash); tgl.onChange((value: boolean) => this.lineAuthorSettingHandler("showCommitHash", value) ); }); new Setting(this.containerEl) .setName("Author name display") .setDesc("If and how the author is displayed") .addDropdown((dropdown) => { const options: Record = { hide: "Hide", initials: "Initials (default)", "first name": "First name", "last name": "Last name", full: "Full name", }; dropdown.addOptions(options); dropdown.setValue(this.settings.lineAuthor.authorDisplay); dropdown.onChange(async (value: LineAuthorDisplay) => this.lineAuthorSettingHandler("authorDisplay", value) ); }); new Setting(this.containerEl) .setName("Authoring date display") .setDesc( "If and how the date and time of authoring the line is displayed" ) .addDropdown((dropdown) => { const options: Record< LineAuthorDateTimeFormatOptions, string > = { hide: "Hide", date: "Date (default)", datetime: "Date and time", "natural language": "Natural language", custom: "Custom", }; dropdown.addOptions(options); dropdown.setValue( this.settings.lineAuthor.dateTimeFormatOptions ); dropdown.onChange( async (value: LineAuthorDateTimeFormatOptions) => { await this.lineAuthorSettingHandler( "dateTimeFormatOptions", value ); this.refreshDisplayWithDelay(); } ); }); if (this.settings.lineAuthor.dateTimeFormatOptions === "custom") { const dateTimeFormatCustomStringSetting = new Setting( this.containerEl ); dateTimeFormatCustomStringSetting .setName("Custom authoring date format") .addText((cb) => { cb.setValue( this.settings.lineAuthor.dateTimeFormatCustomString ); cb.setPlaceholder("YYYY-MM-DD HH:mm"); cb.onChange(async (value) => { await this.lineAuthorSettingHandler( "dateTimeFormatCustomString", value ); dateTimeFormatCustomStringSetting.descEl.innerHTML = this.previewCustomDateTimeDescriptionHtml( value ); }); }); dateTimeFormatCustomStringSetting.descEl.innerHTML = this.previewCustomDateTimeDescriptionHtml( this.settings.lineAuthor.dateTimeFormatCustomString ); } new Setting(this.containerEl) .setName("Authoring date display timezone") .addDropdown((dropdown) => { const options: Record = { "viewer-local": "My local (default)", "author-local": "Author's local", utc0000: "UTC+0000/Z", }; dropdown.addOptions(options); dropdown.setValue( this.settings.lineAuthor.dateTimeTimezone ); dropdown.onChange(async (value: LineAuthorTimezoneOption) => this.lineAuthorSettingHandler("dateTimeTimezone", value) ); }).descEl.innerHTML = ` The time-zone in which the authoring date should be shown. Either your local time-zone (default), the author's time-zone during commit creation or UTC±00:00. `; const oldestAgeSetting = new Setting(this.containerEl).setName( "Oldest age in coloring" ); oldestAgeSetting.descEl.innerHTML = this.previewOldestAgeDescriptionHtml( this.settings.lineAuthor.coloringMaxAge )[0]; oldestAgeSetting.addText((text) => { text.setPlaceholder("1y"); text.setValue(this.settings.lineAuthor.coloringMaxAge); text.onChange(async (value) => { const [preview, valid] = this.previewOldestAgeDescriptionHtml(value); oldestAgeSetting.descEl.innerHTML = preview; if (valid) { await this.lineAuthorSettingHandler( "coloringMaxAge", value ); this.refreshColorSettingsName("oldest"); } }); }); this.createColorSetting("newest"); this.createColorSetting("oldest"); new Setting(this.containerEl) .setName("Text color") .addText((field) => { field.setValue(this.settings.lineAuthor.textColorCss); field.onChange(async (value) => { await this.lineAuthorSettingHandler( "textColorCss", value ); }); }).descEl.innerHTML = ` The CSS color of the gutter text.
It is highly recommended to use CSS variables defined by themes (e.g.
var(--text-muted)
or
var(--text-on-accent)
, because they automatically adapt to theme changes.
See: List of available CSS variables in Obsidian `; new Setting(this.containerEl) .setName("Ignore whitespace and newlines in changes") .addToggle((tgl) => { tgl.setValue(this.settings.lineAuthor.ignoreWhitespace); tgl.onChange((value) => this.lineAuthorSettingHandler("ignoreWhitespace", value) ); }).descEl.innerHTML = ` Whitespace and newlines are interpreted as part of the document and in changes by default (hence not ignored). This makes the last line being shown as 'changed' when a new subsequent line is added, even if the previously last line's text is the same.
If you don't care about purely-whitespace changes (e.g. list nesting / quote indentation changes), then activating this will provide more meaningful change detection. `; } } private createColorSetting(which: "oldest" | "newest") { const setting = new Setting(this.containerEl) .setName("") .addText((text) => { const color = pickColor(which, this.settings.lineAuthor); const defaultColor = pickColor( which, DEFAULT_SETTINGS.lineAuthor ); text.setPlaceholder(rgbToString(defaultColor)); text.setValue(rgbToString(color)); text.onChange(async (colorNew) => { const rgb = convertToRgb(colorNew); if (rgb !== undefined) { const key = which === "newest" ? "colorNew" : "colorOld"; await this.lineAuthorSettingHandler(key, rgb); } this.refreshColorSettingsDesc(which, rgb); }); }); this.lineAuthorColorSettings.set(which, setting); this.refreshColorSettingsName(which); this.refreshColorSettingsDesc( which, pickColor(which, this.settings.lineAuthor) ); } private refreshColorSettingsName(which: "oldest" | "newest") { const settingsDom = this.lineAuthorColorSettings.get(which); if (settingsDom) { const whichDescriber = which === "oldest" ? `oldest (${this.settings.lineAuthor.coloringMaxAge} or older)` : "newest"; settingsDom.nameEl.innerText = `Color for ${whichDescriber} commits`; } } private refreshColorSettingsDesc(which: "oldest" | "newest", rgb?: RGB) { const settingsDom = this.lineAuthorColorSettings.get(which); if (settingsDom) { settingsDom.descEl.innerHTML = this.colorSettingPreviewDescHtml( which, this.settings.lineAuthor, rgb !== undefined ); } } private colorSettingPreviewDescHtml( which: "oldest" | "newest", laSettings: LineAuthorSettings, colorIsValid: boolean ): string { const rgbStr = colorIsValid ? previewColor(which, laSettings) : `rgba(127,127,127,0.3)`; const today = moment.unix(moment.now() / 1000).format("YYYY-MM-DD"); const text = colorIsValid ? `abcdef Author Name ${today}` : "invalid color"; const preview = `
${text}
`; return `Supports 'rgb(r,g,b)', 'hsl(h,s,l)', hex (#) and named colors (e.g. 'black', 'purple'). Color preview: ${preview}`; } private previewCustomDateTimeDescriptionHtml( dateTimeFormatCustomString: string ) { const formattedDateTime = moment().format(dateTimeFormatCustomString); return `
Format string to display the authoring date.
Currently: ${formattedDateTime}`; } private previewOldestAgeDescriptionHtml(coloringMaxAge: string) { const duration = parseColoringMaxAgeDuration(coloringMaxAge); const durationString = duration !== undefined ? `${duration.asDays()} days` : "invalid!"; return [ `The oldest age in the line author coloring. Everything older will have the same color.
Smallest valid age is "1d". Currently: ${durationString}`, duration, ] as const; } /** * Sets the value in the textbox for a given setting only if the saved value differs from the default value. * 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. */ private setNonDefaultValue({ settingsProperty, text, }: { settingsProperty: keyof ObsidianGitSettings; text: TextComponent | TextAreaComponent; }): void { const storedValue = this.plugin.settings[settingsProperty]; const defaultValue = DEFAULT_SETTINGS[settingsProperty]; if (defaultValue !== storedValue) { // Doesn't add "" to saved strings if ( typeof storedValue === "string" || typeof storedValue === "number" || typeof storedValue === "boolean" ) { text.setValue(String(storedValue)); } else { text.setValue(JSON.stringify(storedValue)); } } } /** * Delays the update of the settings UI. * Used when the user toggles one of the settings that control enabled states of other settings. Delaying the update * allows most of the toggle animation to run, instead of abruptly jumping between enabled/disabled states. */ private refreshDisplayWithDelay(timeout = 80): void { setTimeout(() => this.display(), timeout); } } export function pickColor( which: "oldest" | "newest", las: LineAuthorSettings ): RGB { return which === "oldest" ? las.colorOld : las.colorNew; } export function parseColoringMaxAgeDuration( durationString: string ): moment.Duration | undefined { // https://momentjs.com/docs/#/durations/creating/ const duration = moment.duration("P" + durationString.toUpperCase()); return duration.isValid() && duration.asDays() && duration.asDays() >= 1 ? duration : undefined; } ================================================ FILE: src/statusBar.ts ================================================ import { setIcon, moment } from "obsidian"; import type ObsidianGit from "./main"; import { CurrentGitAction } from "./types"; interface StatusBarMessage { message: string; timeout: number; } export class StatusBar { private messages: StatusBarMessage[] = []; private currentMessage: StatusBarMessage | null; private lastCommitTimestamp?: Date; private unPushedCommits?: number; public lastMessageTimestamp: number | null; private base = "obsidian-git-statusbar-"; private iconEl: HTMLElement; private conflictEl: HTMLElement; private pausedEl: HTMLElement; private textEl: HTMLElement; constructor( private statusBarEl: HTMLElement, private readonly plugin: ObsidianGit ) { this.statusBarEl.setAttribute("data-tooltip-position", "top"); plugin.registerEvent( plugin.app.workspace.on("obsidian-git:refreshed", () => { this.refreshCommitTimestamp().catch(console.error); }) ); } public displayMessage(message: string, timeout: number) { this.messages.push({ message: `Git: ${message.slice(0, 100)}`, timeout: timeout, }); this.display(); } public display() { if (this.messages.length > 0 && !this.currentMessage) { this.currentMessage = this.messages.shift() as StatusBarMessage; this.statusBarEl.addClass(this.base + "message"); this.statusBarEl.ariaLabel = ""; this.statusBarEl.setText(this.currentMessage.message); this.lastMessageTimestamp = Date.now(); } else if (this.currentMessage) { const messageAge = Date.now() - (this.lastMessageTimestamp as number); if (messageAge >= this.currentMessage.timeout) { this.currentMessage = null; this.lastMessageTimestamp = null; } } else { this.displayState(); } } private displayState() { //Messages have to be removed before the state is set if ( this.statusBarEl.getText().length > 3 || !this.statusBarEl.hasChildNodes() ) { this.statusBarEl.empty(); this.conflictEl = this.statusBarEl.createDiv(); this.conflictEl.setAttribute("data-tooltip-position", "top"); this.conflictEl.style.float = "left"; this.pausedEl = this.statusBarEl.createDiv(); this.pausedEl.setAttribute("data-tooltip-position", "top"); this.pausedEl.style.float = "left"; this.iconEl = this.statusBarEl.createDiv(); this.iconEl.style.float = "left"; this.textEl = this.statusBarEl.createDiv(); this.textEl.style.float = "right"; this.textEl.style.marginLeft = "5px"; } if (this.plugin.localStorage.getConflict()) { setIcon(this.conflictEl, "alert-circle"); this.conflictEl.ariaLabel = "You have merge conflicts. Resolve them and commit afterwards."; this.conflictEl.style.marginRight = "5px"; this.conflictEl.addClass(this.base + "conflict"); } else { this.conflictEl.empty(); this.conflictEl.style.marginRight = ""; } if (this.plugin.localStorage.getPausedAutomatics()) { setIcon(this.pausedEl, "pause-circle"); this.pausedEl.ariaLabel = "Automatic routines are currently paused."; this.pausedEl.style.marginRight = "5px"; this.pausedEl.addClass(this.base + "paused"); } else { this.pausedEl.empty(); this.pausedEl.style.marginRight = ""; } switch (this.plugin.state.gitAction) { case CurrentGitAction.idle: this.displayFromNow(); break; case CurrentGitAction.status: this.statusBarEl.ariaLabel = "Checking repository status..."; setIcon(this.iconEl, "refresh-cw"); this.statusBarEl.addClass(this.base + "status"); break; case CurrentGitAction.add: this.statusBarEl.ariaLabel = "Adding files..."; setIcon(this.iconEl, "archive"); this.statusBarEl.addClass(this.base + "add"); break; case CurrentGitAction.commit: this.statusBarEl.ariaLabel = "Committing changes..."; setIcon(this.iconEl, "git-commit"); this.statusBarEl.addClass(this.base + "commit"); break; case CurrentGitAction.push: this.statusBarEl.ariaLabel = "Pushing changes..."; setIcon(this.iconEl, "upload"); this.statusBarEl.addClass(this.base + "push"); break; case CurrentGitAction.pull: this.statusBarEl.ariaLabel = "Pulling changes..."; setIcon(this.iconEl, "download"); this.statusBarEl.addClass(this.base + "pull"); break; default: this.statusBarEl.ariaLabel = "Failed on initialization!"; setIcon(this.iconEl, "alert-triangle"); this.statusBarEl.addClass(this.base + "failed-init"); break; } } private displayFromNow(): void { const timestamp = this.lastCommitTimestamp; const offlineMode = this.plugin.state.offlineMode; if (timestamp) { const fromNow = moment(timestamp).fromNow(); this.statusBarEl.ariaLabel = `${ offlineMode ? "Offline: " : "" }Last Commit: ${fromNow}`; if (this.unPushedCommits ?? 0 > 0) { this.statusBarEl.ariaLabel += `\n(${this.unPushedCommits} unpushed commits)`; } } else { this.statusBarEl.ariaLabel = offlineMode ? "Git is offline" : "Git is ready"; } if (offlineMode) { setIcon(this.iconEl, "globe"); } else { setIcon(this.iconEl, "check"); } if ( this.plugin.settings.changedFilesInStatusBar && this.plugin.cachedStatus ) { this.textEl.setText( this.plugin.cachedStatus.changed.length.toString() ); } this.statusBarEl.addClass(this.base + "idle"); } private async refreshCommitTimestamp() { this.lastCommitTimestamp = await this.plugin.gitManager.getLastCommitTime(); this.unPushedCommits = await this.plugin.gitManager.getUnpushedCommits(); } public remove() { this.statusBarEl.remove(); } } ================================================ FILE: src/tools.ts ================================================ import { Notice, Platform, TFile } from "obsidian"; import { CONFLICT_OUTPUT_FILE, DIFF_VIEW_CONFIG, SPLIT_DIFF_VIEW_CONFIG, } from "./constants"; import type ObsidianGit from "./main"; import { SimpleGit } from "./gitManager/simpleGit"; import { getNewLeaf, splitRemoteBranch } from "./utils"; import { GeneralModal } from "./ui/modals/generalModal"; import type { DiffViewState } from "./types"; export default class Tools { constructor(private readonly plugin: ObsidianGit) {} async hasTooBigFiles( files: { vaultPath: string; path: string }[] ): Promise { const branchInfo = await this.plugin.gitManager.branchInfo(); const remote = branchInfo.tracking ? splitRemoteBranch(branchInfo.tracking)[0] : null; if (!remote) return false; const remoteUrl = await this.plugin.gitManager.getRemoteUrl(remote); //Check for files >100mb on GitHub remote if (remoteUrl?.includes("github.com")) { const tooBigFiles = []; const gitManager = this.plugin.gitManager; for (const f of files) { const file = this.plugin.app.vault.getAbstractFileByPath( f.vaultPath ); let over100mb = false; if (file instanceof TFile) { // Prefer the cached file size if available if (file.stat.size >= 100000000) { over100mb = true; } } else { const statRes = await this.plugin.app.vault.adapter.stat( f.vaultPath ); if (statRes && statRes.size >= 100000000) { over100mb = true; } } if (over100mb) { let isFileTrackedByLfs = false; if (gitManager instanceof SimpleGit) { isFileTrackedByLfs = await gitManager.isFileTrackedByLFS(f.path); } if (!isFileTrackedByLfs) { tooBigFiles.push(f); } } } if (tooBigFiles.length > 0) { this.plugin.displayError( `Aborted commit, because the following files are too big:\n- ${tooBigFiles .map((e) => e.vaultPath) .join( "\n- " )}\nPlease remove them or add to .gitignore.` ); return true; } } return false; } async writeAndOpenFile(text?: string) { if (text !== undefined) { await this.plugin.app.vault.adapter.write( CONFLICT_OUTPUT_FILE, text ); } let fileIsAlreadyOpened = false; this.plugin.app.workspace.iterateAllLeaves((leaf) => { if ( leaf.getDisplayText() != "" && CONFLICT_OUTPUT_FILE.startsWith(leaf.getDisplayText()) ) { fileIsAlreadyOpened = true; } }); if (!fileIsAlreadyOpened) { await this.plugin.app.workspace.openLinkText( CONFLICT_OUTPUT_FILE, "/", true ); } } openDiff({ aFile, bFile, aRef, bRef, event, }: { aFile: string; bFile?: string; aRef: string; bRef?: string; event?: MouseEvent; }) { let diffStyle = this.plugin.settings.diffStyle; if (Platform.isMobileApp) { diffStyle = "git_unified"; } const state: DiffViewState = { aFile: aFile, bFile: bFile ?? aFile, aRef: aRef, bRef: bRef, }; if (diffStyle == "split") { void getNewLeaf(this.plugin.app, event)?.setViewState({ type: SPLIT_DIFF_VIEW_CONFIG.type, active: true, state: state, }); } else if (diffStyle == "git_unified") { void getNewLeaf(this.plugin.app, event)?.setViewState({ type: DIFF_VIEW_CONFIG.type, active: true, state: state, }); } } async runRawCommand() { const gitManager = this.plugin.gitManager; if (!(gitManager instanceof SimpleGit)) { return; } const modal = new GeneralModal(this.plugin, { placeholder: "push origin master", allowEmpty: false, }); const command = await modal.openAndGetResult(); if (command === undefined) return; this.plugin.promiseQueue.addTask(async () => { const notice = new Notice(`Running '${command}'...`, 999_999); try { const res = await gitManager.rawCommand(command); if (res) { notice.setMessage(res); window.setTimeout(() => notice.hide(), 5000); } else { notice.hide(); } } catch (e) { notice.hide(); throw e; } }); } } ================================================ FILE: src/types.ts ================================================ import type { LineAuthorSettings } from "src/editor/lineAuthor/model"; export interface ObsidianGitSettings { commitMessage: string; autoCommitMessage: string; commitMessageScript: string; commitDateFormat: string; /** * Interval to either automatically commit-and-sync or just commit */ autoSaveInterval: number; autoPushInterval: number; autoPullInterval: number; autoPullOnBoot: boolean; autoCommitOnlyStaged: boolean; syncMethod: SyncMethod; mergeStrategy: MergeStrategy; /** * Whether to push on commit-and-sync */ disablePush: boolean; /** * Whether to pull on commit-and-sync */ pullBeforePush: boolean; /** * Whether messages from {@link ObsidianGit.displayMessage} should be shown */ disablePopups: boolean; /** * Whether messages from {@link ObsidianGit.displayError} should be shown */ showErrorNotices: boolean; disablePopupsForNoChanges: boolean; listChangedFilesInMessageBody: boolean; showStatusBar: boolean; updateSubmodules: boolean; submoduleRecurseCheckout: boolean; /** * @deprecated Using `localstorage` instead */ gitPath?: string; customMessageOnAutoBackup: boolean; autoBackupAfterFileChange: boolean; treeStructure: boolean; /** * @deprecated Using `localstorage` instead */ username?: string; differentIntervalCommitAndPush: boolean; changedFilesInStatusBar: boolean; /** * @deprecated Migrated to `syncMethod = 'merge'` */ mergeOnPull?: boolean; refreshSourceControl: boolean; basePath: string; showedMobileNotice: boolean; refreshSourceControlTimer: number; showBranchStatusBar: boolean; lineAuthor: LineAuthorSettings; setLastSaveToLastCommit: boolean; gitDir: string; showFileMenu: boolean; authorInHistoryView: ShowAuthorInHistoryView; dateInHistoryView: boolean; diffStyle: "git_unified" | "split"; hunks: { hunkCommands: boolean; showSigns: boolean; statusBar: "disabled" | "colored" | "monochrome"; }; } /** * Ensures, that nested values objects are correctly merged. */ export function mergeSettingsByPriority( low: Omit, high: ObsidianGitSettings ): ObsidianGitSettings { const lineAuthor = Object.assign({}, low.lineAuthor, high.lineAuthor); return Object.assign({}, low, high, { lineAuthor }); } export type SyncMethod = "rebase" | "merge" | "reset"; export type MergeStrategy = "none" | "ours" | "theirs"; export type ShowAuthorInHistoryView = "full" | "initials" | "hide"; export interface Author { name: string; email: string; } export interface Status { all: FileStatusResult[]; changed: FileStatusResult[]; staged: FileStatusResult[]; /* * Only available for `SimpleGit` gitManager */ conflicted: string[]; } export interface GitTimestamp { /** * The number of unix seconds since epoch time (UTC). */ epochSeconds: number; /** * The time zone, in which the commit was originally created. * This can be used to reconstruct the local time during creating time. */ tz: string; } export interface UserEmail { name: string; email: string; } export interface BlameCommit { hash: string; author?: UserEmail & GitTimestamp; committer?: UserEmail & GitTimestamp; previous?: { commitHash?: string; filename: string }; filename?: string; summary: string; isZeroCommit: boolean; // true, if hash is 000...000 } /** * See https://git-scm.com/docs/git-blame#_the_porcelain_format */ export interface Blame { commits: Map; /** * hashPerLine[i] is the commit hash where line i originates from * * The first element is always `undefined`, since line-numbers are 1-based. */ hashPerLine: string[]; /** * originalFileLineNrPerLine[i] contains the original files' line number from where line i * * The first element is always `undefined`, since line-numbers are 1-based.originated */ originalFileLineNrPerLine: number[]; /** * finalFileLineNrPerLine[i] contains the final files' line number from where line i originated * * The first element is always `undefined`, since line-numbers are 1-based. */ finalFileLineNrPerLine: number[]; /** * For each line i, which originates from a different commit than it's previous line, * groupSizePerStartingLine[i] contains the number of lines until either the next * group of lines or EOF is reached. */ groupSizePerStartingLine: Map; } /** * `index` and `working_dir` are each one-character codes, based off the git * status short format: git status --short * The following is from: https://www.git-scm.com/docs/git-status#_short_format * * The possible values are: * - ' ': unmodified * - M : modified * - T : file type changed * - A : added * - D : deleted * - R : renamed * - C : copied * - U : updated but unmerged * * index working_dir Meaning * ------------------------------------------------------------------------ * [AMD] not updated * M [ MTD] updated in index * T [ MTD] type changed in index * A [ MTD] added to index * D deleted from index * R [ MTD] renamed in index * C [ MTD] copied in index * [MTARC] index and work tree match * [ MTARC] M work tree changed since index * [ MTARC] T type changed in work tree since index * [ MTARC] D deleted in work tree * R renamed in work tree * C copied in work tree * D D unmerged, both deleted * A U unmerged, added by us * U D unmerged, deleted by them * U A unmerged, added by them * D U unmerged, deleted by us * A A unmerged, both added * U U unmerged, both modified * ? ? untracked * ! ! ignored * * * FileStatusResult is based off simple-git's FileStatusResult: * https://github.com/steveukx/git-js/blob/a569868d800a0d872e8fb1534bb0dceccff47a4f/typings/response.d.ts#L267 */ export interface FileStatusResult { path: string; vaultPath: string; from?: string; // First digit of the status code of the file, e.g. 'M' = modified. // Represents the status of the index if no merge conflicts, otherwise represents // status of one side of the merge. index: string; // Second digit of the status code of the file. Represents status of the working directory // if no merge conflicts, otherwise represents status of other side of a merge. workingDir: string; } export interface PluginState { offlineMode: boolean; gitAction: CurrentGitAction; } export enum CurrentGitAction { idle, status, pull, add, commit, push, } export interface LogEntry { hash: string; date: string; message: string; refs: string[]; body: string; diff: DiffEntry; author: { name: string; email: string; }; } export interface DiffEntry { changed: number; files: DiffFile[]; } export interface DiffFile { path: string; vaultPath: string; fromPath?: string; fromVaultPath?: string; hash: string; status: string; binary?: boolean; } export interface WalkDifference { path: string; type: "M" | "A" | "D"; } export type UnstagedFile = WalkDifference; export interface BranchInfo { current?: string; tracking?: string; branches: string[]; } export interface TreeItem { title: string; path: string; vaultPath: string; data?: T; children?: TreeItem[]; } export type RootTreeItem = TreeItem & { children: TreeItem[] }; export type StatusRootTreeItem = RootTreeItem; export type HistoryRootTreeItem = RootTreeItem; export type DiffViewState = { /** * The repo relative file path for a. * For diffing a renamed file, this is the old path. */ aFile: string; /** * The git ref to specify which state of that file should be shown. * An empty string refers to the index version of a file, so you have to specifically check against undefined. */ aRef: string; /** * The repo relative file path for b. */ bFile: string; /** * The git ref to specify which state of that file should be shown. * An empty string refers to the index version of a file, so you have to specifically check against undefined. * `undefined` stands for the working tree version. */ bRef?: string; }; export enum FileType { staged, changed, pulled, } export class NoNetworkError extends Error { constructor(public readonly originalError: string) { super("No network connection available"); } } declare module "obsidian" { interface App { loadLocalStorage(key: string): string | null; saveLocalStorage(key: string, value: string | undefined): void; openWithDefaultApp(path: string): void; getTheme(): "obsidian" | "moonstone"; viewRegistry: ViewRegistry; } interface View { titleEl: HTMLElement; inlineTitleEl: HTMLElement; } interface ViewRegistry { /** * PRIVATE API * * Returns the view type for the given extension if available. */ getTypeByExtension(extension: string): string; } interface Workspace { /** * Emitted when some git action has been completed and plugin has been refreshed */ on( name: "obsidian-git:refreshed", callback: () => void, ctx?: unknown ): EventRef; /** * Emitted when some git action has been completed and the plugin should refresh */ on( name: "obsidian-git:refresh", callback: () => void, ctx?: unknown ): EventRef; /** * Emitted when the plugin is currently loading a new cached status. */ on( name: "obsidian-git:loading-status", callback: () => void, ctx?: unknown ): EventRef; /** * Emitted when the HEAD changed. */ on( name: "obsidian-git:head-change", callback: () => void, ctx?: unknown ): EventRef; /** * Emitted when a new cached status is available. */ on( name: "obsidian-git:status-changed", callback: (status: Status) => void, ctx?: unknown ): EventRef; on( name: "obsidian-git:menu", callback: ( menu: Menu, path: string, source: string, leaf?: WorkspaceLeaf ) => unknown, ctx?: unknown ): EventRef; trigger(name: string, ...data: unknown[]): void; trigger(name: "obsidian-git:refreshed"): void; trigger(name: "obsidian-git:refresh"): void; trigger(name: "obsidian-git:loading-status"): void; trigger(name: "obsidian-git:head-change"): void; trigger(name: "obsidian-git:status-changed", status: Status): void; trigger( name: "obsidian-git:menu", menu: Menu, path: string, source: string, leaf?: WorkspaceLeaf ): void; } } ================================================ FILE: src/ui/diff/diffView.ts ================================================ import { html } from "diff2html"; import type { EventRef, ViewStateResult, WorkspaceLeaf } from "obsidian"; import { ItemView, Platform } from "obsidian"; import { DIFF_VIEW_CONFIG } from "src/constants"; import { SimpleGit } from "src/gitManager/simpleGit"; import type ObsidianGit from "src/main"; import type { DiffViewState } from "src/types"; export default class DiffView extends ItemView { parser: DOMParser; gettingDiff = false; state: DiffViewState; gitRefreshRef: EventRef; gitViewRefreshRef: EventRef; constructor( leaf: WorkspaceLeaf, private plugin: ObsidianGit ) { super(leaf); this.parser = new DOMParser(); this.navigation = true; this.contentEl.addClass("git-diff"); this.gitRefreshRef = this.app.workspace.on( "obsidian-git:status-changed", () => { this.refresh().catch(console.error); } ); } getViewType(): string { return DIFF_VIEW_CONFIG.type; } getDisplayText(): string { if (this.state?.bFile != null) { let fileName = this.state.bFile.split("/").last(); if (fileName?.endsWith(".md")) fileName = fileName.slice(0, -3); return `Diff: ${fileName}`; } return DIFF_VIEW_CONFIG.name; } getIcon(): string { return DIFF_VIEW_CONFIG.icon; } async setState(state: DiffViewState, _: ViewStateResult): Promise { this.state = state; if (Platform.isMobile) { //Update view title on mobile only to show the file name of the diff this.leaf.view.titleEl.textContent = this.getDisplayText(); } await this.refresh(); } getState(): Record { return this.state as unknown as Record; } onClose(): Promise { this.app.workspace.offref(this.gitRefreshRef); this.app.workspace.offref(this.gitViewRefreshRef); return super.onClose(); } async onOpen(): Promise { await this.refresh(); return super.onOpen(); } async refresh(): Promise { if (this.state?.bFile && !this.gettingDiff && this.plugin.gitManager) { this.gettingDiff = true; try { let diff = await this.plugin.gitManager.getDiffString( this.state.bFile, this.state.aRef == "HEAD", this.state.bRef ); this.contentEl.empty(); const vaultPath = this.plugin.gitManager.getRelativeVaultPath( this.state.bFile ); if (!diff) { if ( this.plugin.gitManager instanceof SimpleGit && (await this.plugin.gitManager.isTracked( this.state.bFile )) ) { // File is tracked but no changes diff = [ `--- ${this.state.aFile}`, `+++ ${this.state.bFile}`, "", ].join("\n"); } else if (await this.app.vault.adapter.exists(vaultPath)) { const content = await this.app.vault.adapter.read(vaultPath); const header = `--- /dev/null +++ ${this.state.bFile} @@ -0,0 +1,${content.split("\n").length} @@`; diff = [ ...header.split("\n"), ...content.split("\n").map((line) => `+${line}`), ].join("\n"); } } if (diff) { const diffEl = this.parser .parseFromString(html(diff), "text/html") .querySelector(".d2h-file-diff"); this.contentEl.append(diffEl!); } else { const div = this.contentEl.createDiv({ cls: "obsidian-git-center", }); div.createSpan({ text: "⚠️", attr: { style: "font-size: 2em" }, }); div.createEl("br"); div.createSpan({ text: "File not found: " + this.state.bFile, }); } } finally { this.gettingDiff = false; } } } } ================================================ FILE: src/ui/diff/splitDiffView.ts ================================================ import type { Debouncer, ViewStateResult, WorkspaceLeaf } from "obsidian"; import { debounce, ItemView, Platform, setIcon } from "obsidian"; import { SPLIT_DIFF_VIEW_CONFIG } from "src/constants"; import { SimpleGit } from "src/gitManager/simpleGit"; import type ObsidianGit from "src/main"; import type { DiffViewState } from "src/types"; import { history, indentWithTab, standardKeymap } from "@codemirror/commands"; import { getChunks, MergeView } from "@codemirror/merge"; import { highlightSelectionMatches, search } from "@codemirror/search"; import { EditorState, Transaction } from "@codemirror/state"; import { drawSelection, EditorView, keymap, lineNumbers, ViewPlugin, } from "@codemirror/view"; import { GitError } from "simple-git"; import { Hunks } from "src/editor/signs/hunks"; import { rawHunkFromChunk, rawHunksToHunks } from "src/editor/signs/diff"; // 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.` export default class SplitDiffView extends ItemView { refreshing = false; state: DiffViewState; intervalRef: number; mergeView: MergeView | undefined; fileSaveDebouncer: Debouncer<[string], void>; bIsEditable: boolean; /** * Prevent to load text from file if the modification event was caused by this instance */ ignoreNextModification = false; constructor( leaf: WorkspaceLeaf, private plugin: ObsidianGit ) { super(leaf); this.navigation = true; this.registerEvent( this.app.workspace.on("obsidian-git:status-changed", () => { if (!this.mergeView) { this.createMergeView().catch(console.error); } else { this.updateRefEditors().catch(console.error); } }) ); this.intervalRef = window.setInterval(() => { if (this.mergeView) { this.updateRefEditors().catch(console.error); } }, 30 * 1000); this.registerEvent( this.app.vault.on("modify", (file) => { if ( this.state.bRef == undefined && file.path === this.state.bFile ) { if (this.ignoreNextModification) { this.ignoreNextModification = false; } else { this.updateModifiableEditor().catch(console.error); } } }) ); this.registerEvent( this.app.vault.on("delete", (file) => { if ( this.state.bRef == undefined && file.path === this.state.bFile ) { // If the file got deleted, we need to recreate the view to make the editor read-only this.createMergeView().catch(console.error); } }) ); this.registerEvent( this.app.vault.on("create", (file) => { if ( this.state.bRef == undefined && file.path === this.state.bFile ) { // If the file got created, we need to recreate the view to make the editor editable this.createMergeView().catch(console.error); } }) ); this.registerEvent( this.app.vault.on("rename", (file, oldPath) => { if ( this.state.bRef == undefined && (file.path === this.state.bFile || oldPath === this.state.bFile) ) { // If the file got created, we need to recreate the view to make the editor editable this.createMergeView().catch(console.error); } }) ); this.fileSaveDebouncer = debounce( (data: string) => { const file = this.state.bFile; if (file) { this.ignoreNextModification = true; this.plugin.app.vault.adapter .write( this.plugin.gitManager.getRelativeVaultPath(file), data ) .catch((e) => this.plugin.displayError(e)); } }, 1000, false ); } getViewType(): string { return SPLIT_DIFF_VIEW_CONFIG.type; } getDisplayText(): string { if (this.state?.bFile != null) { let fileName = this.state.bFile.split("/").last(); if (fileName?.endsWith(".md")) fileName = fileName.slice(0, -3); let suffix: string; if (this.state.bRef == undefined) { suffix = " (Working Tree)"; } else if (this.state.bRef == "") { suffix = " (Index)"; } else { suffix = "(" + this.state.bRef.substring(0, 7) + ")"; } return `Diff: ${fileName} ${suffix}`; } return SPLIT_DIFF_VIEW_CONFIG.name; } getIcon(): string { return SPLIT_DIFF_VIEW_CONFIG.icon; } async setState(state: DiffViewState, _: ViewStateResult): Promise { this.state = state; if (Platform.isMobile) { //Update view title on mobile only to show the file name of the diff this.leaf.view.titleEl.textContent = this.getDisplayText(); } await super.setState(state, _); await this.createMergeView(); } getState(): Record { return this.state as unknown as Record; } onClose(): Promise { window.clearInterval(this.intervalRef); return super.onClose(); } async onOpen(): Promise { await this.createMergeView(); return super.onOpen(); } async gitShow(commitHash: string, file: string): Promise { try { return await (this.plugin.gitManager as SimpleGit).show( commitHash, file, false ); } catch (error) { if (error instanceof GitError) { if ( error.message.includes("does not exist") || error.message.includes("unknown revision or path") || error.message.includes("exists on disk, but not in") || error.message.includes("fatal: bad object") ) { // Occurs when trying to run diff with an object that's actually a nested respository if (error.message.includes("fatal: bad object")) { this.plugin.displayError(error.message); } // If the file does not exist in the commit, return an empty string return ""; } } throw error; } } async bShouldBeEditable(): Promise { if (this.state.bRef != undefined) { return false; } const bVaultPath = this.plugin.gitManager.getRelativeVaultPath( this.state.bFile ); return await this.app.vault.adapter.exists(bVaultPath); } async updateModifiableEditor() { if (!this.mergeView || this.refreshing) return; const bEditor = this.mergeView.b; this.refreshing = true; const newContent = await this.app.vault.adapter.read(this.state.bFile); if (newContent != bEditor.state.doc.toString()) { const transaction = bEditor.state.update({ changes: { from: 0, to: bEditor.state.doc.length, insert: newContent, }, // The remote annotation is used to mark that change as external // so the new state is not written back to the file, because it // just came from the file system annotations: [Transaction.remote.of(true)], }); bEditor.dispatch(transaction); } this.refreshing = false; } /** * Only update the editors which show a file state of some git ref ike HEAD or index and not the current working tree. * So only the non editable editors. */ async updateRefEditors() { if (!this.mergeView || this.refreshing) return; const aEditor = this.mergeView.a; const bEditor = this.mergeView.b; this.refreshing = true; const aText = await this.gitShow(this.state.aRef, this.state.aFile); let bText: string | undefined; if (this.state.bRef != undefined) { bText = await this.gitShow(this.state.bRef, this.state.bFile); } if (aText != aEditor.state.doc.toString()) { const aTransaction = aEditor.state.update({ changes: { from: 0, to: aEditor.state.doc.length, insert: aText, }, }); aEditor.dispatch(aTransaction); } if (bText != undefined && bText != bEditor.state.doc.toString()) { const bTransaction = bEditor.state.update({ changes: { from: 0, to: bEditor.state.doc.length, insert: bText, }, }); bEditor.dispatch(bTransaction); } this.refreshing = false; } renderButtons(): HTMLElement { const contentEl = document.createElement("div"); const stageButton = contentEl.createDiv(); stageButton.addClass("clickable-icon"); stageButton.setAttr( "aria-label", this.state.bRef == undefined ? "Stage hunk" : "Unstage hunk" ); setIcon(stageButton, this.state.bRef == undefined ? "plus" : "minus"); stageButton.onmousedown = async (_) => { const bEditor = this.mergeView!.b; const aEditor = this.mergeView!.a; const chunks = getChunks(bEditor.state)!; const index = contentEl.parentElement?.indexOf(contentEl); const chunk = chunks.chunks[index!]; const rawHunk = rawHunkFromChunk( chunk, aEditor.state.doc, bEditor.state.doc ); const hunk = rawHunksToHunks( this.mergeView!.a.state.doc.toString(), this.mergeView!.b.state.doc.toString(), [rawHunk] )[0]; const patch = Hunks.createPatch( this.state.bFile, [hunk], "100644", this.state.bRef != undefined ).join("\n") + "\n"; await (this.plugin.gitManager as SimpleGit).applyPatch(patch); this.plugin.app.workspace.trigger("obsidian-git:refresh"); }; if (this.state.bRef == undefined) { const resetButton = contentEl.createDiv(); resetButton.addClass("clickable-icon"); resetButton.setAttr("aria-label", "Reset hunk"); setIcon(resetButton, "undo"); resetButton.onmousedown = (_) => { const source = this.mergeView!.a; const dest = this.mergeView!.b; const chunks = getChunks(dest.state)!; const index = contentEl.parentElement?.indexOf(contentEl); const chunk = chunks.chunks[index!]; if (chunk) { const srcFrom = chunk.fromA; const srcTo = chunk.toA; const destFrom = chunk.fromB; const destTo = chunk.toB; let insert = source.state.sliceDoc( srcFrom, Math.max(srcFrom, srcTo - 1) ); if (srcFrom != srcTo && destTo <= dest.state.doc.length) insert += source.state.lineBreak; dest.dispatch({ changes: { from: destFrom, to: Math.min(dest.state.doc.length, destTo), insert, }, userEvent: "revert", }); } }; } // Prevent the default revert behavior by codemirror to apply contentEl.onmousedown = (event) => { event.preventDefault(); event.stopPropagation(); }; return contentEl; } async createMergeView() { if ( this.state?.aFile && this.state?.bFile && !this.refreshing && this.plugin.gitManager ) { this.refreshing = true; // cleanup this.mergeView?.destroy(); const container = this.containerEl.children[1]; container.empty(); // new this.contentEl.addClass("git-split-diff-view", "git-diff"); this.bIsEditable = await this.bShouldBeEditable(); const aText = await this.gitShow(this.state.aRef, this.state.aFile); let bText: string; if (this.state.bRef != undefined) { bText = await this.gitShow(this.state.bRef, this.state.bFile); } else { const bVaultPath = this.plugin.gitManager.getRelativeVaultPath( this.state.bFile ); if (await this.app.vault.adapter.exists(bVaultPath)) { bText = await this.app.vault.adapter.read(bVaultPath); } else { bText = ""; } } const basicExtensions = [ lineNumbers(), highlightSelectionMatches(), drawSelection(), keymap.of([...standardKeymap, indentWithTab]), history(), search(), EditorView.lineWrapping, ]; // eslint-disable-next-line @typescript-eslint/no-this-alias const myView = this; const autoSavePlugin = ViewPlugin.define((view) => ({ update(update) { if ( update.docChanged && !update.transactions.some((tr) => tr.annotation(Transaction.remote) ) ) { const lhsContent = view.state.doc.toString(); myView.fileSaveDebouncer(lhsContent); } }, })); const aState = { doc: aText, extensions: [ ...basicExtensions, EditorView.editable.of(false), EditorState.readOnly.of(true), ], }; const bExtensions = [...basicExtensions]; // Only make the editor modifiable when viewing the working tree version if (!this.bIsEditable) { bExtensions.push( EditorView.editable.of(false), EditorState.readOnly.of(true) ); } else { bExtensions.push(autoSavePlugin); } const bState = { doc: bText, extensions: bExtensions, }; container.addClasses([ "cm-s-obsidian", "mod-cm6", "markdown-source-view", "cm-content", ]); const showButtons = this.plugin.gitManager instanceof SimpleGit && (this.state.bRef === undefined || this.state.bRef === ""); this.mergeView = new MergeView({ b: bState, a: aState, collapseUnchanged: { minSize: 6, margin: 4, }, renderRevertControl: showButtons ? () => this.renderButtons() : undefined, revertControls: showButtons ? "a-to-b" : undefined, diffConfig: { scanLimit: this.bIsEditable ? 1000 : 10000, // default is 500 }, parent: container, }); this.refreshing = false; } } } ================================================ FILE: src/ui/history/components/logComponent.svelte ================================================
================================================ FILE: src/ui/history/components/logFileComponent.svelte ================================================
{ event.stopPropagation(); if (event.button == 2) mayTriggerFileMenu( view.app, event, diff.vaultPath, view.leaf, "git-history" ); else mainClick(event); }} class="tree-item nav-file" >
================================================ FILE: src/ui/history/components/logTreeComponent.svelte ================================================
{#each hierarchy.children as entity} {#if entity.data}
{:else} {/if} {/each}
================================================ FILE: src/ui/history/historyView.svelte ================================================
================================================ FILE: src/ui/history/historyView.ts ================================================ import type { HoverParent, HoverPopover, WorkspaceLeaf } from "obsidian"; import { ItemView } from "obsidian"; import { HISTORY_VIEW_CONFIG } from "src/constants"; import type ObsidianGit from "src/main"; import HistoryViewComponent from "./historyView.svelte"; import { mount, unmount } from "svelte"; export default class HistoryView extends ItemView implements HoverParent { plugin: ObsidianGit; private _view: Record | undefined; hoverPopover: HoverPopover | null; constructor(leaf: WorkspaceLeaf, plugin: ObsidianGit) { super(leaf); this.plugin = plugin; this.hoverPopover = null; } getViewType(): string { return HISTORY_VIEW_CONFIG.type; } getDisplayText(): string { return HISTORY_VIEW_CONFIG.name; } getIcon(): string { return HISTORY_VIEW_CONFIG.icon; } onClose(): Promise { if (this._view) { // eslint-disable-next-line @typescript-eslint/no-floating-promises unmount(this._view); } return super.onClose(); } reload(): void { if (this._view) { // eslint-disable-next-line @typescript-eslint/no-floating-promises unmount(this._view); } this._view = mount(HistoryViewComponent, { target: this.contentEl, props: { plugin: this.plugin, view: this, }, }); } onOpen(): Promise { this.reload(); return super.onOpen(); } } ================================================ FILE: src/ui/modals/branchModal.ts ================================================ import { FuzzySuggestModal } from "obsidian"; import type ObsidianGit from "src/main"; export class BranchModal extends FuzzySuggestModal { resolve: ( value: string | undefined | PromiseLike ) => void; constructor( plugin: ObsidianGit, private readonly branches: string[] ) { super(plugin.app); this.setPlaceholder("Select branch to checkout"); } getItems(): string[] { return this.branches; } getItemText(item: string): string { return item; } onChooseItem(item: string, _: MouseEvent | KeyboardEvent): void { this.resolve(item); } openAndGetReslt(): Promise { return new Promise((resolve) => { this.resolve = resolve; this.open(); }); } onClose() { //onClose gets called before onChooseItem void new Promise((resolve) => setTimeout(resolve, 10)).then(() => { if (this.resolve) this.resolve(undefined); }); } } ================================================ FILE: src/ui/modals/changedFilesModal.ts ================================================ import { FuzzySuggestModal } from "obsidian"; import type ObsidianGit from "src/main"; import type { FileStatusResult } from "src/types"; export class ChangedFilesModal extends FuzzySuggestModal { plugin: ObsidianGit; changedFiles: FileStatusResult[]; constructor(plugin: ObsidianGit, changedFiles: FileStatusResult[]) { super(plugin.app); this.plugin = plugin; this.changedFiles = changedFiles; this.setPlaceholder( "Not supported files will be opened by default app!" ); } getItems(): FileStatusResult[] { return this.changedFiles; } getItemText(item: FileStatusResult): string { if (item.index == "U" && item.workingDir == "U") { return `Untracked | ${item.vaultPath}`; } let workingDir = ""; let index = ""; if (item.workingDir != " ") workingDir = `Working Dir: ${item.workingDir} `; if (item.index != " ") index = `Index: ${item.index}`; return `${workingDir}${index} | ${item.vaultPath}`; } onChooseItem(item: FileStatusResult, _: MouseEvent | KeyboardEvent): void { if ( this.plugin.app.metadataCache.getFirstLinkpathDest( item.vaultPath, "" ) == null ) { this.app.openWithDefaultApp(item.vaultPath); } else { void this.plugin.app.workspace.openLinkText(item.vaultPath, "/"); } } } ================================================ FILE: src/ui/modals/customMessageModal.ts ================================================ import { moment, SuggestModal } from "obsidian"; import type ObsidianGit from "src/main"; export class CustomMessageModal extends SuggestModal { resolve: | ((value: string | PromiseLike | undefined) => void) | null = null; constructor(private readonly plugin: ObsidianGit) { super(plugin.app); this.setPlaceholder( "Type your message and select optional the version with the added date." ); } openAndGetResult(): Promise { return new Promise((resolve) => { this.resolve = resolve; this.open(); }); } onClose() { // onClose gets called before onChooseItem void new Promise((resolve) => setTimeout(resolve, 10)).then(() => { if (this.resolve) this.resolve(undefined); }); } getSuggestions(query: string): string[] { const date = moment().format(this.plugin.settings.commitDateFormat); if (query == "") query = "..."; return [query, `${date}: ${query}`, `${query}: ${date}`]; } renderSuggestion(value: string, el: HTMLElement): void { el.innerText = value; } onChooseSuggestion(value: string, __: MouseEvent | KeyboardEvent) { if (this.resolve) this.resolve(value); } } ================================================ FILE: src/ui/modals/discardModal.ts ================================================ import type { App } from "obsidian"; import { Modal } from "obsidian"; import { plural } from "src/utils"; export type DiscardResult = false | "delete" | "discard"; export class DiscardModal extends Modal { path: string; deleteCount: number; discardCount: number; constructor({ app, path, filesToDeleteCount, filesToDiscardCount, }: { app: App; path: string; filesToDeleteCount: number; filesToDiscardCount: number; }) { super(app); this.path = path; this.deleteCount = filesToDeleteCount; this.discardCount = filesToDiscardCount; } resolve: ((value: DiscardResult) => void) | null = null; /** * @returns the result of the modal, whcih can be: * - `false` if the user canceled the modal * - `"delete"` if the user chose to delete all files. In case there are also tracked files, they will be discarded as well. * - `"discard"` if the user chose to discard all tracked files. Untracked files will not be deleted. */ openAndGetResult(): Promise { this.open(); return new Promise((resolve) => { this.resolve = resolve; }); } onOpen() { const sum = this.deleteCount + this.discardCount; const { contentEl, titleEl } = this; let titlePart = ""; if (this.path != "") { if (sum > 1) { titlePart = `files in "${this.path}"`; } else { titlePart = `"${this.path}"`; } } titleEl.setText( `${this.discardCount == 0 ? "Delete" : "Discard"} ${titlePart}` ); if (this.deleteCount > 0) { contentEl .createEl("p") .setText( `Are you sure you want to DELETE the ${plural(this.deleteCount, "untracked file")}? They are deleted according to your Obsidian trash settting.` ); } if (this.discardCount > 0) { contentEl .createEl("p") .setText( `Are you sure you want to discard ALL changes in ${plural(this.discardCount, "tracked file")}?` ); } const div = contentEl.createDiv({ cls: "modal-button-container" }); if (this.deleteCount > 0) { const discardAndDelete = div.createEl("button", { cls: "mod-warning", text: `${this.discardCount > 0 ? "Discard" : "Delete"} all ${plural(sum, "file")}`, }); discardAndDelete.addEventListener("click", () => { if (this.resolve) this.resolve("delete"); this.close(); }); discardAndDelete.addEventListener("keypress", () => { if (this.resolve) this.resolve("delete"); this.close(); }); } if (this.discardCount > 0) { const discard = div.createEl("button", { cls: "mod-warning", text: `Discard all ${plural(this.discardCount, "tracked file")}`, }); discard.addEventListener("click", () => { if (this.resolve) this.resolve("discard"); this.close(); }); discard.addEventListener("keypress", () => { if (this.resolve) this.resolve("discard"); this.close(); }); } const close = div.createEl("button", { text: "Cancel", }); close.addEventListener("click", () => { if (this.resolve) this.resolve(false); return this.close(); }); close.addEventListener("keypress", () => { if (this.resolve) this.resolve(false); return this.close(); }); } onClose() { const { contentEl } = this; contentEl.empty(); } } ================================================ FILE: src/ui/modals/generalModal.ts ================================================ import { SuggestModal } from "obsidian"; import type ObsidianGit from "src/main"; export interface OptionalGeneralModalConfig { options?: string[]; placeholder?: string; allowEmpty?: boolean; onlySelection?: boolean; initialValue?: string; obscure?: boolean; } interface GeneralModalConfig { options: string[]; placeholder: string; allowEmpty: boolean; onlySelection: boolean; initialValue?: string; obscure: boolean; } const generalModalConfigDefaults: GeneralModalConfig = { options: [], placeholder: "", allowEmpty: false, onlySelection: false, initialValue: undefined, obscure: false, }; export class GeneralModal extends SuggestModal { resolve: ( value: string | undefined | PromiseLike ) => void; config: GeneralModalConfig; constructor(plugin: ObsidianGit, config: OptionalGeneralModalConfig) { super(plugin.app); this.config = { ...generalModalConfigDefaults, ...config }; this.setPlaceholder(this.config.placeholder); if (this.config.obscure) { this.inputEl.type = "password"; const promptContainer = this.containerEl.querySelector( ".prompt-input-container" )!; promptContainer.addClass("git-obscure-prompt"); promptContainer.setAttr("git-is-obscured", "true"); const obscureSwitchButton = promptContainer?.createDiv({ cls: "search-input-clear-button", }); obscureSwitchButton.style.marginRight = "32px"; obscureSwitchButton.id = "git-show-password"; obscureSwitchButton.addEventListener("click", () => { const isObscured = promptContainer.getAttr("git-is-obscured"); if (isObscured === "true") { this.inputEl.type = "text"; promptContainer.setAttr("git-is-obscured", "false"); } else { this.inputEl.type = "password"; promptContainer.setAttr("git-is-obscured", "true"); } }); } } openAndGetResult(): Promise { return new Promise((resolve) => { this.resolve = resolve; this.open(); if (this.config.initialValue != undefined) { this.inputEl.value = this.config.initialValue; this.inputEl.dispatchEvent(new Event("input")); } }); } onClose() { void new Promise((resolve) => setTimeout(resolve, 10)).then(() => { if (this.resolve) this.resolve(undefined); }); } getSuggestions(query: string): string[] { if (this.config.onlySelection) { return this.config.options; } else if (this.config.allowEmpty) { return [query.length > 0 ? query : " ", ...this.config.options]; } else { return [query.length > 0 ? query : "...", ...this.config.options]; } } renderSuggestion(value: string, el: HTMLElement): void { if (this.config.obscure) { el.hide(); } else { el.setText(value); } } onChooseSuggestion(value: string, _: MouseEvent | KeyboardEvent) { if (this.resolve) { let res; if (this.config.allowEmpty && value === " ") res = ""; else if (value === "...") res = undefined; else res = value; this.resolve(res); } } } ================================================ FILE: src/ui/modals/ignoreModal.ts ================================================ import type { App } from "obsidian"; import { Modal } from "obsidian"; export class IgnoreModal extends Modal { resolve: | ((value: string | PromiseLike | undefined) => void) | null = null; constructor( app: App, private content: string ) { super(app); } openAndGetReslt(): Promise { return new Promise((resolve) => { this.resolve = resolve; this.open(); }); } onOpen() { const { contentEl, titleEl } = this; titleEl.setText("Edit .gitignore"); const div = contentEl.createDiv(); const text = div.createEl("textarea", { text: this.content, cls: ["obsidian-git-textarea"], attr: { rows: 10, cols: 30, wrap: "off" }, }); div.createEl("button", { cls: ["mod-cta", "obsidian-git-center-button"], text: "Save", }).addEventListener("click", () => { this.resolve!(text.value); this.close(); }); } onClose() { const { contentEl } = this; contentEl.empty(); if (this.resolve) this.resolve(undefined); } } ================================================ FILE: src/ui/sourceControl/components/fileComponent.svelte ================================================
{ event.stopPropagation(); if (event.button == 2) mayTriggerFileMenu( view.app, event, change.vaultPath, view.leaf, "git-source-control" ); else mainClick(event); }} class="tree-item nav-file" >
================================================ FILE: src/ui/sourceControl/components/pulledFileComponent.svelte ================================================
{ event.stopPropagation(); if (event.button == 2) mayTriggerFileMenu( view.app, event, change.vaultPath, view.leaf, "git-source-control" ); else open(event); }} class="tree-item nav-file" >
================================================ FILE: src/ui/sourceControl/components/stagedFileComponent.svelte ================================================
{ event.stopPropagation(); if (event.button == 2) mayTriggerFileMenu( view.app, event, change.vaultPath, view.leaf, "git-source-control" ); else mainClick(event); }} class="tree-item nav-file" >
================================================ FILE: src/ui/sourceControl/components/tooManyFilesComponent.svelte ================================================
{#if files.length > 500} {/if}
================================================ FILE: src/ui/sourceControl/components/treeComponent.svelte ================================================
{#each arrayProxyWithNewLength(hierarchy.children, 500) as entity} {#if entity.data}
{#if fileType == FileType.staged} {:else if fileType == FileType.changed} {:else if fileType == FileType.pulled} {/if}
{:else}
fold(event, entity)} onauxclick={(event) => mayTriggerFileMenu( view.app, event, entity.vaultPath, view.leaf, "git-source-control" )} class="tree-item nav-folder" class:is-collapsed={closed[entity.path]} > {#if !closed[entity.path]} {/if}
{/if} {/each}
================================================ FILE: src/ui/sourceControl/sourceControl.svelte ================================================
{#if commitMessage}
(commitMessage = "")} aria-label={"Clear"} >
{/if}
================================================ FILE: src/ui/sourceControl/sourceControl.ts ================================================ import type { HoverParent, HoverPopover, WorkspaceLeaf } from "obsidian"; import { ItemView } from "obsidian"; import { SOURCE_CONTROL_VIEW_CONFIG } from "src/constants"; import type ObsidianGit from "src/main"; import SourceControlViewComponent from "./sourceControl.svelte"; import { mount, unmount } from "svelte"; export default class GitView extends ItemView implements HoverParent { plugin: ObsidianGit; private _view: Record | undefined; hoverPopover: HoverPopover | null; constructor(leaf: WorkspaceLeaf, plugin: ObsidianGit) { super(leaf); this.plugin = plugin; this.hoverPopover = null; } getViewType(): string { return SOURCE_CONTROL_VIEW_CONFIG.type; } getDisplayText(): string { return SOURCE_CONTROL_VIEW_CONFIG.name; } getIcon(): string { return SOURCE_CONTROL_VIEW_CONFIG.icon; } onClose(): Promise { if (this._view) { // eslint-disable-next-line @typescript-eslint/no-floating-promises unmount(this._view); } return super.onClose(); } reload(): void { if (this._view) { // eslint-disable-next-line @typescript-eslint/no-floating-promises unmount(this._view); } this._view = mount(SourceControlViewComponent, { target: this.contentEl, props: { plugin: this.plugin, view: this, }, }); } onOpen(): Promise { this.reload(); return super.onOpen(); } } ================================================ FILE: src/ui/statusBar/branchStatusBar.ts ================================================ import type ObsidianGit from "src/main"; export class BranchStatusBar { constructor( private statusBarEl: HTMLElement, private readonly plugin: ObsidianGit ) { this.statusBarEl.addClass("mod-clickable"); this.statusBarEl.onClickEvent((_) => { this.plugin.switchBranch().catch((e) => plugin.displayError(e)); }); } async display() { if (this.plugin.gitReady) { const branchInfo = await this.plugin.gitManager.branchInfo(); if (branchInfo.current != undefined) { this.statusBarEl.setText(branchInfo.current); } else { this.statusBarEl.empty(); } } else { this.statusBarEl.empty(); } } remove() { this.statusBarEl.remove(); } } ================================================ FILE: src/utils.ts ================================================ import * as cssColorConverter from "css-color-converter"; import { spawn, type SpawnOptionsWithoutStdio } from "child_process"; import deepEqual from "deep-equal"; import type { App, ItemView, RGB, WorkspaceLeaf } from "obsidian"; import { Keymap, Menu, moment, TFile } from "obsidian"; import { BINARY_EXTENSIONS } from "./constants"; export function assertNever(x: never): never { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unexpected object: ${x}`); } export function plural( count: number, singular: string, plural?: string ): string { if (count === 1) { return `${count} ${singular}`; } else { return `${count} ${plural ?? singular + "s"}`; } } export const worthWalking = (filepath: string, root?: string) => { if (filepath === "." || root == null || root.length === 0 || root === ".") { return true; } if (root.length >= filepath.length) { return root.startsWith(filepath); } else { return filepath.startsWith(root); } }; export function getNewLeaf( app: App, event?: MouseEvent ): WorkspaceLeaf | undefined { let leaf: WorkspaceLeaf | undefined; if (event) { if (event.button === 0 || event.button === 1) { const type = Keymap.isModEvent(event); leaf = app.workspace.getLeaf(type); } } else { leaf = app.workspace.getLeaf(false); } return leaf; } export function mayTriggerFileMenu( app: App, event: MouseEvent, filePath: string, view: WorkspaceLeaf, source: string ) { if (event.button == 2) { const file = app.vault.getAbstractFileByPath(filePath); if (file != null) { const fileMenu = new Menu(); app.workspace.trigger("file-menu", fileMenu, file, source, view); fileMenu.showAtPosition({ x: event.pageX, y: event.pageY }); } else { const fileMenu = new Menu(); app.workspace.trigger( "obsidian-git:menu", fileMenu, filePath, source, view ); fileMenu.showAtPosition({ x: event.pageX, y: event.pageY }); } } } /** * Creates a type-error, if this function is in a possible branch. * * Use this to ensure exhaustive switch cases. * * During runtime, an error will be thrown, if executed. */ export function impossibleBranch(x: never): never { /* eslint-disable-next-line @typescript-eslint/restrict-plus-operands */ throw new Error("Impossible branch: " + x); } export function rgbToString(rgb: RGB): string { return `rgb(${rgb.r},${rgb.g},${rgb.b})`; } export function convertToRgb(str: string): RGB | undefined { const color = cssColorConverter.fromString(str)?.toRgbaArray(); if (color === undefined) { return undefined; } const [r, g, b] = color; return { r, g, b }; } export function momentToEpochSeconds(instant: moment.Moment): number { return instant.diff(moment.unix(0), "seconds"); } export function median(array: number[]): number | undefined { if (array.length === 0) return undefined; return array.slice().sort()[Math.floor(array.length / 2)]; } export function strictDeepEqual(a: T, b: T): boolean { return deepEqual(a, b, { strict: true }); } export function arrayProxyWithNewLength(array: T[], length: number): T[] { return new Proxy(array, { get(target, prop) { if (prop === "length") { return Math.min(length, target.length); } return target[prop as keyof T[]]; }, }); } export function resizeToLength( original: string, desiredLength: number, fillChar: string ): string { if (original.length <= desiredLength) { const prefix = new Array(desiredLength - original.length) .fill(fillChar) .join(""); return prefix + original; } else { return original.substring(original.length - desiredLength); } } export function prefixOfLengthAsWhitespace( toBeRenderedText: string, whitespacePrefixLength: number ): string { if (whitespacePrefixLength <= 0) return toBeRenderedText; const whitespacePrefix = new Array(whitespacePrefixLength) .fill(" ") .join(""); const originalSuffix = toBeRenderedText.substring( whitespacePrefixLength, toBeRenderedText.length ); return whitespacePrefix + originalSuffix; } export function between(l: number, x: number, r: number) { return l <= x && x <= r; } export function splitRemoteBranch( remoteBranch: string ): readonly [string, string | undefined] { const [remote, ...branch] = remoteBranch.split("/"); return [remote, branch.length === 0 ? undefined : branch.join("/")]; } export function getDisplayPath(path: string): string { if (path.endsWith("/")) return path; return path.split("/").last()!.replace(/\.md$/, ""); } export function formatMinutes(minutes: number): string { if (minutes === 1) return "1 minute"; return `${minutes} minutes`; } export function getExtensionFromPath(path: string): string { const dotIndex = path.lastIndexOf("."); return path.substring(dotIndex + 1); } /** * Decides if a file is binary based on its extension. */ export function fileIsBinary(path: string): boolean { // This is the case for the most files so we can save some time if (path.endsWith(".md")) return false; const ext = getExtensionFromPath(path); return BINARY_EXTENSIONS.includes(ext); } export function formatRemoteUrl(url: string): string { if ( url.startsWith("https://github.com/") || url.startsWith("https://gitlab.com/") ) { if (!url.endsWith(".git")) { url = url + ".git"; } } return url; } export function fileOpenableInObsidian( relativeVaultPath: string, app: App ): boolean { const file = app.vault.getAbstractFileByPath(relativeVaultPath); if (!(file instanceof TFile)) { return false; } try { // Internal Obsidian API function // If a view type is registired for the file extension, it can be opened in Obsidian. // Just checking if Obsidian tracks the file is not enough, // because it can also track files, it can only open externally. return !!app.viewRegistry.getTypeByExtension(file.extension); } catch { // If the function doesn't exist anymore, it will throw an error. In that case, just skip the check. return true; } } export function convertPathToAbsoluteGitignoreRule({ isFolder, gitRelativePath, }: { isFolder?: boolean; gitRelativePath: string; }): string { // Add a leading slash to set the rule as absolute from root, so it only excludes that exact path let composedPath = "/"; composedPath += gitRelativePath; // Add an explicit folder rule, so that the same path doesn't also apply for files with that same name if (isFolder) { composedPath += "/"; } // Escape special characters, so that git treats them as literal characters. const escaped = composedPath.replace(/([\\!#*?[\]])/g, String.raw`\$1`); // Then escape each trailing whitespace character individually, because git trims trailing whitespace from the end of the rule. // 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. return escaped.replace(/\s(?=\s*$)/g, String.raw`\ `); } /** * When hovering a link going to `to`, show the Obsidian hover-preview of that note. * * You probably have to hold down `Ctrl` when hovering the link for the preview to appear! * @param {MouseEvent} event * @param {YourView} view The view with the link being hovered * @param {string} to The basename of the note to preview. * @template YourView The ViewType of your view * @returns void */ export function hoverPreview( app: App, event: MouseEvent, view: YourView, to: string ): void { const targetEl = event.target as HTMLElement; app.workspace.trigger("hover-link", { event, source: view.getViewType(), hoverParent: view, targetEl, linktext: to, }); } export function spawnAsync( command: string, args: string[], options: SpawnOptionsWithoutStdio = {} ): Promise<{ stdout: string; stderr: string; code: number; error: Error | undefined; }> { return new Promise((resolve, _) => { // Spawn the child process const child = spawn(command, args, options); let stdoutBuffer = ""; let stderrBuffer = ""; // Collect stdout data child.stdout.on("data", (data: Buffer) => { stdoutBuffer += data.toString(); }); // Collect stderr data child.stderr.on("data", (data: Buffer) => { stderrBuffer += data.toString(); }); // Handle process errors (e.g., command not found) child.on("error", (err) => { resolve({ error: new Error(err.message), stdout: stdoutBuffer, stderr: stdoutBuffer, code: 1, }); }); // Handle process exit child.on("close", (code) => { // Resolve the promise with collected data and exit code resolve({ stdout: stdoutBuffer, stderr: stderrBuffer, code: code ?? 1, error: undefined, }); }); }); } ================================================ FILE: styles.css ================================================ @keyframes loading { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .git-signs-gutter { .cm-gutterElement { /* Needed to align the sign properly for different line heigts. Such as * when having a heading or list item. */ padding-top: 0 !important; } } .workspace-leaf-content[data-type="git-view"] .button-border { border: 2px solid var(--interactive-accent); border-radius: var(--radius-s); } .workspace-leaf-content[data-type="git-view"] .view-content { padding-left: 0; padding-top: 0; padding-right: 0; } .workspace-leaf-content[data-type="git-history-view"] .view-content { padding-left: 0; padding-top: 0; padding-right: 0; } .loading { overflow: hidden; } .loading > svg { animation: 2s linear infinite loading; transform-origin: 50% 50%; display: inline-block; } .obsidian-git-center { margin: auto; text-align: center; width: 50%; } .obsidian-git-textarea { display: block; margin-left: auto; margin-right: auto; } .obsidian-git-disabled { opacity: 0.5; } .obsidian-git-center-button { display: block; margin: 20px auto; } .tooltip.mod-left { overflow-wrap: break-word; } .tooltip.mod-right { overflow-wrap: break-word; } /* Limits the scrollbar to the view body */ .git-view { display: flex; flex-direction: column; position: relative; height: 100%; } /* Re-enable wrapping of nav buttns to prevent overflow on smaller screens #*/ .workspace-drawer .git-view .nav-buttons-container { flex-wrap: wrap; } .git-tools { display: flex; margin-left: auto; } .git-tools .type { padding-left: var(--size-2-1); display: flex; align-items: center; justify-content: center; width: 11px; } .git-tools .type[data-type="M"] { color: orange; } .git-tools .type[data-type="D"] { color: red; } .git-tools .buttons { display: flex; } .git-tools .buttons > * { padding: 0 0; height: auto; } .workspace-leaf-content[data-type="git-view"] .tree-item-self, .workspace-leaf-content[data-type="git-history-view"] .tree-item-self { align-items: center; } .workspace-leaf-content[data-type="git-view"] .tree-item-self:hover .clickable-icon, .workspace-leaf-content[data-type="git-history-view"] .tree-item-self:hover .clickable-icon { color: var(--icon-color-hover); } /* Highlight an item as active if it's diff is currently opened */ .is-active .git-tools .buttons > * { color: var(--nav-item-color-active); } .git-author { color: var(--text-accent); } .git-date { color: var(--text-accent); } .git-ref { color: var(--text-accent); } /* ====== diff2html ====== The following styles are adapted from the obsidian-version-history plugin by @kometenstaub https://github.com/kometenstaub/obsidian-version-history-diff/blob/main/src/styles.scss which itself is adapted from the diff2html library with the following original license: https://github.com/rtfpessoa/diff2html/blob/master/LICENSE.md Copyright 2014-2016 Rodrigo Fernandes https://rtfpessoa.github.io/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ .theme-dark, .theme-light { --git-delete-bg: #ff475040; --git-delete-hl: #96050a75; --git-insert-bg: #68d36840; --git-insert-hl: #23c02350; --git-change-bg: #ffd55840; --git-selected: #3572b0; --git-delete: #c33; --git-insert: #399839; --git-change: #d0b44c; --git-move: #3572b0; } .git-diff { .d2h-d-none { display: none; } .d2h-wrapper { text-align: left; border-radius: 0.25em; overflow: auto; } .d2h-file-header.d2h-file-header { background-color: var(--background-secondary); border-bottom: 1px solid var(--background-modifier-border); font-family: Source Sans Pro, Helvetica Neue, Helvetica, Arial, sans-serif; height: 35px; padding: 5px 10px; } .d2h-file-header, .d2h-file-stats { display: -webkit-box; display: -ms-flexbox; display: flex; } .d2h-file-header { display: none; } .d2h-file-stats { font-size: 14px; margin-left: auto; } .d2h-lines-added { border: 1px solid var(--color-green); border-radius: 5px 0 0 5px; color: var(--color-green); padding: 2px; text-align: right; vertical-align: middle; } .d2h-lines-deleted { border: 1px solid var(--color-red); border-radius: 0 5px 5px 0; color: var(--color-red); margin-left: 1px; padding: 2px; text-align: left; vertical-align: middle; } .d2h-file-name-wrapper { -webkit-box-align: center; -ms-flex-align: center; align-items: center; display: -webkit-box; display: -ms-flexbox; display: flex; font-size: 15px; width: 100%; } .d2h-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-normal); font-size: var(--h5-size); } .d2h-file-wrapper { border: 1px solid var(--background-secondary-alt); border-radius: 3px; margin-bottom: 1em; max-height: 100%; } .d2h-file-collapse { -webkit-box-pack: end; -ms-flex-pack: end; -webkit-box-align: center; -ms-flex-align: center; align-items: center; border: 1px solid var(--background-secondary-alt); border-radius: 3px; cursor: pointer; display: none; font-size: 12px; justify-content: flex-end; padding: 4px 8px; } .d2h-file-collapse.d2h-selected { background-color: var(--git-selected); } .d2h-file-collapse-input { margin: 0 4px 0 0; } .d2h-diff-table { border-collapse: collapse; font-family: var(--font-monospace); font-size: var(--code-size); width: 100%; } .d2h-files-diff { width: 100%; } .d2h-file-diff { /* overflow-y: scroll; */ border-radius: 5px; font-size: var(--font-text-size); line-height: var(--line-height-normal); } .d2h-file-side-diff { display: inline-block; margin-bottom: -8px; margin-right: -4px; overflow-x: scroll; overflow-y: hidden; width: 50%; } .d2h-code-line { padding-left: 6em; padding-right: 1.5em; } .d2h-code-line, .d2h-code-side-line { display: inline-block; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; white-space: nowrap; width: 100%; } .d2h-code-side-line { /* needed to be changed */ padding-left: 0.5em; padding-right: 0.5em; } .d2h-code-line-ctn { word-wrap: normal; background: none; display: inline-block; padding: 0; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text; user-select: text; vertical-align: middle; width: 100%; /* only works for line-by-line */ white-space: pre-wrap; } .d2h-code-line del, .d2h-code-side-line del { background-color: var(--git-delete-hl); color: var(--text-normal); } .d2h-code-line del, .d2h-code-line ins, .d2h-code-side-line del, .d2h-code-side-line ins { border-radius: 0.2em; display: inline-block; margin-top: -1px; text-decoration: none; vertical-align: middle; } .d2h-code-line ins, .d2h-code-side-line ins { background-color: var(--git-insert-hl); text-align: left; } .d2h-code-line-prefix { word-wrap: normal; background: none; display: inline; padding: 0; white-space: pre; } .line-num1 { float: left; } .line-num1, .line-num2 { -webkit-box-sizing: border-box; box-sizing: border-box; overflow: hidden; /* padding: 0 0.5em; */ text-overflow: ellipsis; width: 2.5em; padding-left: 0; } .line-num2 { float: right; } .d2h-code-linenumber { background-color: var(--background-primary); border: solid var(--background-modifier-border); border-width: 0 1px; -webkit-box-sizing: border-box; box-sizing: border-box; color: var(--text-faint); cursor: pointer; display: inline-block; position: absolute; text-align: right; width: 5.5em; } .d2h-code-linenumber:after { content: "\200b"; } .d2h-code-side-linenumber { background-color: var(--background-primary); border: solid var(--background-modifier-border); border-width: 0 1px; -webkit-box-sizing: border-box; box-sizing: border-box; color: var(--text-faint); cursor: pointer; overflow: hidden; padding: 0 0.5em; text-align: right; text-overflow: ellipsis; width: 4em; /* needed to be changed */ display: table-cell; position: relative; } .d2h-code-side-linenumber:after { content: "\200b"; } .d2h-code-side-emptyplaceholder, .d2h-emptyplaceholder { background-color: var(--background-primary); border-color: var(--background-modifier-border); } .d2h-code-line-prefix, .d2h-code-linenumber, .d2h-code-side-linenumber, .d2h-emptyplaceholder { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .d2h-code-linenumber, .d2h-code-side-linenumber { direction: rtl; } .d2h-del { background-color: var(--git-delete-bg); border-color: var(--git-delete-hl); } .d2h-ins { background-color: var(--git-insert-bg); border-color: var(--git-insert-hl); } .d2h-info { background-color: var(--background-primary); border-color: var(--background-modifier-border); color: var(--text-faint); } .d2h-del, .d2h-ins, .d2h-file-diff .d2h-change { color: var(--text-normal); } .d2h-file-diff .d2h-del.d2h-change { background-color: var(--git-change-bg); } .d2h-file-diff .d2h-ins.d2h-change { background-color: var(--git-insert-bg); } .d2h-file-list-wrapper { a { text-decoration: none; cursor: default; -webkit-user-drag: none; } svg { display: none; } } .d2h-file-list-header { text-align: left; } .d2h-file-list-title { display: none; } .d2h-file-list-line { display: -webkit-box; display: -ms-flexbox; display: flex; text-align: left; } .d2h-file-list { } .d2h-file-list > li { border-bottom: 1px solid var(--background-modifier-border); margin: 0; padding: 5px 10px; } .d2h-file-list > li:last-child { border-bottom: none; } .d2h-file-switch { cursor: pointer; display: none; font-size: 10px; } .d2h-icon { fill: currentColor; margin-right: 10px; vertical-align: middle; } .d2h-deleted { color: var(--git-delete); } .d2h-added { color: var(--git-insert); } .d2h-changed { color: var(--git-change); } .d2h-moved { color: var(--git-move); } .d2h-tag { background-color: var(--background-secondary); display: -webkit-box; display: -ms-flexbox; display: flex; font-size: 10px; margin-left: 5px; padding: 0 2px; } .d2h-deleted-tag { border: 1px solid var(--git-delete); } .d2h-added-tag { border: 1px solid var(--git-insert); } .d2h-changed-tag { border: 1px solid var(--git-change); } .d2h-moved-tag { border: 1px solid var(--git-move); } /* needed for line-by-line*/ .d2h-diff-tbody { position: relative; } } /* ====================== Line Authoring Information ====================== */ .cm-gutterElement.obs-git-blame-gutter { /* Add background color to spacing inbetween and around the gutter for better aesthetics */ border-width: 0px 2px 0.2px 2px; border-style: solid; border-color: var(--background-secondary); background-color: var(--background-secondary); } .cm-gutterElement.obs-git-blame-gutter > div, .line-author-settings-preview { /* delegate text color to settings */ color: var(--obs-git-gutter-text); font-family: monospace; height: 100%; /* ensure, that age-based background color occupies entire parent */ text-align: right; padding: 0px 6px 0px 6px; white-space: pre; /* Keep spaces and do not collapse them. */ } @media (max-width: 800px) { /* hide git blame gutter not to superpose text */ .cm-gutterElement.obs-git-blame-gutter { display: none; } } .git-unified-diff-view, .git-split-diff-view .cm-deletedLine .cm-changedText { background-color: #ee443330; } .git-unified-diff-view, .git-split-diff-view .cm-insertedLine .cm-changedText { background-color: #22bb2230; } .git-obscure-prompt[git-is-obscured="true"] #git-show-password:after { -webkit-mask-image: url('data:image/svg+xml,'); } .git-obscure-prompt[git-is-obscured="false"] #git-show-password:after { -webkit-mask-image: url('data:image/svg+xml,'); } /* Override styling of Codemirror merge view "collapsed lines" indicator */ .git-split-diff-view .ͼ2 .cm-collapsedLines { background: var(--interactive-normal); border-radius: var(--radius-m); color: var(--text-accent); font-size: var(--font-small); padding: var(--size-4-1) var(--size-4-1); } .git-split-diff-view .ͼ2 .cm-collapsedLines:hover { background: var(--interactive-hover); color: var(--text-accent-hover); } .git-signs-gutter { .cm-gutterElement { display: grid; } } .git-gutter-marker:hover { border-radius: 2px; } .git-gutter-marker.git-add { background-color: var(--color-green); justify-self: center; height: inherit; width: 0.2rem; } .git-gutter-marker.git-change { background-color: var(--color-yellow); justify-self: center; height: inherit; width: 0.2rem; } .git-gutter-marker.git-changedelete { color: var(--color-yellow); font-weight: var(--font-bold); font-size: 1rem; justify-self: center; height: inherit; } .git-gutter-marker.git-delete { background-color: var(--color-red); height: 0.2rem; width: 0.8rem; align-self: end; } .git-gutter-marker.git-topdelete { background-color: var(--color-red); height: 0.2rem; width: 0.8rem; align-self: start; } div:hover > .git-gutter-marker.git-change { width: 0.6rem; } div:hover > .git-gutter-marker.git-add { width: 0.6rem; } div:hover > .git-gutter-marker.git-delete { height: 0.6rem; } div:hover > .git-gutter-marker.git-topdelete { height: 0.6rem; } div:hover > .git-gutter-marker.git-changedelete { font-weight: var(--font-bold); } .git-gutter-marker.staged { opacity: 0.5; } .git-diff { .cm-merge-revert { width: 4em; } /* Ensure that merge revert markers are positioned correctly */ .cm-merge-revert > * { position: absolute; background-color: var(--background-secondary); display: flex; } } /* Prevent shifting of the editor when git signs gutter is the only gutter present */ .cm-gutters.cm-gutters-before:has(> .git-signs-gutter:only-child) { margin-inline-end: 0; .git-signs-gutter { margin-inline-start: -1rem; } } .git-changes-status-bar-colored { .git-add { color: var(--color-green); } .git-change { color: var(--color-yellow); } .git-delete { color: var(--color-red); } } .git-changes-status-bar .git-add { margin-right: 0.3em; } .git-changes-status-bar .git-change { margin-right: 0.3em; } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", "target": "ES6", "allowJs": true, "noImplicitAny": true, "isolatedModules": true, "moduleResolution": "node", "strictNullChecks": true, "importHelpers": true, "allowSyntheticDefaultImports": true, "verbatimModuleSyntax": true, "lib": [ "DOM", "ES5", "ES6", "ES7" ], "skipLibCheck": true, "noUnusedLocals": false }, "include": [ "**/*.ts", "eslint.config.mjs", "**/*.svelte" ] }