├── .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 ├── assets │ ├── credential-manager-windows-git.png │ ├── line-author-activate.png │ ├── line-author-color-config.png │ ├── line-author-commit-hash-full-name-config.png │ ├── line-author-commit-hash-full-name.png │ ├── line-author-copy-commit-hash.png │ ├── line-author-custom-dates-config.png │ ├── line-author-custom-dates.png │ ├── line-author-dark-light.gif │ ├── line-author-default.png │ ├── line-author-follow-all-commits.png │ ├── line-author-follow-config.png │ ├── line-author-follow-no-follow.png │ ├── line-author-ignore-whitespace-before.png │ ├── line-author-ignore-whitespace-ignored.png │ ├── line-author-ignore-whitespace-preserved.png │ ├── line-author-multi-line-newest.gif │ ├── line-author-natural-language-dates.png │ ├── line-author-quick-configure-gutter.gif │ ├── line-author-soft-unintrusive-ux-editing.gif │ ├── line-author-soft-unintrusive-ux.gif │ ├── line-author-text-color-muted.png │ ├── line-author-text-color-normal.png │ ├── line-author-text-color.png │ ├── line-author-tz-author-local.png │ ├── line-author-tz-config.png │ ├── line-author-tz-utc0000.png │ ├── line-author-tz-viewer-plus0100.png │ ├── line-author-untracked.png │ └── third-party-windows-git.png └── dev │ └── LineAuthorFeature.md ├── esbuild.config.mjs ├── eslint.config.mjs ├── images ├── diff-view.png ├── history-view.png └── source-view.png ├── manifest.json ├── package.json ├── pnpm-lock.yaml ├── polyfill_buffer.js ├── src ├── automaticsManager.ts ├── commands.ts ├── constants.ts ├── externalLibTypes.d.ts ├── gitManager │ ├── gitManager.ts │ ├── isomorphicGit.ts │ ├── myAdapter.ts │ └── simpleGit.ts ├── lineAuthor │ ├── control.ts │ ├── eventsPerFilepath.ts │ ├── 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 ├── 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 /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | tab_width = 4 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: "bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: "**Please make sure you are on the latest version.**" 9 | - type: textarea 10 | id: what-happened 11 | attributes: 12 | label: Describe the bug 13 | placeholder: The following error occurs when running command X when I have X enabled. 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: logs 18 | attributes: 19 | label: Relevant errors (if available) from notifications or console (`CTRL+SHIFT+I`) 20 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 21 | render: shell 22 | - type: textarea 23 | id: reproduce 24 | attributes: 25 | label: Steps to reproduce 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: expected 30 | attributes: 31 | label: Expected Behavior 32 | description: A clear and concise description of what you expected to happen. 33 | - type: textarea 34 | id: context 35 | attributes: 36 | label: Addition context 37 | description: Add any other context about the problem here. 38 | - type: dropdown 39 | id: os 40 | attributes: 41 | label: Operating system 42 | description: Which OS are you using? 43 | options: 44 | - Windows 45 | - Linux 46 | - macOS 47 | - Android 48 | - iOS 49 | validations: 50 | required: true 51 | - type: dropdown 52 | id: installation-method 53 | attributes: 54 | label: Installation Method 55 | description: Only necessary on Linux 56 | options: 57 | - Flatpak 58 | - AppImage 59 | - Snap 60 | - Other 61 | validations: 62 | required: false 63 | - type: input 64 | id: version 65 | attributes: 66 | label: Plugin version 67 | validations: 68 | required: true 69 | -------------------------------------------------------------------------------- /.github/workflows/releases.yml: -------------------------------------------------------------------------------- 1 | name: Build obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-git 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: pnpm/action-setup@v2 20 | with: 21 | version: latest 22 | - name: Build 23 | id: build 24 | run: | 25 | pnpm install 26 | pnpm run build --if-present 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | - name: Create Release 31 | id: create_release 32 | uses: softprops/action-gh-release@v1 33 | with: 34 | tag_name: ${{ github.ref }} 35 | name: ${{ github.ref_name }} 36 | generate_release_notes: true 37 | draft: false 38 | prerelease: false 39 | - name: Upload zip file 40 | id: upload-zip 41 | uses: actions/upload-release-asset@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | with: 45 | upload_url: ${{ steps.create_release.outputs.upload_url }} 46 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 47 | asset_name: ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip 48 | asset_content_type: application/zip 49 | - name: Upload main.js 50 | id: upload-main 51 | uses: actions/upload-release-asset@v1 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | upload_url: ${{ steps.create_release.outputs.upload_url }} 56 | asset_path: ./main.js 57 | asset_name: main.js 58 | asset_content_type: text/javascript 59 | - name: Upload manifest.json 60 | id: upload-manifest 61 | uses: actions/upload-release-asset@v1 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | with: 65 | upload_url: ${{ steps.create_release.outputs.upload_url }} 66 | asset_path: ./manifest.json 67 | asset_name: manifest.json 68 | asset_content_type: application/json 69 | - name: Upload styles.css 70 | id: upload-css 71 | uses: actions/upload-release-asset@v1 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | with: 75 | upload_url: ${{ steps.create_release.outputs.upload_url }} 76 | asset_path: ./styles.css 77 | asset_name: styles.css 78 | asset_content_type: text/css 79 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | svelte-check: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: pnpm/action-setup@v2 11 | with: 12 | version: latest 13 | - name: Install modules 14 | run: pnpm install 15 | - name: Run Svelte-Check 16 | run: pnpm run svelte 17 | lint: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: pnpm/action-setup@v2 22 | with: 23 | version: latest 24 | - name: Install modules 25 | run: pnpm install 26 | - name: Run ESLint 27 | run: pnpm run lint 28 | format: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: pnpm/action-setup@v2 33 | with: 34 | version: latest 35 | - name: Install modules 36 | run: pnpm install 37 | - name: Run Prettier 38 | run: pnpm run format 39 | compile: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: pnpm/action-setup@v2 44 | with: 45 | version: latest 46 | - name: Install modules 47 | run: pnpm install 48 | - name: Run tsc 49 | run: tsc --noEmit 50 | build: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: pnpm/action-setup@v2 55 | with: 56 | version: latest 57 | - name: Install modules 58 | run: pnpm install 59 | - name: Run build 60 | run: pnpm run build 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | main.js 8 | yarn.lock 9 | 10 | # build 11 | *.js.map 12 | 13 | .prettierignore 14 | /data.json 15 | 16 | .vscode 17 | 18 | .DS_Store 19 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "plugins": ["prettier-plugin-svelte"], 6 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Vinzent03, Denis Olehov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Git Plugin 2 | 3 | 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. 4 | 5 | ## 📚 Documentation 6 | 7 | All setup instructions (including mobile), common issues, tips, and advanced configuration can be found in the 📖 [full documentation](https://publish.obsidian.md/git-doc). 8 | 9 | > 👉 Mobile users: Please check the dedicated [Mobile](#mobile) section below. 10 | 11 | ## ✨ Key Features 12 | 13 | - 🔁 **Automatic commit-and-sync** (commit, pull, and push) on a schedule. 14 | - 📥 **Auto-pull on Obsidian startup** 15 | - 📂 **Submodule support** for managing multiple repositories (desktop only and opt-in) 16 | - 🔧 **Source Control View** to stage/unstage, commit and diff files - Open it with the `Open source control view` command. 17 | - 📜 **History View** for browsing commit logs and changed files - Open it with the `Open history view` command. 18 | - 🔍 **Diff View** for viewing changes in a file - Open it with the `Open diff view` command. 19 | - 🔗 GitHub integration to open files and history in your browser 20 | 21 | > 🧩 For detailed file history, consider pairing this plugin with the [Version History Diff](obsidian://show-plugin?id=obsidian-version-history-diff) plugin. 22 | 23 | ## 🖥️ UI Previews 24 | 25 | ### 🔧 Source Control View 26 | 27 | Manage your file changes directly inside Obsidian like stage/unstage individual files and commit them. 28 | 29 | ![Source Control View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/source-view.png) 30 | 31 | ### 📜 History View 32 | 33 | 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. 34 | 35 | ![History View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/history-view.png) 36 | 37 | ### 🔍 Diff View 38 | 39 | Compare versions with a clear and concise diff viewer. 40 | Open it from the source control view or via the `Open diff view` command. 41 | 42 | ![Diff View](https://raw.githubusercontent.com/Vinzent03/obsidian-git/master/images/diff-view.png) 43 | 44 | ## ⚙️ Available Commands 45 | > Not exhaustive - these are just some of the most common commands. For a full list, see the Command Palette in Obsidian. 46 | 47 | - 🔄 Changes 48 | - `List changed files`: Lists all changes in a modal 49 | - `Open diff view`: Open diff view for the current file 50 | - `Stage current file` 51 | - `Unstage current file` 52 | - ✅ Commit 53 | - `Commit all changes`: Only commits all changes without pushing 54 | - `Commit all changes with specific message`: Same as above, but with a custom message 55 | - `Commit staged`: Commits only files that have been staged 56 | - `Commit staged with specific message`: Same as above, but with a custom message 57 | - 🔀 Commit-and-sync 58 | - `Commit-and-sync`: With default settings, this will commit all changes, pull, and push 59 | - `Commit-and-sync with specific message`: Same as above, but with a custom message 60 | - `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. 61 | - 🌐 Remote 62 | - `Push`, `Pull` 63 | - `Edit remotes` 64 | - `Remove remote` 65 | - `Clone an existing remote repo`: Opens dialog that will prompt for URL and authentication to clone a remote repo 66 | - `Open file on GitHub`: Open the file view of the current file on GitHub in a browser window. Note: only works on desktop 67 | - `Open file history on GitHub`: Open the file history of the current file on GitHub in a browser window. Note: only works on desktop 68 | - 🏠 Manage local repository 69 | - `Initialize a new repo` 70 | - `Create new branch` 71 | - `Delete branch` 72 | - `CAUTION: Delete repository` 73 | - 🧪 Miscellaneous 74 | - `Open source control view`: Opens side pane displaying [Source control view](#sidebar-view) 75 | - `Edit .gitignore` 76 | - `Add file to .gitignore`: Add current file to `.gitignore` 77 | 78 | ## 💻 Desktop Notes 79 | 80 | ### 🔐 Authentication 81 | 82 | Some Git services may require further setup for HTTPS/SSH authentication. Refer to the [Authentication Guide](https://publish.obsidian.md/git-doc/Authentication) 83 | 84 | ### Obsidian on Linux 85 | 86 | - ⚠️ Snap is not supported. 87 | - ⚠️ Flatpak is not recommended, because it doesn't have access to all system files. 88 | - ✅ 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)) 89 | 90 | ## 📱 Mobile Support (⚠️ Experimental) 91 | 92 | The Git implementation on mobile is **very unstable**! I would not recommend using this plugin on mobile, but try other syncing services. 93 | > 🧪 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. 94 | 95 | ### ❌ Mobile Limitations 96 | 97 | - No **SSH authentication** ([isomorphic-git issue](https://github.com/isomorphic-git/isomorphic-git/issues/231)) 98 | - Limited repo size, because of memory restrictions 99 | - No rebase merge strategy 100 | - No submodules support 101 | 102 | ### ⚠️ Performance Caveats 103 | 104 | > [!caution] 105 | > Depending on your device and available free RAM, Obsidian may 106 | > 107 | > - crash on clone/pull 108 | > - create buffer overflow errors 109 | > - run indefinitely. 110 | > 111 | > 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. 112 | 113 | **Setup:** iPad Pro M1 with a [repo](https://github.com/Vinzent03/obsidian-git-stress-test) of 3000 files reduced from [10000 markdown files](https://github.com/Zettelkasten-Method/10000-markdown-files) 114 | 115 | The initial clone took 0m25s. After that, the most time consuming part is to check the whole working directory for file changes. On this setup, checking all files for changes to stage takes 03m40s. Other commands like pull, push and commit are very fast (1-5 seconds). 116 | 117 | ### Tips for Mobile Use: 118 | 119 | The fastest way to work on mobile if you have a large repo/vault is to stage individual files and only commit staged files. 120 | 121 | ## 🙋 Contact & Credits 122 | 123 | - The Line Authoring feature was developed by [GollyTicker](https://github.com/GollyTicker), so any questions may be best answered by him. 124 | - 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. 125 | - If you have any kind of feedback or questions, feel free to reach out via GitHub issues or `vinzent3` on [Obsidian Discord server](https://discord.com/invite/veuWUTm). 126 | 127 | ## ☕ Support 128 | 129 | If you find this plugin useful and would like to support its development, you can support me on Ko-fi. 130 | 131 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F195IQ5) 132 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .obsidian -------------------------------------------------------------------------------- /docs/Authentication.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: 3 | - "04 Authentication" 4 | --- 5 | # macOS 6 | 7 | ## HTTPS 8 | 9 | Run the following to use the macOS keychain to store your credentials. 10 | 11 | ```bash 12 | git config --global credential.helper osxkeychain 13 | ``` 14 | 15 | 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. 16 | 17 | ## SSH 18 | 19 | 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). 20 | 21 | # Windows 22 | 23 | ## HTTPS 24 | 25 | Ensure you are using Git 2.29 or higher and you are using Git Credential Manager as a credential helper. 26 | 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`. 27 | 28 | ```bash 29 | git config credential.helper 30 | ``` 31 | 32 | If this doesn't output `manager`, please run `git config set credential.helper manager` 33 | Just execute any authentication command like push/pull/clone and a pop window should come up, allowing your to sign in. 34 | 35 | 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)., 36 | 37 | ## SSH 38 | 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). 39 | 40 | # Linux 41 | 42 | ## HTTPS 43 | 44 | ### Storing 45 | 46 | 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). 47 | 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. 48 | 49 | ```bash 50 | git config set credential.helper libsecret 51 | ``` 52 | 53 | 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. 54 | 55 | 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. 56 | Here is an example for Ubuntu. 57 | 58 | ```bash 59 | sudo apt install libsecret-1-0 libsecret-1-dev make gcc 60 | 61 | sudo make --directory=/usr/share/doc/git/contrib/credential/libsecret 62 | 63 | # 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. 64 | git config --global credential.helper \ 65 | /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret 66 | 67 | ``` 68 | 69 | ### SSH_PASS Tools 70 | 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. 71 | 72 | #### Native SSH_ASKPASS 73 | 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. 74 | 75 | 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. 76 | 77 | ``` 78 | SSH_ASKPASS=ksshaskpass 79 | ``` 80 | 81 | You should get a new window to enter your username/password when using a Git action needing authentication now. 82 | 83 | #### SSH_PASS integrated in Obsidian 84 | 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. 85 | 86 | ## SSH 87 | 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). 88 | -------------------------------------------------------------------------------- /docs/Common issues.md: -------------------------------------------------------------------------------- 1 | ## xcrun: error: invalid developer path 2 | 3 | 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. 4 | 5 | ## Error: spansSync git ENOENT/ Cannot run Git command 6 | 7 | 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. 8 | If you think everything is correctly set up and the error still occurs try the following: 9 | 10 | 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: 11 | 12 | ### Windows 13 | 14 | Run `where git` in the terminal. It should return the path to the Git executable. If it fails, Git is not properly installed. 15 | 16 | ### Linux/MacOS 17 | 18 | Run `which git` in the terminal. It should return the path to the Git executable. If it fails, Git is not properly installed. 19 | 20 | ## Infinite pulling/pushing with no error 21 | 22 | That's most time caused by authentication problems. Head over to [[Authentication]] 23 | 24 | ## Bad owner or permissions on /home/\/.ssh/config 25 | 26 | Run `chmod 600 ~/.ssh/config` in the terminal. 27 | 28 | 29 | ## Files in `.gitignore` aren't ignored 30 | 31 | 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. 32 | 33 | 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: 34 | 1. Run `git rm --cached ` in your terminal. The file will stay on your file system. It's just deleted in your repo. 35 | 2. The file should be listed as deleted in `git status` 36 | 3. Commit the deletion 37 | 4. Now any changes to the file are properly ignored. 38 | 39 | ## Cannot run gpg 40 | 41 | ``` 42 | Error: error: cannot run gpg: No such file or directory 43 | error: gpg failed to sign the data 44 | fatal: failed to write commit object 45 | ``` 46 | 47 | See [[Integration with other tools#GPG Signing]] on how to solve this. 48 | 49 | ## This repository is configured for Git LFS but 'git-lfs' was not found on your path. 50 | 51 | See [[Integration with other tools#Git Large File Storage]] on how to solve this. -------------------------------------------------------------------------------- /docs/Features.md: -------------------------------------------------------------------------------- 1 | ## Source Control View 2 | 3 | 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 4 | 5 | - Stage/Unstage individual files 6 | - Discard any changes to a specific file 7 | - Open the diff view for changed files 8 | - Stage/Unstage all files 9 | - Push/Pull 10 | - Commit or [[Start here#commit-and-sync|commit-and-sync]] 11 | - Switch between list and tree view using the button at the top 12 | 13 | ## History View 14 | 15 | 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. 16 | 17 | ## Line Authoring 18 | 19 | For each line, view the last time, it was modified: [[Line Authoring|Line Authoring]]. Technically known as `git-blame`. 20 | 21 | ## Automatic commit-and-sync 22 | 23 | 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. 24 | 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. 25 | 26 | 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. 27 | 28 | 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. 29 | 30 | ## Commit message 31 | 32 | The plugin uses [momentjs](https://momentjs.com/) for formatting the date, so read through their documentation on how to construct your date placeholder. 33 | 34 | ## Submodules Support 35 | 36 | 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. 37 | 38 | Additional **requirements**: 39 | 40 | - Checked out branch (not just a commit as it is when running `git submodule update --init`) 41 | - Tracking branch is set up, so that `git push` works 42 | - Tracking branch needs to be fetched, so that a `git diff` with the branch works 43 | -------------------------------------------------------------------------------- /docs/Getting Started.md: -------------------------------------------------------------------------------- 1 | # Desktop 2 | 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]]. 3 | 4 | ## Create new local repository 5 | 6 | 1. Follow the [[Installation]] instructions for your operating system 7 | 2. Call the `Initialize a new repo` command 8 | 3. Create your first commit by creating some files and calling the `Commit all changes with specific message` command 9 | 4. If you want to Setup to push it to a remote repository like to GitHub: 10 | 1. Setup [[Authentication]] 11 | 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]]. 12 | 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. 13 | 14 | ## For existing remote repository 15 | 16 | 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. 17 | `https`: `https://github.com//.git` 18 | `ssh`: `git@github.com:/.git` 19 | 20 | 1. Follow the [[Installation]] instructions for your operating system 21 | 2. Setup [[Authentication]] 22 | 3. Git can only clone a remote repo in a new folder. Thus you have two options 23 | - Use the "Clone an exising remote repository" command to clone your repo into a subfolder of your vault. You then have again two choices 24 | - Move all your files from the new folder (including `.git` !) into your vault root. 25 | - Open your new subfolder as a new vault. You may have to install the plugin again. 26 | - Run `git clone ` in the command line wherever you want your vault to be located. 27 | 4. Read on how to best configure your [[Tips-and-Tricks#Gitignore|.gitignore]] 28 | 29 | 30 | > [!info] iCloud and Git 31 | > 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. 32 | > - One solution is to put the git repository above your Obsidian vault. So that your vault is a sub directory of your git repository. 33 | > - 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: ` 34 | 35 | # Mobile 36 | The git implementation on mobile is **very unstable**! 37 | 38 | ## Restrictions 39 | 40 | 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. 41 | 42 | - SSH authentication is not supported ([isomorphic-git issue](https://github.com/isomorphic-git/isomorphic-git/issues/231)) 43 | - Repo size is limited, because of memory restrictions 44 | - Rebase merge strategy is not supported 45 | - Submodules are not supported 46 | 47 | ## Performance on mobile 48 | 49 | > [!danger] Warning 50 | > Depending on your device and available free RAM, Obsidian may 51 | > - crash on clone/pull 52 | > - create buffer overflow errors 53 | > - run indefinitely. 54 | > 55 | > 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. 56 | 57 | ## Start with existing remote repository 58 | 59 | ### Clone via plugin 60 | 61 | Follow these instructions for setting up an Obsidian Vault on a mobile device that is already backed up in a remote git repository. 62 | 63 | The instructions assume you are using [GitHub](https://github.com), but can be extrapolated to other providers. 64 | 65 | 1. Make sure any outstanding changes on all devices are pushed and reconciled with the remote repo. 66 | 2. Install Obsidian for Android or iOS. 67 | 3. Create a new vault (or point Obsidian to an empty directory). Do NOT select `Store in iCloud` if you are on iOS. 68 | 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). 69 | - Minimal permissions required are 70 | - "Read access to metadata" 71 | - "Read and Write access to contents and commit status" 72 | 5. In Obsidian settings, enable community plugins. Browse plugins to install Git. 73 | 6. Enable Git (on the same screen) 74 | 7. Go to Options for the Git plugin (bottom of main settings page, under Community Plugins section) 75 | 8. Under the "Authentication/Commit Author" section, fill in the username on your git server and your password/personal access token. 76 | 9. Don't touch any settings under "Advanced" 77 | 10. Exit plugin settings, open command palette, choose "Git: Clone existing remote repo". 78 | 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` 79 | - E.g. `https://github.com/denolehov/obsidian-git.git` 80 | 12. Follow instructions to determine the folder to place repo in and whether an `.obsidian` directory already exits. 81 | 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". 82 | 83 | ### Clone via Working Copy on iOS 84 | 85 | 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. 86 | 87 | 1. Make sure any outstanding changes on all devices are pushed and reconciled with the remote repo. 88 | 2. Install Obsidian for Android or iOS. 89 | 3. Create a new vault (or point Obsidian to an empty directory). Do NOT select `Store in iCloud` if you are on iOS. 90 | 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). 91 | - Minimal permissions required are 92 | - "Read access to metadata" 93 | - "Read and Write access to contents and commit status" 94 | 5. Swipe up and away Obsidian to fully close it. Open Working Copy app. 95 | 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. 96 | 7. Open Files app. 97 | 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). 98 | 9. Open Obsidian. 99 | 10. All your cloned files should be visible. 100 | 11. Install and enable the Git plugin. 101 | 12. Add your name/email to the "Authentication/Commit Author" section in the plugin settings. 102 | 13. Use the command palette to call the "Pull" command. 103 | 104 | ## Start with new repo 105 | 106 | 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]]. 107 | -------------------------------------------------------------------------------- /docs/Installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: 3 | - 02 Installation 4 | --- 5 | 6 | > [!important] 7 | > Although the plugin itself is desktop platform independent, an incorrect installation of Obsidian or Git may break the plugin. 8 | 9 | ## Plugin installation 10 | 11 | ### From within Obsidian 12 | Go to "Settings" -> "Community plugins" -> "Browse", search for "Git", install and enable it. 13 | 14 | ### Manual 15 | 1. Download `obsidian-git-.zip` from the [latest release](https://github.com/denolehov/obsidian-git/releases/latest) 16 | 2. Unpack the zip in `/.obsidian/plugins/obsidian-git` 17 | 3. Reload Obsidian (CTRL + R) 18 | 4. Go to settings and disable restricted mode 19 | 5. Enable `Git` 20 | 21 | # Windows 22 | 23 | Installing [GitHub Desktop](https://github.com/apps/desktop) is **not** enough! You need to install regular Git as well. 24 | ## Git installation 25 | 26 | > [!info] 27 | > Ensure you are using Git 2.29 or higher. 28 | 29 | Install Git from the official [website](https://git-scm.com/download/win) with all default settings. 30 | Make sure you have `3rd-party software` access enabled. 31 | 32 | ![[third-party-windows-git.png]] 33 | 34 | Enable Git Credential Manager. You can verify this for existing installations by executing the following. It should ouput `manager`. 35 | 36 | ```bash 37 | git config credential.helper 38 | ``` 39 | 40 | ![[credential-manager-windows-git.png]] 41 | 42 | 43 | # Linux 44 | 45 | ## Obsidian installation 46 | 47 | Known **supported** Obsidian installation methods: 48 | - AppImage 49 | 50 | Known **not fully supported** package managers 51 | - Snap (Snap puts Obsidian in a kind of sandbox, so that Obsidian can't access Git) 52 | - [Flatpak](https://flathub.org/apps/details/md.obsidian.Obsidian) can access Git, but not all system files, so it's not recommended. 53 | 54 | If you installed Obsidian a while ago via **Flatpak**, and it doesn't work, please run the following snippet. 55 | 56 | ``` 57 | $ flatpak update md.obsidian.Obsidian 58 | $ flatpak override --reset md.obsidian.Obsidian 59 | $ flatpak run md.obsidian.Obsidian 60 | ``` 61 | [Source of this snippet](https://github.com/flathub/md.obsidian.Obsidian/issues/5#issuecomment-736974662) 62 | 63 | ## MacOS 64 | 65 | Nothing specific. -------------------------------------------------------------------------------- /docs/Integration with other tools.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | # Git Large File Storage 4 | Git Large File Storage is supported, but may need a bit configuration for the plugin to find the `git-lfs` executable. 5 | 6 | ## MacOS 7 | 8 | 1. Make sure to install [git-lfs](https://git-lfs.com/) using `brew install git-lfs`. 9 | - This will install `git-lfs` to `/opt/homebrew/bin/`, which is probably not in your `PATH` environment variable when using Obsidian. 10 | 2. To make `/opt/homebrew/bin/` available in Obsidian, add `/opt/homebrew/bin/` to the "Additional PATH environment variables paths" setting under "Advanced". 11 | 3. Restart Obsidian. 12 | 13 | ## Linux 14 | 1. Make sure to install [git-lfs](https://git-lfs.com/). 15 | - 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. 16 | 2. Run `which git-lfs` in your terminal to get the installation path. It should output something of the form `/git-lfs. 17 | 2. Add the `` part of the previous step to the "Additional PATH environment variables paths" setting under "Advanced". 18 | 3. Restart Obsidian. 19 | 20 | ## Windows 21 | 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. 22 | 23 | # GPG Signing 24 | 25 | 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. 26 | One issue you might encounter though is the following: 27 | ``` 28 | Error: error: cannot run gpg: No such file or directory 29 | error: gpg failed to sign the data 30 | fatal: failed to write commit object 31 | ``` 32 | 33 | 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`. 34 | 35 | - You can either add that to the "Additional PATH environment variables" plugin setting to provide the gpg binary to your plugin installation only. 36 | - Or set it in your Git config via `git config --global gpg.program ` to set the gpg binary globally for all git repositories. 37 | 38 | Please create an issue if you encounter any issues and the documentation needs to be improved. -------------------------------------------------------------------------------- /docs/Line Authoring.md: -------------------------------------------------------------------------------- 1 | # Quick User Guide 2 | 3 | A quick showcase of all functionality. This feature is based on [git-blame](https://git-scm.com/docs/git-blame). 4 | 5 | ℹ️ The line author view only works in Live-Preview and Source mode - not in Reading mode. 6 | 7 | ℹ️ Currently, only Obsidian on desktop is supported. 8 | 9 | ℹ️ The recently released Obsidian v1.0 is fully supported. The images and GIFs in this document are however not yet updated. 10 | 11 | ## Activate 12 | 13 | ![](assets/line-author-activate.png) 14 | 15 | It can also be activated via Command Palette `Git: Toggle line author information`. 16 | 17 | ## Default line author information 18 | 19 | ![](assets/line-author-default.png) 20 | 21 | Shows the initials of the author as well as the authoring date in `YYYY-MM-DD` format. 22 | 23 | The `*` indicates, that the author and committer (or their timestamps) are different - i.e., due to a rebase. 24 | 25 | ## Commit hash and full name 26 | 27 | ![](assets/line-author-commit-hash-full-name.png) 28 | 29 | via config 30 | 31 | ![](assets/line-author-commit-hash-full-name-config.png) 32 | 33 | ## Natural language dates 34 | 35 | ![](assets/line-author-natural-language-dates.png) 36 | 37 | ## Custom date formats 38 | 39 | ![](assets/line-author-custom-dates.png) 40 | 41 | via config 42 | 43 | ![](assets/line-author-custom-dates-config.png) 44 | 45 | ## Commit time in local/author/UTC time-zone 46 | 47 | **UTC+0000/Z** 48 | 49 | The simplest option to start with is showing the time in `UTC+00:00/Z` time-zone. 50 | This is independent of both your local and the author's time-zone. 51 | It is shown with a suffix `Z` to avoid confusion with local time. 52 | 53 | ![](assets/line-author-tz-utc0000.png) 54 | 55 | This is the time displayed in the guter is the same for all users. 56 | 57 | **My local (default)** 58 | 59 | 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. 60 | 61 | ![](assets/line-author-tz-viewer-plus0100.png) 62 | 63 | Note, how the displayed time is `1h` ahead of the above `UTC+0000` time. 64 | 65 | **Author's local** 66 | 67 | 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?` 68 | 69 | This is independent of your local time-zone and the same time is displayed for all users. 70 | 71 | ![](assets/line-author-tz-author-local.png) 72 | 73 | **Configuration** 74 | 75 | ![](assets/line-author-tz-config.png) 76 | 77 | ## Age-based gutter colors 78 | 79 | The line gutter color is based on the age of the commit. It adapts to the dark/light mode automatically. 80 | 81 | ![](assets/line-author-dark-light.gif) 82 | 83 | Red-ish means newer and blue-ish means older. All commits at and above a certain maximum coloring 84 | age (configurable; default `1 year`) get the same strongest blue-ish color. 85 | 86 | The colors are configurable and the defaults are chosen to be accessible. 87 | 88 | ![](assets/line-author-color-config.png) 89 | 90 | ## Adjust text color CSS based on theme 91 | 92 | By default, the gutter text color uses `var(--text-muted)` which 93 | is whatever is defined by your theme. You can however, change it to a different CSS 94 | color or variable. 95 | 96 | ![](assets/line-author-text-color.png) 97 | 98 | Example: 99 | | `var(--text-muted)` | `var(--text-normal)` | 100 | |----------------------------------------------|-----------------------------------------------| 101 | | ![](assets/line-author-text-color-muted.png) | ![](assets/line-author-text-color-normal.png) | 102 | 103 | ## Copy commit hash 104 | 105 | ![](assets/line-author-copy-commit-hash.png) 106 | 107 | ## Quick configure gutter 108 | 109 | ![](assets/line-author-quick-configure-gutter.gif) 110 | 111 | ## New/uncommitted lines and files show `+++` 112 | 113 | ![](assets/line-author-untracked.png) 114 | 115 | ## Follow lines across cut-copy-paste-ing within same commit / all commits 116 | 117 | By default, each line shows the last commit, where it was changed. 118 | This means, that cut-copy-paste-ing lines will show the new commit, 119 | even though it was not originally written in that commit. 120 | 121 | ![](assets/line-author-follow-no-follow.png) 122 | 123 | However, if for instance following is set to `all commits`, then this is the result: 124 | 125 | ![](assets/line-author-follow-all-commits.png) 126 | 127 | Configuration: 128 | 129 | ![](assets/line-author-follow-config.png) 130 | 131 | ## Soft and unintrusive ansynchronous view updates 132 | 133 | Since computing the line author information takes time (due to a `git blame` shell invocation) 134 | the result appears delayed. To minimize distraction and improve user experience, 135 | the view is updated in a soft and unintrusive manner. 136 | 137 | When opening a file, a placeholder is shown meanwhile: 138 | 139 | ![](assets/line-author-soft-unintrusive-ux.gif) 140 | 141 | While editing, a placeholder is shown as well until the file is saved and the line author information is computed. 142 | 143 | ![](assets/line-author-soft-unintrusive-ux-editing.gif) 144 | 145 | ## Multi-line block support 146 | 147 | The markdown rendering of multiple lines as a combined block is also supported. 148 | In this case the newest of all lines is shown in the gutter. 149 | 150 | ![](assets/line-author-multi-line-newest.gif) 151 | 152 | ## Ignore whitespace and newlines 153 | 154 | This can be activated in the settings. 155 | 156 | | **Original** | **Changed with preserved whitespace** | **Changed with ignored whitespace** | 157 | | ---------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------- | 158 | | ![](assets/line-author-ignore-whitespace-before.png) | ![](assets/line-author-ignore-whitespace-preserved.png) | ![](assets/line-author-ignore-whitespace-ignored.png) | 159 | 160 | Note, how ignoring the whitespace does not mark the indented 161 | lines as changes, as only additional whitespace was added. 162 | 163 | ## Submodules support 164 | 165 | Line author information is fully supported in submodules. 166 | -------------------------------------------------------------------------------- /docs/Start here.md: -------------------------------------------------------------------------------- 1 | --- 2 | aliases: 3 | - "01 Start here" 4 | --- 5 | 6 | # Git plugin Documentation 7 | 8 | ## Topics 9 | - [[Installation|Installation]] 10 | - [[Getting Started|Getting Started]] 11 | - [[Authentication|Authentication]] 12 | - [[Integration with other tools]] 13 | - [[Features|Features]] 14 | - [[Tips-and-Tricks|Tips-and-Tricks]] 15 | - [[Common issues|Common Issues]] 16 | - [[Line Authoring|Line Authoring]] 17 | 18 | > [!warning] Obsidian installation on Linux 19 | > Please don't use Flatpak or Snap to install Obsidian on Linux. Learn more [[Installation#Linux|here]] 20 | 21 | 22 | ![[Getting Started#Performance on mobile]] 23 | 24 | ## What is Git? 25 | 26 | 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). 27 | 28 | > [!info] Git/GitHub is not a syncing service! 29 | > 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. 30 | 31 | 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. 32 | 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). 33 | 34 | 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. 35 | 36 | ## Terminology and concepts 37 | 38 | ### Backup - no longer in use 39 | For simplification, the term "Backup" refers to staging everything -> committing -> pulling -> pushing. 40 | 41 | ### Sync 42 | 43 | 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. 44 | 45 | ### Commit-and-sync 46 | 47 | 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. 48 | -------------------------------------------------------------------------------- /docs/Tips-and-Tricks.md: -------------------------------------------------------------------------------- 1 | # Tips and Tricks 2 | 3 | ## Gitignore 4 | 5 | To exclude cache files from the repository, create `.gitignore` file in the root of your vault and add the lines in the snippet below. 6 | There's also the `Edit .gitignore` command that will open the file in a modal. 7 | 8 | ``` 9 | # to exclude Obsidian's settings (including plugin and hotkey configurations) 10 | .obsidian/ 11 | 12 | # to only exclude plugin configuration. Might be useful to prevent some plugin from exposing sensitive data 13 | .obsidian/plugins 14 | 15 | # OR only to exclude workspace cache 16 | .obsidian/workspace.json 17 | 18 | # to exclude workspace cache specific to mobile devices 19 | .obsidian/workspace-mobile.json 20 | 21 | # Add below lines to exclude OS settings and caches 22 | .trash/ 23 | .DS_Store 24 | ``` 25 | 26 | 27 | ## Usage with Obsidian Sync 28 | 29 | 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. 30 | 31 | ### Use Git plugin only on one device 32 | 33 | 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. 34 | 35 | ### Use Git plugin, but not to pull your files 36 | 37 | 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. 38 | -------------------------------------------------------------------------------- /docs/assets/credential-manager-windows-git.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/credential-manager-windows-git.png -------------------------------------------------------------------------------- /docs/assets/line-author-activate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-activate.png -------------------------------------------------------------------------------- /docs/assets/line-author-color-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-color-config.png -------------------------------------------------------------------------------- /docs/assets/line-author-commit-hash-full-name-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-commit-hash-full-name-config.png -------------------------------------------------------------------------------- /docs/assets/line-author-commit-hash-full-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-commit-hash-full-name.png -------------------------------------------------------------------------------- /docs/assets/line-author-copy-commit-hash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-copy-commit-hash.png -------------------------------------------------------------------------------- /docs/assets/line-author-custom-dates-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-custom-dates-config.png -------------------------------------------------------------------------------- /docs/assets/line-author-custom-dates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-custom-dates.png -------------------------------------------------------------------------------- /docs/assets/line-author-dark-light.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-dark-light.gif -------------------------------------------------------------------------------- /docs/assets/line-author-default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-default.png -------------------------------------------------------------------------------- /docs/assets/line-author-follow-all-commits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-follow-all-commits.png -------------------------------------------------------------------------------- /docs/assets/line-author-follow-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-follow-config.png -------------------------------------------------------------------------------- /docs/assets/line-author-follow-no-follow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-follow-no-follow.png -------------------------------------------------------------------------------- /docs/assets/line-author-ignore-whitespace-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-ignore-whitespace-before.png -------------------------------------------------------------------------------- /docs/assets/line-author-ignore-whitespace-ignored.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-ignore-whitespace-ignored.png -------------------------------------------------------------------------------- /docs/assets/line-author-ignore-whitespace-preserved.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-ignore-whitespace-preserved.png -------------------------------------------------------------------------------- /docs/assets/line-author-multi-line-newest.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-multi-line-newest.gif -------------------------------------------------------------------------------- /docs/assets/line-author-natural-language-dates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-natural-language-dates.png -------------------------------------------------------------------------------- /docs/assets/line-author-quick-configure-gutter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-quick-configure-gutter.gif -------------------------------------------------------------------------------- /docs/assets/line-author-soft-unintrusive-ux-editing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-soft-unintrusive-ux-editing.gif -------------------------------------------------------------------------------- /docs/assets/line-author-soft-unintrusive-ux.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-soft-unintrusive-ux.gif -------------------------------------------------------------------------------- /docs/assets/line-author-text-color-muted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-text-color-muted.png -------------------------------------------------------------------------------- /docs/assets/line-author-text-color-normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-text-color-normal.png -------------------------------------------------------------------------------- /docs/assets/line-author-text-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-text-color.png -------------------------------------------------------------------------------- /docs/assets/line-author-tz-author-local.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-tz-author-local.png -------------------------------------------------------------------------------- /docs/assets/line-author-tz-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-tz-config.png -------------------------------------------------------------------------------- /docs/assets/line-author-tz-utc0000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-tz-utc0000.png -------------------------------------------------------------------------------- /docs/assets/line-author-tz-viewer-plus0100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-tz-viewer-plus0100.png -------------------------------------------------------------------------------- /docs/assets/line-author-untracked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/line-author-untracked.png -------------------------------------------------------------------------------- /docs/assets/third-party-windows-git.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/docs/assets/third-party-windows-git.png -------------------------------------------------------------------------------- /docs/dev/LineAuthorFeature.md: -------------------------------------------------------------------------------- 1 | # Line Authoring Feature - Developer Documentation 2 | 3 | - This feature was developed by [GollyTicker](https://github.com/GollyTicker). 4 | - [Feature documentation for users](https://publish.obsidian.md/git-doc/Line+Authoring) 5 | 6 | ## Architecture 7 | 8 | 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/): 9 | 10 | - Architecture Overview > (everything) 11 | - Data Model 12 | - Configuration 13 | - Facets 14 | - Transactions 15 | - View > (intro) 16 | - Extending Codemirror 17 | - State Fields 18 | 19 | Furthermore, the following concepts are necessary: 20 | 21 | - [EditorState](https://codemirror.net/docs/ref/#state.EditorState) 22 | - [State Field](https://codemirror.net/docs/ref/#state.StateField) 23 | - [Transaction](https://codemirror.net/docs/ref/#state.Transaction) 24 | - [Creating a transaction](https://codemirror.net/docs/ref/#state.EditorState.update) 25 | - [Annotation within a transaction](https://codemirror.net/docs/ref/#state.Annotation) 26 | - [ChangeSet](https://codemirror.net/docs/ref/#state.ChangeSet) (for the unsaved changes gutter update) 27 | - [Exmaple: Document Changes](https://codemirror.net/examples/change/) 28 | - [Example: Configuratoin and Extension](https://codemirror.net/examples/config/) 29 | 30 | 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. 31 | 32 | 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. 33 | 34 | We achieve the goal via the following steps: 35 | 36 | 1. Every new editor pane in Obsidian subscribes itself 37 | by its filepath ([LineAuthoringSubcriber](/src/lineAuthor/control.ts)) 38 | and listens in an internal publish-subscriber-model 39 | ([eventsPerFilepath.ts](/src/lineAuthor/eventsPerFilepath.ts)) 40 | for updates on that filepath. 41 | 2. Any changed file in the Obsidian Vault or anytime when a new 42 | file is opened, [lineAuthorProvider](/src/lineAuthor/lineAuthoProvider.ts) 43 | initiates the asynchronous computation of the 44 | [LineAuthoring](/src/lineAuthor/model.ts) 45 | via [simpleGit.ts](/src/simpleGit.ts) - 46 | which parses the output of `git-blame`. 47 | 3. Once the `LineAuthoring` is computed, the publish-subscriber-model is notified 48 | of the new value for the corresponding filepath. 49 | 4. The notified `LineAuthoringSubcriber` creates a new transaction 50 | (via [newComputationResultAsTransaction](/src/lineAuthor/model.ts)) 51 | containing the `LineAuthoring`. 52 | 5. The `LineAuthoringSubscriber` [dispatches the transaction 53 | on the current EditorView](https://codemirror.net/docs/ref/#view.EditorView.dispatch). 54 | 6. The [StateField's update](https://codemirror.net/docs/ref/#state.StateField^define^config.update) 55 | method is called by Codemirror due to the dispatched transaction. 56 | The [lineAuthorState](/src/lineAuthor/model.ts) updates itself with the 57 | newest `LineAuthoring`, if it one was provided in the transaction. 58 | 7. The [lineAuthorGutter](/src/lineAuthor/view/view.ts) is automatically re-rendered, 59 | due to the dispatch and the changes of the state-fields. The re-rendering 60 | now accesses the newest state-field values - resulting in a new DOM. 61 | 62 | ## Development 63 | 64 | You can use this test-vault https://github.com/GollyTicker/obsidian-git-test-vault-online. 65 | 66 | Once the watchmode npm is started, one can simply open the `test-vault` in Obsidian to 67 | test the plugin. The Git plugin files are symbolic links to the 68 | automatically re-compiled files at repository root level. 69 | 70 | One can additionally use the 71 | [docker-setup from this branch for a reproduceable developer setup](https://github.com/GollyTicker/obsidian-git/tree/docker-setup). 72 | 73 | ## Edge cases and error cases 74 | 75 | These cases should be tested, when changes to this feature have been made. 76 | 77 | - running outside of a git repository 78 | - opening an untracked file 79 | - opening and closing obsidian windows of panes/notes 80 | - notes with a starting "--" in their filename 81 | - special characters in filenames 82 | - unicode filenames 83 | - empty file 84 | - file with populated last line 85 | - multi-line block with differeing line commits 86 | - examples for moving/copy-following 87 | - submodules 88 | - vault root != repository root 89 | - error in git blame result 90 | - open multiple files simultanously 91 | - open same file multiple times - and edit 92 | - open same files in multiple windows - and edit 93 | - open empty tracked file and make edits. quick update should respond sensibly 94 | - open file in a large, complex real-world vault with unknown characteristics 95 | (the private vault of the developer GollyTicker suffices) and repeatedly press Enter in a tracked file. 96 | - We expect no errors, but after adding the unsaved changed gutter update feature, 97 | an early bu was present, where errors would occur during rendering and the view would become messed up. 98 | - UI should render correctly regardless of whether line numbers are shown as well or not. 99 | - [[see obsidan forum discussion](https://forum.obsidian.md/t/added-editor-gutter-overlaps-and-obscures-editor-content/45217) 100 | - indentation changes and changes after last line (without trailing newline) with 'Ignored whitespace' enabled/disabled 101 | - [Unsaved Changes Gutter Update Scenario](#unsaved-changes-gutter-update-scenario) 102 | - commit file in a different time-zone than the current Obsidian user 103 | - check that time-zone "local" formatting is correct 104 | - time-zone "UTC" should always show the same result regardless of the local time-zone 105 | - line authoring id correctly uses submodule HEAD revision rather than super-project. 106 | 107 | - 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. 108 | 109 | 1. remember the lineAuthoringId A for a file in a submodule in the vault. 110 | 111 | - it uses the HEAD of the git super-project rather than of the submodule the file is contained in. 112 | 113 | 2. add a few lines in the file. The plugin will correctly detect the changed file-contents 114 | hash, which will trigger re-computation and re-render. 115 | 3. commit the changes in the submodule - without making a corresponding commit in the super-project. 116 | 4. Close the file and re-open it in Obsidian. 117 | 118 | - In the submodule, the HEAD has changed - but not in the super-project. 119 | - Since the file path and file contents are same after committing, they haven't changed. 120 | - The current cache key doesn't detect this change and hence the view isn't updated. 121 | - Reloading Obsidian entirely will evict the cache - and the line authoring will be shown correctly again. 122 | 123 | ### Unsaved Changes Gutter Update Scenario 124 | 125 | This scenario contains two main cases to test: 126 | 127 | #### 1. Untracked file 128 | 129 | 1. Open an untracked file. It should show +++ everywhere. 130 | 2. Make insertions, deletions and in-line changes. It should always show +++. 131 | 132 | #### 2. Tracked file 133 | 134 | 1. Open a tracked file with different line author dates and colors 135 | 2. Make insertions, deletions and in-line changes. 136 | 137 | - It should first show % until the changes are saved and the line authoring is computed. 138 | - The % should preserving the color of the changed line and insertions/deletions should shift the 139 | line authoring for subsequent lines accordingly 140 | 141 | 3. Make multi-line insertions, deletions and in-line changes (e.g. via cut-copy-pasting of blocks of text). 142 | 143 | - Hint: Use Ctrl+Z as well. 144 | - The behavior should be same as above. 145 | 146 | 4. Make changes at the intersection of unsaved and saved changes. The result should be consistent with above. 147 | 148 | ## Potential Future Improvements 149 | 150 | - show commit info when click/hover on gutter 151 | - show / highlight diff when hover/click on gutter 152 | - small tooltip widget when hovering/right-clicking on line author gutter with author/hash, etc. 153 | - show deleted lines 154 | - interpret new 'newline' at end of line as non-change to make gutter change marking more intuitive. 155 | - [one option is to add a setting which switches between compatibility-mode and comfort-mode](https://github.com/denolehov/obsidian-git/pull/288) 156 | - distinguish untracked and changed line (e.g. "~" and "+") 157 | - use addMomentFormat in settings.ts when configuring the line author date format. 158 | - main.ts: refreshUpdatedHead(): Detect, if the head has changed from outside of Git (e.g. script) and run this callback then. 159 | - Avoid "Uncaught illegal access error" when closing a separate Obsidian window. 160 | It doesn't seem to have any impact on UX yet though... 161 | - Unique initials option: [work in progress branch](https://github.com/GollyTicker/obsidian-git/tree/line-author-unique-initials) 162 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import esbuildSvelte from "esbuild-svelte"; 3 | import process from "process"; 4 | import { sveltePreprocess } from "svelte-preprocess"; 5 | 6 | const banner = `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source visit the plugins github repository (https://github.com/denolehov/obsidian-git) 9 | */ 10 | `; 11 | 12 | const prod = process.argv[2] === "production"; 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "child_process", 24 | "fs", 25 | "path", 26 | "moment", 27 | "node:events", 28 | "@codemirror/autocomplete", 29 | "@codemirror/collab", 30 | "@codemirror/commands", 31 | "@codemirror/language", 32 | "@codemirror/lint", 33 | "@codemirror/search", 34 | "@codemirror/state", 35 | "@codemirror/view", 36 | "@lezer/common", 37 | "@lezer/highlight", 38 | "@lezer/lr", 39 | ], 40 | format: "cjs", 41 | target: "es2018", 42 | logLevel: "info", 43 | sourcemap: prod ? false : "inline", 44 | treeShaking: true, 45 | platform: "browser", 46 | minify: prod, 47 | conditions: [prod ? "production" : "development"], // https://www.npmjs.com/package/esm-env 48 | plugins: [ 49 | esbuildSvelte({ 50 | compilerOptions: { 51 | css: "injected", 52 | dev: !prod, 53 | }, 54 | filterWarnings: (warning) => { 55 | if (warning.code.startsWith("a11y-")) return false; 56 | return true; 57 | }, 58 | preprocess: sveltePreprocess(), 59 | }), 60 | ], 61 | inject: ["polyfill_buffer.js"], 62 | outfile: "main.js", 63 | }); 64 | 65 | if (prod) { 66 | await context.rebuild(); 67 | process.exit(0); 68 | } else { 69 | await context.watch(); 70 | } 71 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import svelteParser from "svelte-eslint-parser"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | import eslint from "@eslint/js"; 4 | import tseslint from "typescript-eslint"; 5 | import eslintPluginSvelte from "eslint-plugin-svelte"; 6 | 7 | export default tseslint.config( 8 | { 9 | ignores: ["**/node_modules/", "**/main.js"], 10 | }, 11 | eslint.configs.recommended, 12 | ...tseslint.configs.recommendedTypeChecked, 13 | ...eslintPluginSvelte.configs["flat/prettier"], 14 | { 15 | languageOptions: { 16 | parserOptions: { 17 | projectService: true, 18 | tsconfigRootDir: import.meta.dirname, 19 | }, 20 | }, 21 | rules: { 22 | "@typescript-eslint/no-unused-vars": [ 23 | "error", 24 | { 25 | args: "all", 26 | argsIgnorePattern: "^_", 27 | caughtErrors: "all", 28 | caughtErrorsIgnorePattern: "^_", 29 | destructuredArrayIgnorePattern: "^_", 30 | varsIgnorePattern: "^_", 31 | ignoreRestSiblings: true, 32 | }, 33 | ], 34 | }, 35 | }, 36 | { 37 | files: ["**/*.svelte"], 38 | languageOptions: { 39 | parser: svelteParser, 40 | parserOptions: { 41 | extraFileExtensions: [".svelte"], 42 | parser: tsParser, 43 | }, 44 | }, 45 | rules: { 46 | "no-undef": "off", 47 | }, 48 | } 49 | ); 50 | -------------------------------------------------------------------------------- /images/diff-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/images/diff-view.png -------------------------------------------------------------------------------- /images/history-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/images/history-view.png -------------------------------------------------------------------------------- /images/source-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-git/3fbd59365085c3084d0b4f654db382b086367f23/images/source-view.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Vinzent", 3 | "authorUrl": "https://github.com/Vinzent03", 4 | "id": "obsidian-git", 5 | "name": "Git", 6 | "description": "Integrate Git version control with automatic backup and other advanced features.", 7 | "isDesktopOnly": false, 8 | "fundingUrl": "https://ko-fi.com/vinzent", 9 | "version": "2.33.0" 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-git", 3 | "version": "2.33.0", 4 | "description": "Integrate Git version control with automatic backup and other advanced features in Obsidian.md", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs dev", 8 | "build": "node esbuild.config.mjs production", 9 | "release": "standard-version", 10 | "lint": "eslint src", 11 | "format": "prettier --check src", 12 | "tsc": "tsc --noEmit", 13 | "svelte": "svelte-check", 14 | "all": "pnpm run tsc && pnpm run svelte && pnpm run format && pnpm run lint" 15 | }, 16 | "keywords": [], 17 | "author": "Vinzent03", 18 | "license": "MIT", 19 | "standard-version": { 20 | "t": "" 21 | }, 22 | "engines": { 23 | "node": ">=18", 24 | "pnpm": ">=9" 25 | }, 26 | "devDependencies": { 27 | "@eslint/js": "^9.14.0", 28 | "@types/debug": "^4.1.12", 29 | "@types/deep-equal": "^1.0.4", 30 | "@types/diff": "^5.2.3", 31 | "@types/node": "^22.7.5", 32 | "@typescript-eslint/parser": "^8.8.1", 33 | "esbuild": "^0.24.0", 34 | "esbuild-svelte": "^0.8.2", 35 | "eslint": "^9.12.0", 36 | "eslint-plugin-svelte": "^2.45.1", 37 | "obsidian": "^1.7.2", 38 | "prettier": "3.3.2", 39 | "prettier-plugin-svelte": "^3.2.7", 40 | "scss": "^0.2.4", 41 | "standard-version": "^9.5.0", 42 | "svelte-check": "^4.0.5", 43 | "svelte-preprocess": "^6.0.3", 44 | "svelte-eslint-parser": "^0.43.0", 45 | "tslib": "^2.7.0", 46 | "typescript": "^5.6.3", 47 | "typescript-eslint": "^8.8.1" 48 | }, 49 | "dependencies": { 50 | "@codemirror/commands": "^6.7.1", 51 | "@codemirror/merge": "^6.7.5", 52 | "@codemirror/search": "^6.5.7", 53 | "@codemirror/state": "^6.4.1", 54 | "@codemirror/view": "^6.34.2", 55 | "buffer": "^6.0.3", 56 | "codemirror": "^6.0.1", 57 | "css-color-converter": "^2.0.0", 58 | "debug": "^4.3.7", 59 | "deep-equal": "^2.2.3", 60 | "diff": "^7.0.0", 61 | "diff2html": "^3.4.48", 62 | "isomorphic-git": "^1.27.1", 63 | "js-sha256": "^0.9.0", 64 | "obsidian-community-lib": "github:Vinzent03/obsidian-community-lib#upgrade", 65 | "simple-git": "^3.27.0", 66 | "supports-color": "^9.4.0", 67 | "svelte": "^5.0.0" 68 | }, 69 | "moduleFileExtensions": [ 70 | "js", 71 | "ts", 72 | "svelte" 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /polyfill_buffer.js: -------------------------------------------------------------------------------- 1 | import { Platform } from 'obsidian'; 2 | let buffer; 3 | if (Platform.isMobileApp) { 4 | buffer = require('buffer/index.js').Buffer 5 | } else { 6 | buffer = global.Buffer 7 | } 8 | 9 | export const Buffer = buffer; 10 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "obsidian"; 2 | import type { ObsidianGitSettings } from "./types"; 3 | export const DATE_FORMAT = "YYYY-MM-DD"; 4 | export const DATE_TIME_FORMAT_MINUTES = `${DATE_FORMAT} HH:mm`; 5 | export const DATE_TIME_FORMAT_SECONDS = `${DATE_FORMAT} HH:mm:ss`; 6 | 7 | export const GIT_LINE_AUTHORING_MOVEMENT_DETECTION_MINIMAL_LENGTH = 40; 8 | 9 | export const CONFLICT_OUTPUT_FILE = "conflict-files-obsidian-git.md"; 10 | 11 | export const DEFAULT_SETTINGS: ObsidianGitSettings = { 12 | commitMessage: "vault backup: {{date}}", 13 | autoCommitMessage: "vault backup: {{date}}", 14 | commitDateFormat: DATE_TIME_FORMAT_SECONDS, 15 | autoSaveInterval: 0, 16 | autoPushInterval: 0, 17 | autoPullInterval: 0, 18 | autoPullOnBoot: false, 19 | disablePush: false, 20 | pullBeforePush: true, 21 | disablePopups: false, 22 | showErrorNotices: true, 23 | disablePopupsForNoChanges: false, 24 | listChangedFilesInMessageBody: false, 25 | showStatusBar: true, 26 | updateSubmodules: false, 27 | syncMethod: "merge", 28 | customMessageOnAutoBackup: false, 29 | autoBackupAfterFileChange: false, 30 | treeStructure: false, 31 | refreshSourceControl: Platform.isDesktopApp, 32 | basePath: "", 33 | differentIntervalCommitAndPush: false, 34 | changedFilesInStatusBar: false, 35 | showedMobileNotice: false, 36 | refreshSourceControlTimer: 7000, 37 | showBranchStatusBar: true, 38 | setLastSaveToLastCommit: false, 39 | submoduleRecurseCheckout: false, 40 | gitDir: "", 41 | showFileMenu: true, 42 | authorInHistoryView: "hide", 43 | dateInHistoryView: false, 44 | diffStyle: "split", 45 | lineAuthor: { 46 | show: false, 47 | followMovement: "inactive", 48 | authorDisplay: "initials", 49 | showCommitHash: false, 50 | dateTimeFormatOptions: "date", 51 | dateTimeFormatCustomString: DATE_TIME_FORMAT_MINUTES, 52 | dateTimeTimezone: "viewer-local", 53 | coloringMaxAge: "1y", 54 | // colors were picked via: 55 | // https://color.adobe.com/de/create/color-accessibility 56 | colorNew: { r: 255, g: 150, b: 150 }, 57 | colorOld: { r: 120, g: 160, b: 255 }, 58 | textColorCss: "var(--text-muted)", // more pronounced than line numbers, but less than the content text 59 | ignoreWhitespace: false, 60 | gutterSpacingFallbackLength: 5, 61 | }, 62 | }; 63 | 64 | export const SOURCE_CONTROL_VIEW_CONFIG = { 65 | type: "git-view", 66 | name: "Source Control", 67 | icon: "git-pull-request", 68 | }; 69 | 70 | export const HISTORY_VIEW_CONFIG = { 71 | type: "git-history-view", 72 | name: "History", 73 | icon: "history", 74 | }; 75 | 76 | export const SPLIT_DIFF_VIEW_CONFIG = { 77 | type: "split-diff-view", 78 | name: "Diff view", 79 | icon: "diff", 80 | }; 81 | export const DIFF_VIEW_CONFIG = { 82 | type: "diff-view", 83 | name: "Diff View", 84 | icon: "git-pull-request", 85 | }; 86 | 87 | export const DEFAULT_WIN_GIT_PATH = "C:\\Program Files\\Git\\cmd\\git.exe"; 88 | export const ASK_PASS_INPUT_FILE = "git_credentials_input"; 89 | export const ASK_PASS_SCRIPT_FILE = "obsidian_askpass.sh"; 90 | 91 | export const ASK_PASS_SCRIPT = `#!/bin/sh 92 | 93 | PROMPT="$1" 94 | TEMP_FILE="$OBSIDIAN_GIT_CREDENTIALS_INPUT" 95 | 96 | cleanup() { 97 | rm -f "$TEMP_FILE" "$TEMP_FILE.response" 98 | } 99 | trap cleanup EXIT 100 | 101 | echo "$PROMPT" > "$TEMP_FILE" 102 | 103 | while [ ! -e "$TEMP_FILE.response" ]; do 104 | if [ ! -e "$TEMP_FILE" ]; then 105 | echo "Trigger file got removed: Abort" >&2 106 | exit 1 107 | fi 108 | sleep 0.1 109 | done 110 | 111 | RESPONSE=$(cat "$TEMP_FILE.response") 112 | 113 | echo "$RESPONSE" 114 | `; 115 | 116 | /** 117 | * Copied from https://github.com/sindresorhus/binary-extensions/blob/main/binary-extensions.json 118 | */ 119 | export const BINARY_EXTENSIONS = [ 120 | "3dm", 121 | "3ds", 122 | "3g2", 123 | "3gp", 124 | "7z", 125 | "a", 126 | "aac", 127 | "adp", 128 | "afdesign", 129 | "afphoto", 130 | "afpub", 131 | "ai", 132 | "aif", 133 | "aiff", 134 | "alz", 135 | "ape", 136 | "apk", 137 | "appimage", 138 | "ar", 139 | "arj", 140 | "asf", 141 | "au", 142 | "avi", 143 | "bak", 144 | "baml", 145 | "bh", 146 | "bin", 147 | "bk", 148 | "bmp", 149 | "btif", 150 | "bz2", 151 | "bzip2", 152 | "cab", 153 | "caf", 154 | "cgm", 155 | "class", 156 | "cmx", 157 | "cpio", 158 | "cr2", 159 | "cur", 160 | "dat", 161 | "dcm", 162 | "deb", 163 | "dex", 164 | "djvu", 165 | "dll", 166 | "dmg", 167 | "dng", 168 | "doc", 169 | "docm", 170 | "docx", 171 | "dot", 172 | "dotm", 173 | "dra", 174 | "DS_Store", 175 | "dsk", 176 | "dts", 177 | "dtshd", 178 | "dvb", 179 | "dwg", 180 | "dxf", 181 | "ecelp4800", 182 | "ecelp7470", 183 | "ecelp9600", 184 | "egg", 185 | "eol", 186 | "eot", 187 | "epub", 188 | "exe", 189 | "f4v", 190 | "fbs", 191 | "fh", 192 | "fla", 193 | "flac", 194 | "flatpak", 195 | "fli", 196 | "flv", 197 | "fpx", 198 | "fst", 199 | "fvt", 200 | "g3", 201 | "gh", 202 | "gif", 203 | "graffle", 204 | "gz", 205 | "gzip", 206 | "h261", 207 | "h263", 208 | "h264", 209 | "icns", 210 | "ico", 211 | "ief", 212 | "img", 213 | "ipa", 214 | "iso", 215 | "jar", 216 | "jpeg", 217 | "jpg", 218 | "jpgv", 219 | "jpm", 220 | "jxr", 221 | "key", 222 | "ktx", 223 | "lha", 224 | "lib", 225 | "lvp", 226 | "lz", 227 | "lzh", 228 | "lzma", 229 | "lzo", 230 | "m3u", 231 | "m4a", 232 | "m4v", 233 | "mar", 234 | "mdi", 235 | "mht", 236 | "mid", 237 | "midi", 238 | "mj2", 239 | "mka", 240 | "mkv", 241 | "mmr", 242 | "mng", 243 | "mobi", 244 | "mov", 245 | "movie", 246 | "mp3", 247 | "mp4", 248 | "mp4a", 249 | "mpeg", 250 | "mpg", 251 | "mpga", 252 | "mxu", 253 | "nef", 254 | "npx", 255 | "numbers", 256 | "nupkg", 257 | "o", 258 | "odp", 259 | "ods", 260 | "odt", 261 | "oga", 262 | "ogg", 263 | "ogv", 264 | "otf", 265 | "ott", 266 | "pages", 267 | "pbm", 268 | "pcx", 269 | "pdb", 270 | "pdf", 271 | "pea", 272 | "pgm", 273 | "pic", 274 | "png", 275 | "pnm", 276 | "pot", 277 | "potm", 278 | "potx", 279 | "ppa", 280 | "ppam", 281 | "ppm", 282 | "pps", 283 | "ppsm", 284 | "ppsx", 285 | "ppt", 286 | "pptm", 287 | "pptx", 288 | "psd", 289 | "pya", 290 | "pyc", 291 | "pyo", 292 | "pyv", 293 | "qt", 294 | "rar", 295 | "ras", 296 | "raw", 297 | "resources", 298 | "rgb", 299 | "rip", 300 | "rlc", 301 | "rmf", 302 | "rmvb", 303 | "rpm", 304 | "rtf", 305 | "rz", 306 | "s3m", 307 | "s7z", 308 | "scpt", 309 | "sgi", 310 | "shar", 311 | "snap", 312 | "sil", 313 | "sketch", 314 | "slk", 315 | "smv", 316 | "snk", 317 | "so", 318 | "stl", 319 | "suo", 320 | "sub", 321 | "swf", 322 | "tar", 323 | "tbz", 324 | "tbz2", 325 | "tga", 326 | "tgz", 327 | "thmx", 328 | "tif", 329 | "tiff", 330 | "tlz", 331 | "ttc", 332 | "ttf", 333 | "txz", 334 | "udf", 335 | "uvh", 336 | "uvi", 337 | "uvm", 338 | "uvp", 339 | "uvs", 340 | "uvu", 341 | "viv", 342 | "vob", 343 | "war", 344 | "wav", 345 | "wax", 346 | "wbmp", 347 | "wdp", 348 | "weba", 349 | "webm", 350 | "webp", 351 | "whl", 352 | "wim", 353 | "wm", 354 | "wma", 355 | "wmv", 356 | "wmx", 357 | "woff", 358 | "woff2", 359 | "wrm", 360 | "wvx", 361 | "xbm", 362 | "xif", 363 | "xla", 364 | "xlam", 365 | "xls", 366 | "xlsb", 367 | "xlsm", 368 | "xlsx", 369 | "xlt", 370 | "xltm", 371 | "xltx", 372 | "xm", 373 | "xmind", 374 | "xpi", 375 | "xpm", 376 | "xwd", 377 | "xz", 378 | "z", 379 | "zip", 380 | "zipx", 381 | ]; 382 | -------------------------------------------------------------------------------- /src/externalLibTypes.d.ts: -------------------------------------------------------------------------------- 1 | declare module "css-color-converter" { 2 | /* The following list of type definitions is incomplete! */ 3 | 4 | class Color { 5 | toRgbaArray(): [number, number, number, number]; 6 | toRgbString(): string; 7 | toRgbaString(): string; 8 | toHslString(): string; 9 | toHslaString(): string; 10 | toHexString(): string; 11 | } 12 | function fromString(str: string): Color | null; 13 | } 14 | -------------------------------------------------------------------------------- /src/gitManager/myAdapter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/require-await */ 2 | /* eslint-disable @typescript-eslint/only-throw-error */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 5 | /* eslint-disable @typescript-eslint/no-explicit-any */ 6 | import type { DataAdapter, Vault } from "obsidian"; 7 | import { normalizePath, TFile } from "obsidian"; 8 | import type ObsidianGit from "../main"; 9 | 10 | export class MyAdapter { 11 | promises: any = {}; 12 | adapter: DataAdapter; 13 | vault: Vault; 14 | index: Buffer | undefined; 15 | indexctime: number | undefined; 16 | indexmtime: number | undefined; 17 | lastBasePath: string | undefined; 18 | 19 | constructor( 20 | vault: Vault, 21 | private readonly plugin: ObsidianGit 22 | ) { 23 | this.adapter = vault.adapter; 24 | this.vault = vault; 25 | this.lastBasePath = this.plugin.settings.basePath; 26 | 27 | this.promises.readFile = this.readFile.bind(this); 28 | this.promises.writeFile = this.writeFile.bind(this); 29 | this.promises.readdir = this.readdir.bind(this); 30 | this.promises.mkdir = this.mkdir.bind(this); 31 | this.promises.rmdir = this.rmdir.bind(this); 32 | this.promises.stat = this.stat.bind(this); 33 | this.promises.unlink = this.unlink.bind(this); 34 | this.promises.lstat = this.lstat.bind(this); 35 | this.promises.readlink = this.readlink.bind(this); 36 | this.promises.symlink = this.symlink.bind(this); 37 | } 38 | async readFile(path: string, opts: any) { 39 | this.maybeLog("Read: " + path + JSON.stringify(opts)); 40 | if (opts == "utf8" || opts.encoding == "utf8") { 41 | const file = this.vault.getAbstractFileByPath(path); 42 | if (file instanceof TFile) { 43 | this.maybeLog("Reuse"); 44 | 45 | return this.vault.read(file); 46 | } else { 47 | return this.adapter.read(path); 48 | } 49 | } else { 50 | if (path.endsWith(this.gitDir + "/index")) { 51 | if (this.plugin.settings.basePath != this.lastBasePath) { 52 | this.clearIndex(); 53 | this.lastBasePath = this.plugin.settings.basePath; 54 | return this.adapter.readBinary(path); 55 | } 56 | return this.index ?? this.adapter.readBinary(path); 57 | } 58 | const file = this.vault.getAbstractFileByPath(path); 59 | if (file instanceof TFile) { 60 | this.maybeLog("Reuse"); 61 | 62 | return this.vault.readBinary(file); 63 | } else { 64 | return this.adapter.readBinary(path); 65 | } 66 | } 67 | } 68 | async writeFile(path: string, data: string | Buffer) { 69 | this.maybeLog("Write: " + path); 70 | 71 | if (typeof data === "string") { 72 | const file = this.vault.getAbstractFileByPath(path); 73 | if (file instanceof TFile) { 74 | return this.vault.modify(file, data); 75 | } else { 76 | return this.adapter.write(path, data); 77 | } 78 | } else { 79 | if (path.endsWith(this.gitDir + "/index")) { 80 | this.index = data; 81 | this.indexmtime = Date.now(); 82 | // this.adapter.writeBinary(path, data); 83 | } else { 84 | const file = this.vault.getAbstractFileByPath(path); 85 | if (file instanceof TFile) { 86 | return this.vault.modifyBinary(file, data); 87 | } else { 88 | return this.adapter.writeBinary(path, data); 89 | } 90 | } 91 | } 92 | } 93 | async readdir(path: string) { 94 | if (path === ".") path = "/"; 95 | const res = await this.adapter.list(path); 96 | const all = [...res.files, ...res.folders]; 97 | let formattedAll; 98 | if (path !== "/") { 99 | formattedAll = all.map((e) => 100 | normalizePath(e.substring(path.length)) 101 | ); 102 | } else { 103 | formattedAll = all; 104 | } 105 | return formattedAll; 106 | } 107 | async mkdir(path: string) { 108 | return this.adapter.mkdir(path); 109 | } 110 | async rmdir(path: string, opts: any) { 111 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 112 | return this.adapter.rmdir(path, opts?.options?.recursive ?? false); 113 | } 114 | async stat(path: string) { 115 | if (path.endsWith(this.gitDir + "/index")) { 116 | if ( 117 | this.index !== undefined && 118 | this.indexctime != undefined && 119 | this.indexmtime != undefined 120 | ) { 121 | return { 122 | isFile: () => true, 123 | isDirectory: () => false, 124 | isSymbolicLink: () => false, 125 | size: this.index.length, 126 | type: "file", 127 | ctimeMs: this.indexctime, 128 | mtimeMs: this.indexmtime, 129 | }; 130 | } else { 131 | const stat = await this.adapter.stat(path); 132 | if (stat == undefined) { 133 | throw { code: "ENOENT" }; 134 | } 135 | this.indexctime = stat.ctime; 136 | this.indexmtime = stat.mtime; 137 | return { 138 | ctimeMs: stat.ctime, 139 | mtimeMs: stat.mtime, 140 | size: stat.size, 141 | type: "file", 142 | isFile: () => true, 143 | isDirectory: () => false, 144 | isSymbolicLink: () => false, 145 | }; 146 | } 147 | } 148 | if (path === ".") path = "/"; 149 | const file = this.vault.getAbstractFileByPath(path); 150 | this.maybeLog("Stat: " + path); 151 | if (file instanceof TFile) { 152 | this.maybeLog("Reuse stat"); 153 | return { 154 | ctimeMs: file.stat.ctime, 155 | mtimeMs: file.stat.mtime, 156 | size: file.stat.size, 157 | type: "file", 158 | isFile: () => true, 159 | isDirectory: () => false, 160 | isSymbolicLink: () => false, 161 | }; 162 | } else { 163 | const stat = await this.adapter.stat(path); 164 | if (stat) { 165 | return { 166 | ctimeMs: stat.ctime, 167 | mtimeMs: stat.mtime, 168 | size: stat.size, 169 | type: stat.type === "folder" ? "directory" : stat.type, 170 | isFile: () => stat.type === "file", 171 | isDirectory: () => stat.type === "folder", 172 | isSymbolicLink: () => false, 173 | }; 174 | } else { 175 | // used to determine whether a file exists or not 176 | throw { code: "ENOENT" }; 177 | } 178 | } 179 | } 180 | async unlink(path: string) { 181 | return this.adapter.remove(path); 182 | } 183 | async lstat(path: string) { 184 | return this.stat(path); 185 | } 186 | async readlink(path: string) { 187 | throw new Error(`readlink of (${path}) is not implemented.`); 188 | } 189 | async symlink(path: string) { 190 | throw new Error(`symlink of (${path}) is not implemented.`); 191 | } 192 | 193 | async saveAndClear(): Promise { 194 | if (this.index !== undefined) { 195 | await this.adapter.writeBinary( 196 | this.plugin.gitManager.getRelativeVaultPath( 197 | this.gitDir + "/index" 198 | ), 199 | this.index, 200 | { 201 | ctime: this.indexctime, 202 | mtime: this.indexmtime, 203 | } 204 | ); 205 | } 206 | this.clearIndex(); 207 | } 208 | 209 | clearIndex() { 210 | this.index = undefined; 211 | this.indexctime = undefined; 212 | this.indexmtime = undefined; 213 | } 214 | 215 | private get gitDir(): string { 216 | return this.plugin.settings.gitDir || ".git"; 217 | } 218 | 219 | private maybeLog(_: string) { 220 | // console.log(text); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/lineAuthor/control.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState } from "@codemirror/state"; 2 | import { StateField } from "@codemirror/state"; 3 | import type { EditorView } from "@codemirror/view"; 4 | import { editorEditorField, editorInfoField } from "obsidian"; 5 | import { eventsPerFilePathSingleton } from "src/lineAuthor/eventsPerFilepath"; 6 | import type { LineAuthoring, LineAuthoringId } from "src/lineAuthor/model"; 7 | import { newComputationResultAsTransaction } from "src/lineAuthor/model"; 8 | 9 | /* 10 | ================== CONTROL ====================== 11 | Contains classes and function responsible for updating the model 12 | given the changes in the Obsidian UI. 13 | */ 14 | 15 | /** 16 | * Subscribes to changes in the files on a specific filepath. 17 | * It knows its corresponding editor and initiates editor view changes. 18 | */ 19 | export class LineAuthoringSubscriber { 20 | private lastSeenPath: string; // remember path to detect and adapt to renames 21 | 22 | constructor(private state: EditorState) { 23 | this.subscribeMe(); 24 | } 25 | 26 | public notifyLineAuthoring(id: LineAuthoringId, la: LineAuthoring) { 27 | if (this.view === undefined) { 28 | console.warn( 29 | `Git: View is not defined for editor cache key. Unforeseen situation. id: ${id}` 30 | ); 31 | return; 32 | } 33 | 34 | // using "this.state" directly here leads to some problems when closing panes. Hence, "this.view.state" 35 | const state = this.view.state; 36 | const transaction = newComputationResultAsTransaction(id, la, state); 37 | this.view.dispatch(transaction); 38 | } 39 | 40 | public updateToNewState(state: EditorState) { 41 | // if filepath has changed, then re-subcribe. 42 | const filepathChanged = 43 | this.lastSeenPath && this.filepath != this.lastSeenPath; 44 | this.state = state; 45 | 46 | if (filepathChanged) { 47 | this.unsubscribeMe(this.lastSeenPath); 48 | this.subscribeMe(); 49 | // the update of the view by starting a new computation is done by 50 | // listening to rename events in the line authoring controller. 51 | } 52 | 53 | return this; 54 | } 55 | 56 | public removeIfStale(): void { 57 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access 58 | if ((this.view as any).destroyed) { 59 | this.unsubscribeMe(this.lastSeenPath); 60 | } 61 | } 62 | 63 | private subscribeMe() { 64 | if (this.filepath === undefined) return; // happens on the very first editor after start. 65 | 66 | eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers( 67 | this.filepath, 68 | (subs) => subs.add(this) 69 | ); 70 | this.lastSeenPath = this.filepath; 71 | } 72 | 73 | private unsubscribeMe(oldFilepath: string) { 74 | eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers( 75 | oldFilepath, 76 | (subs) => subs.delete(this) 77 | ); 78 | } 79 | 80 | private get filepath(): string | undefined { 81 | return this.state.field(editorInfoField)?.file?.path; 82 | } 83 | 84 | private get view(): EditorView | undefined { 85 | return this.state.field(editorEditorField); 86 | } 87 | } 88 | 89 | export type LineAuthoringSubscribers = Set; 90 | 91 | /** 92 | * The Codemirror {@link Extension} used to make each editor subscribe itself to this pub-sub. 93 | */ 94 | export const subscribeNewEditor: StateField = 95 | StateField.define({ 96 | create: (state) => new LineAuthoringSubscriber(state), 97 | update: (v, transaction) => v.updateToNewState(transaction.state), 98 | compare: (a, b) => a === b, 99 | }); 100 | -------------------------------------------------------------------------------- /src/lineAuthor/eventsPerFilepath.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LineAuthoringSubscriber, 3 | LineAuthoringSubscribers, 4 | } from "src/lineAuthor/control"; 5 | 6 | const SECONDS = 1000; 7 | const REMOVE_STALES_FREQUENCY = 60 * SECONDS; 8 | 9 | /** 10 | * * stores the subscribers/editors interested in changed per filepath 11 | * * We need this pub-sub design, because a filepath may be opened in multiple editors 12 | * and each editor should be updated asynchronously and independently. 13 | * * Subscribers can be cleared when the feature is deactivated 14 | */ 15 | class EventsPerFilePath { 16 | private eventsPerFilepath: Map = 17 | new Map(); 18 | private removeStalesSubscribersTimer: number; 19 | 20 | constructor() { 21 | this.startRemoveStalesSubscribersInterval(); 22 | } 23 | 24 | /** 25 | * Run the {@link handler} on the subscribers to {@link filepath}. 26 | */ 27 | public ifFilepathDefinedTransformSubscribers( 28 | filepath: string | undefined, 29 | handler: (lass: LineAuthoringSubscribers) => T 30 | ): T | undefined { 31 | if (!filepath) return; 32 | 33 | this.ensureInitialized(filepath); 34 | 35 | return handler(this.eventsPerFilepath.get(filepath)!); 36 | } 37 | 38 | public forEachSubscriber( 39 | handler: (las: LineAuthoringSubscriber) => void 40 | ): void { 41 | this.eventsPerFilepath.forEach((subs) => subs.forEach(handler)); 42 | } 43 | 44 | private ensureInitialized(filepath: string) { 45 | if (!this.eventsPerFilepath.get(filepath)) 46 | this.eventsPerFilepath.set(filepath, new Set()); 47 | } 48 | 49 | private startRemoveStalesSubscribersInterval() { 50 | this.removeStalesSubscribersTimer = window.setInterval( 51 | () => this?.forEachSubscriber((las) => las?.removeIfStale()), 52 | REMOVE_STALES_FREQUENCY 53 | ); 54 | } 55 | 56 | public clear() { 57 | window.clearInterval(this.removeStalesSubscribersTimer); 58 | this.eventsPerFilepath.clear(); 59 | } 60 | } 61 | 62 | export const eventsPerFilePathSingleton = new EventsPerFilePath(); 63 | -------------------------------------------------------------------------------- /src/lineAuthor/lineAuthorIntegration.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from "@codemirror/state"; 2 | import type { EventRef, TAbstractFile, WorkspaceLeaf } from "obsidian"; 3 | import { MarkdownView, Platform, TFile } from "obsidian"; 4 | import { SimpleGit } from "src/gitManager/simpleGit"; 5 | import { 6 | LineAuthorProvider, 7 | enabledLineAuthorInfoExtensions, 8 | } from "src/lineAuthor/lineAuthorProvider"; 9 | import type { LineAuthorSettings } from "src/lineAuthor/model"; 10 | import { provideSettingsAccess } from "src/lineAuthor/model"; 11 | import { handleContextMenu } from "src/lineAuthor/view/contextMenu"; 12 | import { setTextColorCssBasedOnSetting } from "src/lineAuthor/view/gutter/coloring"; 13 | import { prepareGutterSearchForContextMenuHandling } from "src/lineAuthor/view/gutter/gutterElementSearch"; 14 | import type ObsidianGit from "src/main"; 15 | 16 | /** 17 | * Manages the interaction between Obsidian (file-open event, modification event, etc.) 18 | * and the line authoring feature. It also manages the (de-) activation of the 19 | * line authoring functionality. 20 | */ 21 | export class LineAuthoringFeature { 22 | private lineAuthorInfoProvider?: LineAuthorProvider; 23 | private fileOpenEvent?: EventRef; 24 | private workspaceLeafChangeEvent?: EventRef; 25 | private fileModificationEvent?: EventRef; 26 | private refreshOnCssChangeEvent?: EventRef; 27 | private fileRenameEvent?: EventRef; 28 | private gutterContextMenuEvent?: EventRef; 29 | private codeMirrorExtensions: Extension[] = []; 30 | 31 | constructor(private plg: ObsidianGit) {} 32 | 33 | // ========================= INIT and DE-INIT ========================== 34 | 35 | public onLoadPlugin() { 36 | this.plg.registerEditorExtension(this.codeMirrorExtensions); 37 | provideSettingsAccess( 38 | () => this.plg.settings.lineAuthor, 39 | (laSettings: LineAuthorSettings) => { 40 | this.plg.settings.lineAuthor = laSettings; 41 | void this.plg.saveSettings(); 42 | } 43 | ); 44 | } 45 | 46 | public conditionallyActivateBySettings() { 47 | if (this.plg.settings.lineAuthor.show) { 48 | this.activateFeature(); 49 | } 50 | } 51 | 52 | public activateFeature() { 53 | try { 54 | if (!this.isAvailableOnCurrentPlatform().available) return; 55 | 56 | setTextColorCssBasedOnSetting(this.plg.settings.lineAuthor); 57 | 58 | this.lineAuthorInfoProvider = new LineAuthorProvider(this.plg); 59 | 60 | this.createEventHandlers(); 61 | 62 | this.activateCodeMirrorExtensions(); 63 | 64 | console.log(this.plg.manifest.name + ": Enabled line authoring."); 65 | } catch (e) { 66 | console.warn("Git: Error while loading line authoring feature.", e); 67 | this.deactivateFeature(); 68 | } 69 | } 70 | 71 | /** 72 | * Deactivates the feature. This function is very defensive, as it is also 73 | * called to cleanup, if a critical error in the line authoring has occurred. 74 | */ 75 | public deactivateFeature() { 76 | this.destroyEventHandlers(); 77 | 78 | this.deactivateCodeMirrorExtensions(); 79 | 80 | this.lineAuthorInfoProvider?.destroy(); 81 | this.lineAuthorInfoProvider = undefined; 82 | 83 | console.log(this.plg.manifest.name + ": Disabled line authoring."); 84 | } 85 | 86 | public isAvailableOnCurrentPlatform(): { 87 | available: boolean; 88 | gitManager: SimpleGit; 89 | } { 90 | return { 91 | available: this.plg.useSimpleGit && Platform.isDesktopApp, 92 | gitManager: 93 | this.plg.gitManager instanceof SimpleGit 94 | ? this.plg.gitManager 95 | : undefined!, 96 | }; 97 | } 98 | 99 | // ========================= REFRESH ========================== 100 | 101 | public refreshLineAuthorViews() { 102 | if (this.plg.settings.lineAuthor.show) { 103 | this.deactivateFeature(); 104 | this.activateFeature(); 105 | } 106 | } 107 | 108 | // ========================= CODEMIRROR EXTENSIONS ========================== 109 | 110 | private activateCodeMirrorExtensions() { 111 | // Yes, we need to directly modify the array and notify the change to have 112 | // toggleable Codemirror extensions. 113 | this.codeMirrorExtensions.push(enabledLineAuthorInfoExtensions); 114 | this.plg.app.workspace.updateOptions(); 115 | 116 | // Handle all already opened files 117 | this.plg.app.workspace.iterateAllLeaves(this.handleWorkspaceLeaf); 118 | } 119 | 120 | private deactivateCodeMirrorExtensions() { 121 | // Yes, we need to directly modify the array and notify the change to have 122 | // toggleable Codemirror extensions. 123 | for (const ext of this.codeMirrorExtensions) { 124 | this.codeMirrorExtensions.remove(ext); 125 | } 126 | this.plg.app.workspace.updateOptions(); 127 | } 128 | 129 | // ========================= HANDLERS ========================== 130 | 131 | private createEventHandlers() { 132 | this.gutterContextMenuEvent = this.createGutterContextMenuHandler(); 133 | this.fileOpenEvent = this.createFileOpenEvent(); 134 | this.workspaceLeafChangeEvent = this.createWorkspaceLeafChangeEvent(); 135 | this.fileModificationEvent = this.createVaultFileModificationHandler(); 136 | this.refreshOnCssChangeEvent = this.createCssRefreshHandler(); 137 | this.fileRenameEvent = this.createFileRenameEvent(); 138 | 139 | prepareGutterSearchForContextMenuHandling(); 140 | 141 | this.plg.registerEvent(this.gutterContextMenuEvent); 142 | this.plg.registerEvent(this.refreshOnCssChangeEvent); 143 | this.plg.registerEvent(this.fileOpenEvent); 144 | this.plg.registerEvent(this.workspaceLeafChangeEvent); 145 | this.plg.registerEvent(this.fileModificationEvent); 146 | this.plg.registerEvent(this.fileRenameEvent); 147 | } 148 | 149 | private destroyEventHandlers() { 150 | this.plg.app.workspace.offref(this.refreshOnCssChangeEvent!); 151 | this.plg.app.workspace.offref(this.fileOpenEvent!); 152 | this.plg.app.workspace.offref(this.workspaceLeafChangeEvent!); 153 | this.plg.app.workspace.offref(this.refreshOnCssChangeEvent!); 154 | this.plg.app.vault.offref(this.fileRenameEvent!); 155 | this.plg.app.workspace.offref(this.gutterContextMenuEvent!); 156 | } 157 | 158 | private handleWorkspaceLeaf = (leaf: WorkspaceLeaf) => { 159 | if (!this.lineAuthorInfoProvider) { 160 | console.warn( 161 | "Git: undefined lineAuthorInfoProvider. Unexpected situation." 162 | ); 163 | return; 164 | } 165 | const obsView = leaf?.view; 166 | 167 | if ( 168 | !(obsView instanceof MarkdownView) || 169 | obsView.file == null || 170 | obsView?.allowNoFile === true 171 | ) 172 | return; 173 | 174 | this.lineAuthorInfoProvider 175 | .trackChanged(obsView.file) 176 | .catch(console.error); 177 | }; 178 | 179 | private createFileOpenEvent(): EventRef { 180 | return this.plg.app.workspace.on( 181 | "file-open", 182 | (file: TFile) => 183 | void this.lineAuthorInfoProvider 184 | ?.trackChanged(file) 185 | .catch(console.error) 186 | ); 187 | } 188 | 189 | private createWorkspaceLeafChangeEvent(): EventRef { 190 | return this.plg.app.workspace.on( 191 | "active-leaf-change", 192 | this.handleWorkspaceLeaf 193 | ); 194 | } 195 | 196 | private createFileRenameEvent(): EventRef { 197 | return this.plg.app.vault.on( 198 | "rename", 199 | (file, _old) => 200 | file instanceof TFile && 201 | this.lineAuthorInfoProvider?.trackChanged(file) 202 | ); 203 | } 204 | 205 | private createVaultFileModificationHandler() { 206 | return this.plg.app.vault.on( 207 | "modify", 208 | (anyPath: TAbstractFile) => 209 | anyPath instanceof TFile && 210 | this.lineAuthorInfoProvider?.trackChanged(anyPath) 211 | ); 212 | } 213 | 214 | private createCssRefreshHandler(): EventRef { 215 | return this.plg.app.workspace.on("css-change", () => 216 | this.refreshLineAuthorViews() 217 | ); 218 | } 219 | 220 | private createGutterContextMenuHandler() { 221 | return this.plg.app.workspace.on("editor-menu", handleContextMenu); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/lineAuthor/lineAuthorProvider.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from "@codemirror/state"; 2 | import { Prec } from "@codemirror/state"; 3 | import type { TFile } from "obsidian"; 4 | import { subscribeNewEditor } from "src/lineAuthor/control"; 5 | import { eventsPerFilePathSingleton } from "src/lineAuthor/eventsPerFilepath"; 6 | import type { LineAuthoring, LineAuthoringId } from "src/lineAuthor/model"; 7 | import { lineAuthorState, lineAuthoringId } from "src/lineAuthor/model"; 8 | import { clearViewCache } from "src/lineAuthor/view/cache"; 9 | import { lineAuthorGutter } from "src/lineAuthor/view/view"; 10 | import type ObsidianGit from "src/main"; 11 | 12 | export { previewColor } from "src/lineAuthor/view/gutter/coloring"; 13 | /** 14 | * * handles changes in git head, filesystem, etc. by initiating computation 15 | * * Initiates the line authoring computation via 16 | * git-blame 17 | * * notifies computation results and settings to subscribers (editors) 18 | * * deytroys cache and editor-subscribers when plugin is deactivated 19 | */ 20 | export class LineAuthorProvider { 21 | /** 22 | * Saves all computed line authoring results. 23 | * 24 | * See {@link LineAuthoringId} 25 | */ 26 | private lineAuthorings: Map = new Map(); 27 | 28 | constructor(private plugin: ObsidianGit) {} 29 | 30 | public async trackChanged(file: TFile) { 31 | return this.trackChangedHelper(file).catch((reason) => { 32 | console.warn("Git: Error in trackChanged." + reason); 33 | // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors 34 | return Promise.reject(reason); 35 | }); 36 | } 37 | 38 | private async trackChangedHelper(file: TFile) { 39 | if (!file) return; 40 | 41 | if (file.path === undefined) { 42 | console.warn( 43 | "Git: Attempted to track change of undefined filepath. Unforeseen situation." 44 | ); 45 | return; 46 | } 47 | 48 | return this.computeLineAuthorInfo(file.path); 49 | } 50 | 51 | public destroy() { 52 | this.lineAuthorings.clear(); 53 | eventsPerFilePathSingleton.clear(); 54 | clearViewCache(); 55 | } 56 | 57 | private async computeLineAuthorInfo(filepath: string) { 58 | const gitManager = 59 | this.plugin.lineAuthoringFeature.isAvailableOnCurrentPlatform() 60 | .gitManager; 61 | 62 | const headRevision = 63 | await gitManager.submoduleAwareHeadRevisonInContainingDirectory( 64 | filepath 65 | ); 66 | 67 | const fileHash = await gitManager.hashObject(filepath); 68 | 69 | const key = lineAuthoringId(headRevision, fileHash, filepath); 70 | 71 | if (key === undefined) { 72 | return; 73 | } 74 | 75 | if (this.lineAuthorings.has(key)) { 76 | // already computed. just tell the editor to update to the key's state 77 | } else { 78 | const gitAuthorResult = await gitManager.blame( 79 | filepath, 80 | this.plugin.settings.lineAuthor.followMovement, 81 | this.plugin.settings.lineAuthor.ignoreWhitespace 82 | ); 83 | this.lineAuthorings.set(key, gitAuthorResult); 84 | } 85 | 86 | this.notifyComputationResultToSubscribers(filepath, key); 87 | } 88 | 89 | private notifyComputationResultToSubscribers( 90 | filepath: string, 91 | key: string 92 | ) { 93 | eventsPerFilePathSingleton.ifFilepathDefinedTransformSubscribers( 94 | filepath, 95 | (subs) => 96 | subs.forEach((sub) => 97 | sub.notifyLineAuthoring(key, this.lineAuthorings.get(key)!) 98 | ) 99 | ); 100 | } 101 | } 102 | 103 | // ========================================================= 104 | 105 | export const enabledLineAuthorInfoExtensions: Extension = Prec.high([ 106 | subscribeNewEditor, 107 | lineAuthorState, 108 | lineAuthorGutter, 109 | ]); 110 | -------------------------------------------------------------------------------- /src/lineAuthor/view/cache.ts: -------------------------------------------------------------------------------- 1 | import type { RangeSet } from "@codemirror/state"; 2 | import type { GutterMarker } from "@codemirror/view"; 3 | import { latestSettings } from "src/lineAuthor/model"; 4 | import type { LineAuthoringGutter } from "src/lineAuthor/view/gutter/gutter"; 5 | import { median } from "src/utils"; 6 | 7 | /* 8 | VIEW-CACHE 9 | This file contains temporarily cached information used in the view. 10 | They make it also possible to have unintrusive and soft UI updates, when 11 | the git line author information appears delayed. 12 | The caches here are evicted whenever the line author feature is disabled/refreshed. 13 | */ 14 | 15 | /** 16 | * Clears the cache. This should be called whenever the settings are changed. 17 | * 18 | * Currently, the entire feature is re-loaded, which is why it suffices this to be called 19 | * in the disabler in `lineAuthorIntegration.ts`. 20 | */ 21 | export function clearViewCache() { 22 | longestRenderedGutter = undefined; 23 | 24 | renderedAgeInDaysForAdaptiveInitialColoring = []; 25 | ageIdx = 0; 26 | 27 | gutterInstances.clear(); 28 | gutterMarkersRangeSet.clear(); 29 | 30 | attachedGutterElements.clear(); 31 | } 32 | 33 | /** 34 | * A cache containing the last maximally-sized encountered gutter together with its length and text. 35 | * 36 | * Whenever a longer gutter is encountered, it is saved via {@link conditionallyUpdateLongestRenderedGutter}. 37 | */ 38 | type LongestGutterCache = { 39 | gutter: LineAuthoringGutter; 40 | length: number; 41 | text: string; 42 | }; 43 | let longestRenderedGutter: LongestGutterCache | undefined = undefined; 44 | 45 | export const getLongestRenderedGutter = () => longestRenderedGutter; 46 | 47 | /** 48 | * Given a newly rendered gutter, update the {@link longestRenderedGutter} by comparing the 49 | * text lengths. 50 | * 51 | * If bigger, then update the global variable and persist the settings via {@link latestSettings.save} 52 | */ 53 | export function conditionallyUpdateLongestRenderedGutter( 54 | gutter: LineAuthoringGutter, 55 | text: string 56 | ) { 57 | const length = text.length; 58 | 59 | if (length < (longestRenderedGutter?.length ?? 0)) return; 60 | 61 | longestRenderedGutter = { gutter, length, text }; 62 | 63 | const settings = latestSettings.get(); 64 | if (length !== settings.gutterSpacingFallbackLength) { 65 | settings.gutterSpacingFallbackLength = length; 66 | latestSettings.save(settings); 67 | } 68 | } 69 | 70 | /** 71 | * When a new file is opened, we need to already render the line gutter even before we 72 | * know the true git line authoring - and their true colors. 73 | * 74 | * Simply rendering them with the background color initially is not good, as the 75 | * UI update, when the result is available, is distracting and flickering. 76 | * 77 | * Hence, we adapt the initial color shown when opening and switching panes. 78 | * 79 | * The initial color is computed from the distribution of ages of each line commit 80 | * (in days). Currently, we use {@link ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE}`=50` 81 | * elements and the `median` to compute the color. 82 | */ 83 | let renderedAgeInDaysForAdaptiveInitialColoring: number[] = []; 84 | 85 | const ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE = 15; 86 | 87 | let ageIdx = 0; 88 | export function recordRenderedAgeInDays(age: number) { 89 | renderedAgeInDaysForAdaptiveInitialColoring[ageIdx] = age; 90 | ageIdx = (ageIdx + 1) % ADAPTIVE_INITIAL_COLORING_AGE_CACHE_SIZE; 91 | } 92 | 93 | export function computeAdaptiveInitialColoringAgeInDays(): number | undefined { 94 | return median(renderedAgeInDaysForAdaptiveInitialColoring); 95 | } 96 | 97 | /** 98 | * Caches the {@link LineAuthoringGutter} instances created in `gutter.ts`. 99 | */ 100 | export const gutterInstances: Map = new Map(); 101 | 102 | /** 103 | * Caches the computation of {@link computeLineAuthoringGutterMarkersRangeSet}. 104 | * 105 | * Despite the computation of the document digest and line-blocks, the performance 106 | * was measured to be faster with the caching. 107 | */ 108 | export const gutterMarkersRangeSet: Map< 109 | string, 110 | RangeSet 111 | > = new Map(); 112 | 113 | /** 114 | * Stores all DOM-attached gutter elements so that they can be checked for being 115 | * under the mouse during a gutter context-menu event; 116 | */ 117 | export const attachedGutterElements: Set = new Set(); 118 | -------------------------------------------------------------------------------- /src/lineAuthor/view/contextMenu.ts: -------------------------------------------------------------------------------- 1 | import type { Editor, MarkdownView, Menu } from "obsidian"; 2 | import { DEFAULT_SETTINGS } from "src/constants"; 3 | import type { LineAuthorSettings } from "src/lineAuthor/model"; 4 | import { findGutterElementUnderMouse } from "src/lineAuthor/view/gutter/gutterElementSearch"; 5 | import { pluginRef } from "src/pluginGlobalRef"; 6 | import type { BlameCommit } from "src/types"; 7 | import { impossibleBranch } from "src/utils"; 8 | 9 | type ContextMenuConfigurableSettingsKeys = 10 | | "showCommitHash" 11 | | "authorDisplay" 12 | | "dateTimeFormatOptions"; 13 | 14 | type CtxMenuCommitInfo = Pick & { 15 | isWaitingGutter: boolean; 16 | }; 17 | const COMMIT_ATTR = "data-commit"; 18 | 19 | export function handleContextMenu( 20 | menu: Menu, 21 | editor: Editor, 22 | _mdv: MarkdownView 23 | ) { 24 | // Click was inside text-editor with active cursor. Don't trigger there. 25 | if (editor.hasFocus()) return; 26 | 27 | const gutterElement = findGutterElementUnderMouse(); 28 | if (!gutterElement) return; 29 | 30 | const info = getCommitInfo(gutterElement); 31 | if (!info) return; 32 | 33 | // Zero-commit and waiting-for-result must not be copied 34 | if (!info.isZeroCommit && !info.isWaitingGutter) { 35 | addCopyHashMenuItem(info, menu); 36 | } 37 | 38 | addConfigurableLineAuthorSettings("showCommitHash", menu); 39 | addConfigurableLineAuthorSettings("authorDisplay", menu); 40 | addConfigurableLineAuthorSettings("dateTimeFormatOptions", menu); 41 | } 42 | 43 | function addCopyHashMenuItem(commit: CtxMenuCommitInfo, menu: Menu) { 44 | menu.addItem((item) => 45 | item 46 | .setTitle("Copy commit hash") 47 | .setIcon("copy") 48 | .setSection("obs-git-line-author-copy") 49 | .onClick((_e) => navigator.clipboard.writeText(commit.hash)) 50 | ); 51 | } 52 | 53 | function addConfigurableLineAuthorSettings( 54 | key: ContextMenuConfigurableSettingsKeys, 55 | menu: Menu 56 | ) { 57 | let title: string; 58 | let actionNewValue: LineAuthorSettings[typeof key]; 59 | 60 | const settings = pluginRef.plugin!.settings.lineAuthor; 61 | const currentValue = settings[key]; 62 | const currentlyShown = 63 | typeof currentValue === "boolean" 64 | ? currentValue 65 | : currentValue !== "hide"; 66 | 67 | const defaultValue = DEFAULT_SETTINGS.lineAuthor[key]; 68 | 69 | if (key === "showCommitHash") { 70 | title = "Show commit hash"; 71 | actionNewValue = currentValue; 72 | } else if (key === "authorDisplay") { 73 | const showOption = settings.lastShownAuthorDisplay ?? defaultValue; 74 | title = "Show author " + (currentlyShown ? currentValue : showOption); 75 | actionNewValue = currentlyShown ? "hide" : showOption; 76 | } else if (key === "dateTimeFormatOptions") { 77 | const showOption = 78 | settings.lastShownDateTimeFormatOptions ?? defaultValue; 79 | title = "Show " + (currentlyShown ? currentValue : showOption); 80 | title += !title.contains("date") ? " date" : ""; 81 | actionNewValue = currentlyShown ? "hide" : showOption; 82 | } else { 83 | impossibleBranch(key); 84 | } 85 | 86 | menu.addItem((item) => 87 | item 88 | .setTitle(title) 89 | .setSection("obs-git-line-author-configure") // group settings together 90 | .setChecked(currentlyShown) 91 | .onClick((_e) => 92 | pluginRef.plugin?.settingsTab?.lineAuthorSettingHandler( 93 | key, 94 | actionNewValue 95 | ) 96 | ) 97 | ); 98 | } 99 | 100 | export function enrichCommitInfoForContextMenu( 101 | commit: BlameCommit, 102 | isWaitingGutter: boolean, 103 | elt: HTMLElement 104 | ) { 105 | elt.setAttr( 106 | COMMIT_ATTR, 107 | JSON.stringify({ 108 | hash: commit.hash, 109 | isZeroCommit: commit.isZeroCommit, 110 | isWaitingGutter, 111 | }) 112 | ); 113 | } 114 | 115 | function getCommitInfo(elt: HTMLElement): CtxMenuCommitInfo | undefined { 116 | const commitInfoStr = elt.getAttr(COMMIT_ATTR); 117 | return commitInfoStr 118 | ? (JSON.parse(commitInfoStr) as CtxMenuCommitInfo) 119 | : undefined; 120 | } 121 | -------------------------------------------------------------------------------- /src/lineAuthor/view/gutter/coloring.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "obsidian"; 2 | import type { LineAuthorSettings } from "src/lineAuthor/model"; 3 | import { maxAgeInDaysFromSettings } from "src/lineAuthor/model"; 4 | import type { GitTimestamp } from "src/types"; 5 | 6 | /** 7 | * Given the settings, it computes the background gutter color for the 8 | * oldest and newest commit. 9 | */ 10 | export function previewColor( 11 | which: "oldest" | "newest", 12 | settings: LineAuthorSettings 13 | ) { 14 | return which === "oldest" 15 | ? coloringBasedOnCommitAge(0 /* epoch time: 1970 */, false, settings) 16 | .color 17 | : coloringBasedOnCommitAge(undefined, true, settings).color; 18 | } 19 | 20 | /** 21 | * Computes the `rgba(...)` color string describing the background color 22 | * for a commit timestamp {@link GitTimestamp} and the settings. 23 | * 24 | * It first computes the age x (from 0 to 1) of the commit where 25 | * 0 means now and 1 means maximum age (settings) or older. 26 | * 27 | * The zero commit gets the age 0. 28 | * 29 | * The coloring is then linearly interpolated between the two colors provided in the settings. 30 | * 31 | * Additional minor adjustments were made for dark/light mode, transparency, scaling 32 | * and also using more red-like colors near the newer ages. 33 | */ 34 | export function coloringBasedOnCommitAge( 35 | commitAuthorEpochSeonds: GitTimestamp["epochSeconds"] | undefined, 36 | isZeroCommit: boolean, 37 | settings: LineAuthorSettings 38 | ): { color: string; daysSinceCommit: number } { 39 | const maxAgeInDays = maxAgeInDaysFromSettings(settings); 40 | 41 | const epochSecondsNow = Date.now() / 1000; 42 | const authoringEpochSeconds = commitAuthorEpochSeonds ?? 0; 43 | 44 | const secondsSinceCommit = isZeroCommit 45 | ? 0 46 | : epochSecondsNow - authoringEpochSeconds; 47 | 48 | const daysSinceCommit = secondsSinceCommit / 60 / 60 / 24; 49 | 50 | // 0 <= x <= 1, larger means older 51 | // use n-th-root to make recent changes more prnounced 52 | const x = Math.pow( 53 | Math.clamp(daysSinceCommit / maxAgeInDays, 0, 1), 54 | 1 / 2.3 55 | ); 56 | 57 | const dark = isDarkMode(); 58 | 59 | const color0 = settings.colorNew; 60 | const color1 = settings.colorOld; 61 | 62 | const scaling = dark ? 0.4 : 1; 63 | const r = lin(color0.r, color1.r, x) * scaling; 64 | const g = lin(color0.g, color1.g, x) * scaling; 65 | const b = lin(color0.b, color1.b, x) * scaling; 66 | const a = dark ? 0.75 : 0.25; 67 | 68 | return { color: `rgba(${r},${g},${b},${a})`, daysSinceCommit }; 69 | } 70 | 71 | function lin(z0: number, z1: number, x: number): number { 72 | return z0 + (z1 - z0) * x; 73 | } 74 | 75 | function isDarkMode() { 76 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access 77 | return ((window as any).app as App)?.getTheme() === "obsidian"; // light mode is "moonstone" 78 | } 79 | 80 | /** 81 | * Set the CSS variable `--obs-git-gutter-text` based on the configured 82 | * value in the line author settings. This is necessary for proper text coloring. 83 | */ 84 | export function setTextColorCssBasedOnSetting(settings: LineAuthorSettings) { 85 | document.body.style.setProperty( 86 | "--obs-git-gutter-text", 87 | settings.textColorCss 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/lineAuthor/view/gutter/commitChoice.ts: -------------------------------------------------------------------------------- 1 | import type { LineAuthoring } from "src/lineAuthor/model"; 2 | import type { BlameCommit } from "src/types"; 3 | 4 | /** 5 | * Chooses the newest commit from the {@link LineAuthoring} for the 6 | * lines {@link startLine} to {@link endLine} (inclusive). 7 | */ 8 | export function chooseNewestCommit( 9 | lineAuthoring: Exclude, 10 | startLine: number, 11 | endLine: number 12 | ): BlameCommit { 13 | let newest: BlameCommit = undefined!; 14 | 15 | for (let line = startLine; line <= endLine; line++) { 16 | const currentHash = lineAuthoring.hashPerLine[line]; 17 | const currentCommit = lineAuthoring.commits.get(currentHash)!; 18 | 19 | if ( 20 | !newest || 21 | currentCommit.isZeroCommit || 22 | isNewerThan(currentCommit, newest) 23 | ) { 24 | newest = currentCommit; 25 | } 26 | } 27 | 28 | return newest; 29 | } 30 | 31 | function isNewerThan(left: BlameCommit, right: BlameCommit): boolean { 32 | const l = left.author?.epochSeconds ?? 0; 33 | const r = right.author?.epochSeconds ?? 0; 34 | return l > r; 35 | } 36 | -------------------------------------------------------------------------------- /src/lineAuthor/view/gutter/gutterElementSearch.ts: -------------------------------------------------------------------------------- 1 | import { attachedGutterElements } from "src/lineAuthor/view/cache"; 2 | 3 | const mouseXY = { x: -10, y: -10 }; 4 | 5 | // todo. According to a discord message from the Obsidian Team, the source bug 6 | // will be fixed in the next release. Then this hack should be removed. 7 | /** 8 | * Stores the last MouseDownEvent clientX and clientY position. 9 | * 10 | * This is part of the 'hack' to be able to detect the line author gutter element below 11 | * the mouse as part of the context menu. This is necessary, as I couldn't find 12 | * a way to retrieve the target gutter from the Obsidian "editor-menu" event. 13 | */ 14 | export function prepareGutterSearchForContextMenuHandling() { 15 | if (mouseXY.x === -10) { 16 | // event listener is not yet registered 17 | window.addEventListener("mousedown", (e) => { 18 | mouseXY.x = e.clientX; 19 | mouseXY.y = e.clientY; 20 | }); 21 | } 22 | } 23 | 24 | export function findGutterElementUnderMouse(): HTMLElement | undefined { 25 | for (const elt of attachedGutterElements) { 26 | if (contains(elt, mouseXY)) return elt; 27 | } 28 | } 29 | 30 | function contains(elt: HTMLElement, pt: { x: number; y: number }): boolean { 31 | const { x, y, width, height } = elt.getBoundingClientRect(); 32 | return x <= pt.x && pt.x <= x + width && y <= pt.y && pt.y <= y + height; 33 | } 34 | -------------------------------------------------------------------------------- /src/lineAuthor/view/gutter/initial.ts: -------------------------------------------------------------------------------- 1 | import { moment } from "obsidian"; 2 | import { DEFAULT_SETTINGS } from "src/constants"; 3 | import type { LineAuthoring, LineAuthorSettings } from "src/lineAuthor/model"; 4 | import { latestSettings, maxAgeInDaysFromSettings } from "src/lineAuthor/model"; 5 | import { computeAdaptiveInitialColoringAgeInDays } from "src/lineAuthor/view/cache"; 6 | import { 7 | lineAuthoringGutterMarker, 8 | TextGutter, 9 | } from "src/lineAuthor/view/gutter/gutter"; 10 | import type { Blame, BlameCommit, GitTimestamp, UserEmail } from "src/types"; 11 | import { momentToEpochSeconds } from "src/utils"; 12 | 13 | /** 14 | * The gutter used to reserve the space used for the line authoring before it is loaded. 15 | * 16 | * Until the true length is known, it uses the last saved `gutterSpacingFallbackLength`. 17 | */ 18 | export function initialSpacingGutter() { 19 | const length = 20 | latestSettings.get()?.gutterSpacingFallbackLength ?? 21 | DEFAULT_SETTINGS.lineAuthor.gutterSpacingFallbackLength; 22 | return new TextGutter(Array(length).fill("-").join("")); 23 | } 24 | 25 | /** 26 | * Initial line authoring gutter with adaptive coloring for softer UI updates. 27 | * 28 | * **DO NOT CACHE THIS FUNCTION CALL, AS THE ADAPTIVE COLOR NEED TO BE FRESHLY CALCULATED.** 29 | */ 30 | export function initialLineAuthoringGutter(settings: LineAuthorSettings) { 31 | const { lineAuthoring, ageForInitialRender } = 32 | adaptiveInitialColoredWaitingLineAuthoring(settings); 33 | return lineAuthoringGutterMarker( 34 | lineAuthoring, 35 | 1, 36 | 1, 37 | "initialGutter" + ageForInitialRender, // use a age coloring based cache key 38 | settings, 39 | "waiting-for-result" 40 | ); 41 | } 42 | 43 | /** 44 | * Creates a line authoring with an adaptive initial color based on {@link computeAdaptiveInitialColoringAgeInDays} (previously recorded ages). 45 | * 46 | * If no such color is available, then it takes the 25% of the max age as the color. 47 | * e.g. for max age = 100 days, this means it'll use the color for the age of 25 days. 48 | * This case only happens on each (re-)start of Obsidian. 49 | * 50 | * We use a waiting-gutter, to have it rendered - so that we can use it's rendered text 51 | * and transform it into unintrusive placeholder characters. 52 | */ 53 | export function adaptiveInitialColoredWaitingLineAuthoring( 54 | settings: LineAuthorSettings 55 | ): { 56 | lineAuthoring: Exclude; 57 | ageForInitialRender: number; 58 | } { 59 | const ageForInitialRender: number = 60 | computeAdaptiveInitialColoringAgeInDays() ?? 61 | maxAgeInDaysFromSettings(settings) * 0.25; 62 | 63 | const slightlyOlderAgeForInitialRender: moment.Moment = moment().add( 64 | -ageForInitialRender, 65 | "days" 66 | ); 67 | 68 | const dummyAuthor = { 69 | name: "", 70 | epochSeconds: momentToEpochSeconds(slightlyOlderAgeForInitialRender), 71 | tz: "+0000", 72 | }; 73 | 74 | const dummyCommit = { 75 | hash: "waiting-for-result", 76 | author: dummyAuthor, 77 | committer: dummyAuthor, 78 | isZeroCommit: false, 79 | }; 80 | 81 | return { 82 | lineAuthoring: { 83 | hashPerLine: [undefined!, "waiting-for-result"], 84 | commits: new Map([["waiting-for-result", dummyCommit]]), 85 | }, 86 | ageForInitialRender, 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/lineAuthor/view/gutter/untrackedFile.ts: -------------------------------------------------------------------------------- 1 | import { zeroCommit } from "src/gitManager/simpleGit"; 2 | import type { LineAuthorSettings } from "src/lineAuthor/model"; 3 | import { lineAuthoringGutterMarker } from "src/lineAuthor/view/gutter/gutter"; 4 | import type { Blame } from "src/types"; 5 | 6 | /** 7 | * The gutter to show on untracked files. 8 | */ 9 | export function newUntrackedFileGutter( 10 | key: string, 11 | settings: LineAuthorSettings 12 | ) { 13 | const dummyLineAuthoring = { 14 | hashPerLine: [undefined!, "000000"], 15 | commits: new Map([["000000", zeroCommit]]), 16 | }; 17 | return lineAuthoringGutterMarker(dummyLineAuthoring, 1, 1, key, settings); 18 | } 19 | -------------------------------------------------------------------------------- /src/openInGitHub.ts: -------------------------------------------------------------------------------- 1 | import type { Editor, TFile } from "obsidian"; 2 | import { Notice } from "obsidian"; 3 | import type { GitManager } from "./gitManager/gitManager"; 4 | import { SimpleGit } from "./gitManager/simpleGit"; 5 | 6 | export async function openLineInGitHub( 7 | editor: Editor, 8 | file: TFile, 9 | manager: GitManager 10 | ) { 11 | const data = await getData(file, manager); 12 | 13 | if (data.result === "failure") { 14 | new Notice(data.reason); 15 | return; 16 | } 17 | 18 | const { isGitHub, branch, repo, user, filePath } = data; 19 | if (isGitHub) { 20 | const from = editor.getCursor("from").line + 1; 21 | const to = editor.getCursor("to").line + 1; 22 | if (from === to) { 23 | window.open( 24 | `https://github.com/${user}/${repo}/blob/${branch}/${filePath}?plain=1#L${from}` 25 | ); 26 | } else { 27 | window.open( 28 | `https://github.com/${user}/${repo}/blob/${branch}/${filePath}?plain=1#L${from}-L${to}` 29 | ); 30 | } 31 | } else { 32 | new Notice("It seems like you are not using GitHub"); 33 | } 34 | } 35 | 36 | export async function openHistoryInGitHub(file: TFile, manager: GitManager) { 37 | const data = await getData(file, manager); 38 | 39 | if (data.result === "failure") { 40 | new Notice(data.reason); 41 | return; 42 | } 43 | 44 | const { isGitHub, branch, repo, user, filePath } = data; 45 | 46 | if (isGitHub) { 47 | window.open( 48 | `https://github.com/${user}/${repo}/commits/${branch}/${filePath}` 49 | ); 50 | } else { 51 | new Notice("It seems like you are not using GitHub"); 52 | } 53 | } 54 | 55 | async function getData( 56 | file: TFile, 57 | manager: GitManager 58 | ): Promise< 59 | | { 60 | result: "success"; 61 | isGitHub: boolean; 62 | user: string; 63 | repo: string; 64 | branch: string; 65 | filePath: string; 66 | } 67 | | { result: "failure"; reason: string } 68 | > { 69 | const branchInfo = await manager.branchInfo(); 70 | let remoteBranch = branchInfo.tracking; 71 | let branch = branchInfo.current; 72 | let remoteUrl: string | undefined = undefined; 73 | let filePath = manager.getRelativeRepoPath(file.path); 74 | 75 | if (manager instanceof SimpleGit) { 76 | const submodule = await manager.getSubmoduleOfFile( 77 | manager.getRelativeRepoPath(file.path) 78 | ); 79 | if (submodule) { 80 | filePath = submodule.relativeFilepath; 81 | const status = await manager.git 82 | .cwd({ 83 | path: submodule.submodule, 84 | root: false, 85 | }) 86 | .status(); 87 | 88 | remoteBranch = status.tracking || undefined; 89 | branch = status.current || undefined; 90 | if (remoteBranch) { 91 | const remote = remoteBranch.substring( 92 | 0, 93 | remoteBranch.indexOf("/") 94 | ); 95 | 96 | const config = await manager.git 97 | .cwd({ 98 | path: submodule.submodule, 99 | root: false, 100 | }) 101 | .getConfig(`remote.${remote}.url`, "local"); 102 | 103 | if (config.value != null) { 104 | remoteUrl = config.value; 105 | } else { 106 | return { 107 | result: "failure", 108 | reason: "Failed to get remote url of submodule", 109 | }; 110 | } 111 | } 112 | } 113 | } 114 | 115 | if (remoteBranch == null) { 116 | return { 117 | result: "failure", 118 | reason: "Remote branch is not configured", 119 | }; 120 | } 121 | 122 | if (branch == null) { 123 | return { 124 | result: "failure", 125 | reason: "Failed to get current branch name", 126 | }; 127 | } 128 | 129 | if (remoteUrl == null) { 130 | const remote = remoteBranch.substring(0, remoteBranch.indexOf("/")); 131 | remoteUrl = await manager.getConfig(`remote.${remote}.url`); 132 | if (remoteUrl == null) { 133 | return { 134 | result: "failure", 135 | reason: "Failed to get remote url", 136 | }; 137 | } 138 | } 139 | const res = remoteUrl.match( 140 | /(?:^https:\/\/github\.com\/(.+)\/(.+?)(?:\.git)?$)|(?:^[a-zA-Z]+@github\.com:(.+)\/(.+?)(?:\.git)?$)/ 141 | ); 142 | if (res == null) { 143 | return { 144 | result: "failure", 145 | reason: "Could not parse remote url", 146 | }; 147 | } else { 148 | const [isGitHub, httpsUser, httpsRepo, sshUser, sshRepo] = res; 149 | return { 150 | result: "success", 151 | isGitHub: !!isGitHub, 152 | repo: httpsRepo || sshRepo, 153 | user: httpsUser || sshUser, 154 | branch: branch, 155 | filePath: filePath, 156 | }; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/pluginGlobalRef.ts: -------------------------------------------------------------------------------- 1 | import type ObsidianGit from "src/main"; 2 | 3 | /** 4 | * Store the reference to the {@link ObsidianGit} plugin globally, so that 5 | * the line author gutter context menu can access it for quick configuration. 6 | */ 7 | export const pluginRef: { plugin?: ObsidianGit } = {}; 8 | -------------------------------------------------------------------------------- /src/promiseQueue.ts: -------------------------------------------------------------------------------- 1 | import type ObsidianGit from "./main"; 2 | 3 | export class PromiseQueue { 4 | private tasks: { 5 | task: () => Promise; 6 | onFinished: (res: unknown) => void; 7 | }[] = []; 8 | 9 | constructor(private readonly plugin: ObsidianGit) {} 10 | 11 | /** 12 | * Add a task to the queue. 13 | * 14 | * @param task The task to add. 15 | * @param onFinished A callback that is called when the task is finished. Both on success and on error. 16 | */ 17 | addTask( 18 | task: () => Promise, 19 | onFinished?: (res: T | undefined) => void 20 | ): void { 21 | this.tasks.push({ task, onFinished: onFinished ?? (() => {}) }); 22 | if (this.tasks.length === 1) { 23 | this.handleTask(); 24 | } 25 | } 26 | 27 | private handleTask(): void { 28 | if (this.tasks.length > 0) { 29 | const item = this.tasks[0]; 30 | item.task().then( 31 | (res) => { 32 | item.onFinished(res); 33 | this.tasks.shift(); 34 | this.handleTask(); 35 | }, 36 | (e) => { 37 | this.plugin.displayError(e); 38 | item.onFinished(undefined); 39 | this.tasks.shift(); 40 | this.handleTask(); 41 | } 42 | ); 43 | } 44 | } 45 | 46 | clear(): void { 47 | this.tasks = []; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/setting/localStorageSettings.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "obsidian"; 2 | import type ObsidianGit from "../main"; 3 | export class LocalStorageSettings { 4 | private prefix: string; 5 | private app: App; 6 | constructor(private readonly plugin: ObsidianGit) { 7 | this.prefix = this.plugin.manifest.id + ":"; 8 | this.app = plugin.app; 9 | } 10 | 11 | migrate(): void { 12 | const keys = [ 13 | "password", 14 | "hostname", 15 | "conflict", 16 | "lastAutoPull", 17 | "lastAutoBackup", 18 | "lastAutoPush", 19 | "gitPath", 20 | "pluginDisabled", 21 | ]; 22 | for (const key of keys) { 23 | const old = localStorage.getItem(this.prefix + key); 24 | if ( 25 | this.app.loadLocalStorage(this.prefix + key) == null && 26 | old != null 27 | ) { 28 | if (old != null) { 29 | this.app.saveLocalStorage(this.prefix + key, old); 30 | localStorage.removeItem(this.prefix + key); 31 | } 32 | } 33 | } 34 | } 35 | 36 | getPassword(): string | null { 37 | return this.app.loadLocalStorage(this.prefix + "password"); 38 | } 39 | 40 | setPassword(value: string): void { 41 | return this.app.saveLocalStorage(this.prefix + "password", value); 42 | } 43 | 44 | getUsername(): string | null { 45 | return this.app.loadLocalStorage(this.prefix + "username"); 46 | } 47 | 48 | setUsername(value: string): void { 49 | return this.app.saveLocalStorage(this.prefix + "username", value); 50 | } 51 | 52 | getHostname(): string | null { 53 | return this.app.loadLocalStorage(this.prefix + "hostname"); 54 | } 55 | 56 | setHostname(value: string): void { 57 | return this.app.saveLocalStorage(this.prefix + "hostname", value); 58 | } 59 | 60 | getConflict(): boolean { 61 | return this.app.loadLocalStorage(this.prefix + "conflict") == "true"; 62 | } 63 | 64 | setConflict(value: boolean): void { 65 | return this.app.saveLocalStorage(this.prefix + "conflict", `${value}`); 66 | } 67 | 68 | getLastAutoPull(): string | null { 69 | return this.app.loadLocalStorage(this.prefix + "lastAutoPull"); 70 | } 71 | 72 | setLastAutoPull(value: string): void { 73 | return this.app.saveLocalStorage(this.prefix + "lastAutoPull", value); 74 | } 75 | 76 | getLastAutoBackup(): string | null { 77 | return this.app.loadLocalStorage(this.prefix + "lastAutoBackup"); 78 | } 79 | 80 | setLastAutoBackup(value: string): void { 81 | return this.app.saveLocalStorage(this.prefix + "lastAutoBackup", value); 82 | } 83 | 84 | getLastAutoPush(): string | null { 85 | return this.app.loadLocalStorage(this.prefix + "lastAutoPush"); 86 | } 87 | 88 | setLastAutoPush(value: string): void { 89 | return this.app.saveLocalStorage(this.prefix + "lastAutoPush", value); 90 | } 91 | 92 | getGitPath(): string | null { 93 | return this.app.loadLocalStorage(this.prefix + "gitPath"); 94 | } 95 | 96 | setGitPath(value: string): void { 97 | return this.app.saveLocalStorage(this.prefix + "gitPath", value); 98 | } 99 | 100 | getPATHPaths(): string[] { 101 | return ( 102 | this.app.loadLocalStorage(this.prefix + "PATHPaths")?.split(":") ?? 103 | [] 104 | ); 105 | } 106 | 107 | setPATHPaths(value: string[]): void { 108 | return this.app.saveLocalStorage( 109 | this.prefix + "PATHPaths", 110 | value.join(":") 111 | ); 112 | } 113 | 114 | getEnvVars(): string[] { 115 | return JSON.parse( 116 | this.app.loadLocalStorage(this.prefix + "envVars") ?? "[]" 117 | ) as string[]; 118 | } 119 | 120 | setEnvVars(value: string[]): void { 121 | return this.app.saveLocalStorage( 122 | this.prefix + "envVars", 123 | JSON.stringify(value) 124 | ); 125 | } 126 | 127 | getPluginDisabled(): boolean { 128 | return ( 129 | this.app.loadLocalStorage(this.prefix + "pluginDisabled") == "true" 130 | ); 131 | } 132 | 133 | setPluginDisabled(value: boolean): void { 134 | return this.app.saveLocalStorage( 135 | this.prefix + "pluginDisabled", 136 | `${value}` 137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/statusBar.ts: -------------------------------------------------------------------------------- 1 | import { setIcon, moment } from "obsidian"; 2 | import type ObsidianGit from "./main"; 3 | import { CurrentGitAction } from "./types"; 4 | 5 | interface StatusBarMessage { 6 | message: string; 7 | timeout: number; 8 | } 9 | 10 | export class StatusBar { 11 | private messages: StatusBarMessage[] = []; 12 | private currentMessage: StatusBarMessage | null; 13 | private lastCommitTimestamp?: Date; 14 | private unPushedCommits?: number; 15 | public lastMessageTimestamp: number | null; 16 | private base = "obsidian-git-statusbar-"; 17 | private iconEl: HTMLElement; 18 | private conflictEl: HTMLElement; 19 | private textEl: HTMLElement; 20 | 21 | constructor( 22 | private statusBarEl: HTMLElement, 23 | private readonly plugin: ObsidianGit 24 | ) { 25 | this.statusBarEl.setAttribute("data-tooltip-position", "top"); 26 | 27 | plugin.registerEvent( 28 | plugin.app.workspace.on("obsidian-git:refreshed", () => { 29 | this.refreshCommitTimestamp().catch(console.error); 30 | }) 31 | ); 32 | } 33 | 34 | public displayMessage(message: string, timeout: number) { 35 | this.messages.push({ 36 | message: `Git: ${message.slice(0, 100)}`, 37 | timeout: timeout, 38 | }); 39 | this.display(); 40 | } 41 | 42 | public display() { 43 | if (this.messages.length > 0 && !this.currentMessage) { 44 | this.currentMessage = this.messages.shift() as StatusBarMessage; 45 | this.statusBarEl.addClass(this.base + "message"); 46 | this.statusBarEl.ariaLabel = ""; 47 | this.statusBarEl.setText(this.currentMessage.message); 48 | this.lastMessageTimestamp = Date.now(); 49 | } else if (this.currentMessage) { 50 | const messageAge = 51 | Date.now() - (this.lastMessageTimestamp as number); 52 | if (messageAge >= this.currentMessage.timeout) { 53 | this.currentMessage = null; 54 | this.lastMessageTimestamp = null; 55 | } 56 | } else { 57 | this.displayState(); 58 | } 59 | } 60 | 61 | private displayState() { 62 | //Messages have to be removed before the state is set 63 | if ( 64 | this.statusBarEl.getText().length > 3 || 65 | !this.statusBarEl.hasChildNodes() 66 | ) { 67 | this.statusBarEl.empty(); 68 | 69 | this.conflictEl = this.statusBarEl.createDiv(); 70 | this.conflictEl.setAttribute("data-tooltip-position", "top"); 71 | this.conflictEl.style.float = "left"; 72 | 73 | this.iconEl = this.statusBarEl.createDiv(); 74 | this.iconEl.style.float = "left"; 75 | 76 | this.textEl = this.statusBarEl.createDiv(); 77 | this.textEl.style.float = "right"; 78 | this.textEl.style.marginLeft = "5px"; 79 | } 80 | 81 | if (this.plugin.localStorage.getConflict()) { 82 | setIcon(this.conflictEl, "alert-circle"); 83 | this.conflictEl.ariaLabel = 84 | "You have merge conflicts. Resolve them and commit afterwards."; 85 | this.conflictEl.style.marginRight = "5px"; 86 | this.conflictEl.addClass(this.base + "conflict"); 87 | } else { 88 | this.conflictEl.empty(); 89 | 90 | this.conflictEl.style.marginRight = ""; 91 | } 92 | switch (this.plugin.state.gitAction) { 93 | case CurrentGitAction.idle: 94 | this.displayFromNow(); 95 | break; 96 | case CurrentGitAction.status: 97 | this.statusBarEl.ariaLabel = "Checking repository status..."; 98 | setIcon(this.iconEl, "refresh-cw"); 99 | this.statusBarEl.addClass(this.base + "status"); 100 | break; 101 | case CurrentGitAction.add: 102 | this.statusBarEl.ariaLabel = "Adding files..."; 103 | setIcon(this.iconEl, "archive"); 104 | this.statusBarEl.addClass(this.base + "add"); 105 | break; 106 | case CurrentGitAction.commit: 107 | this.statusBarEl.ariaLabel = "Committing changes..."; 108 | setIcon(this.iconEl, "git-commit"); 109 | this.statusBarEl.addClass(this.base + "commit"); 110 | break; 111 | case CurrentGitAction.push: 112 | this.statusBarEl.ariaLabel = "Pushing changes..."; 113 | setIcon(this.iconEl, "upload"); 114 | this.statusBarEl.addClass(this.base + "push"); 115 | break; 116 | case CurrentGitAction.pull: 117 | this.statusBarEl.ariaLabel = "Pulling changes..."; 118 | setIcon(this.iconEl, "download"); 119 | this.statusBarEl.addClass(this.base + "pull"); 120 | break; 121 | default: 122 | this.statusBarEl.ariaLabel = "Failed on initialization!"; 123 | setIcon(this.iconEl, "alert-triangle"); 124 | this.statusBarEl.addClass(this.base + "failed-init"); 125 | break; 126 | } 127 | } 128 | 129 | private displayFromNow(): void { 130 | const timestamp = this.lastCommitTimestamp; 131 | const offlineMode = this.plugin.state.offlineMode; 132 | if (timestamp) { 133 | const fromNow = moment(timestamp).fromNow(); 134 | this.statusBarEl.ariaLabel = `${ 135 | offlineMode ? "Offline: " : "" 136 | }Last Commit: ${fromNow}`; 137 | 138 | if (this.unPushedCommits ?? 0 > 0) { 139 | this.statusBarEl.ariaLabel += `\n(${this.unPushedCommits} unpushed commits)`; 140 | } 141 | } else { 142 | this.statusBarEl.ariaLabel = offlineMode 143 | ? "Git is offline" 144 | : "Git is ready"; 145 | } 146 | 147 | if (offlineMode) { 148 | setIcon(this.iconEl, "globe"); 149 | } else { 150 | setIcon(this.iconEl, "check"); 151 | } 152 | if ( 153 | this.plugin.settings.changedFilesInStatusBar && 154 | this.plugin.cachedStatus 155 | ) { 156 | this.textEl.setText( 157 | this.plugin.cachedStatus.changed.length.toString() 158 | ); 159 | } 160 | this.statusBarEl.addClass(this.base + "idle"); 161 | } 162 | 163 | private async refreshCommitTimestamp() { 164 | this.lastCommitTimestamp = 165 | await this.plugin.gitManager.getLastCommitTime(); 166 | this.unPushedCommits = 167 | await this.plugin.gitManager.getUnpushedCommits(); 168 | } 169 | 170 | public remove() { 171 | this.statusBarEl.remove(); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Platform, TFile } from "obsidian"; 2 | import { 3 | CONFLICT_OUTPUT_FILE, 4 | DIFF_VIEW_CONFIG, 5 | SPLIT_DIFF_VIEW_CONFIG, 6 | } from "./constants"; 7 | import type ObsidianGit from "./main"; 8 | import { SimpleGit } from "./gitManager/simpleGit"; 9 | import { getNewLeaf, splitRemoteBranch } from "./utils"; 10 | import { GeneralModal } from "./ui/modals/generalModal"; 11 | 12 | export default class Tools { 13 | constructor(private readonly plugin: ObsidianGit) {} 14 | 15 | async hasTooBigFiles( 16 | files: { vaultPath: string; path: string }[] 17 | ): Promise { 18 | const branchInfo = await this.plugin.gitManager.branchInfo(); 19 | const remote = branchInfo.tracking 20 | ? splitRemoteBranch(branchInfo.tracking)[0] 21 | : null; 22 | 23 | if (!remote) return false; 24 | 25 | const remoteUrl = await this.plugin.gitManager.getRemoteUrl(remote); 26 | 27 | //Check for files >100mb on GitHub remote 28 | if (remoteUrl?.includes("github.com")) { 29 | const tooBigFiles = []; 30 | 31 | const gitManager = this.plugin.gitManager; 32 | for (const f of files) { 33 | const file = this.plugin.app.vault.getAbstractFileByPath( 34 | f.vaultPath 35 | ); 36 | let over100mb = false; 37 | 38 | if (file instanceof TFile) { 39 | // Prefer the cached file size if available 40 | if (file.stat.size >= 100000000) { 41 | over100mb = true; 42 | } 43 | } else { 44 | const statRes = await this.plugin.app.vault.adapter.stat( 45 | f.vaultPath 46 | ); 47 | if (statRes && statRes.size >= 100000000) { 48 | over100mb = true; 49 | } 50 | } 51 | if (over100mb) { 52 | let isFileTrackedByLfs = false; 53 | if (gitManager instanceof SimpleGit) { 54 | isFileTrackedByLfs = 55 | await gitManager.isFileTrackedByLFS(f.path); 56 | } 57 | if (!isFileTrackedByLfs) { 58 | tooBigFiles.push(f); 59 | } 60 | } 61 | } 62 | 63 | if (tooBigFiles.length > 0) { 64 | this.plugin.displayError( 65 | `Aborted commit, because the following files are too big:\n- ${tooBigFiles 66 | .map((e) => e.vaultPath) 67 | .join( 68 | "\n- " 69 | )}\nPlease remove them or add to .gitignore.` 70 | ); 71 | 72 | return true; 73 | } 74 | } 75 | return false; 76 | } 77 | async writeAndOpenFile(text?: string) { 78 | if (text !== undefined) { 79 | await this.plugin.app.vault.adapter.write( 80 | CONFLICT_OUTPUT_FILE, 81 | text 82 | ); 83 | } 84 | let fileIsAlreadyOpened = false; 85 | this.plugin.app.workspace.iterateAllLeaves((leaf) => { 86 | if ( 87 | leaf.getDisplayText() != "" && 88 | CONFLICT_OUTPUT_FILE.startsWith(leaf.getDisplayText()) 89 | ) { 90 | fileIsAlreadyOpened = true; 91 | } 92 | }); 93 | if (!fileIsAlreadyOpened) { 94 | await this.plugin.app.workspace.openLinkText( 95 | CONFLICT_OUTPUT_FILE, 96 | "/", 97 | true 98 | ); 99 | } 100 | } 101 | 102 | openDiff({ 103 | aFile, 104 | bFile, 105 | aRef, 106 | bRef, 107 | event, 108 | }: { 109 | aFile: string; 110 | bFile?: string; 111 | aRef: string; 112 | bRef?: string; 113 | event?: MouseEvent; 114 | }) { 115 | let diffStyle = this.plugin.settings.diffStyle; 116 | if (Platform.isMobileApp) { 117 | diffStyle = "git_unified"; 118 | } 119 | 120 | const state = { 121 | aFile: aFile, 122 | bFile: bFile ?? aFile, 123 | aRef: aRef, 124 | bRef: bRef, 125 | }; 126 | 127 | if (diffStyle == "split") { 128 | void getNewLeaf(this.plugin.app, event)?.setViewState({ 129 | type: SPLIT_DIFF_VIEW_CONFIG.type, 130 | active: true, 131 | state: state, 132 | }); 133 | } else if (diffStyle == "git_unified") { 134 | void getNewLeaf(this.plugin.app, event)?.setViewState({ 135 | type: DIFF_VIEW_CONFIG.type, 136 | active: true, 137 | state: state, 138 | }); 139 | } 140 | } 141 | 142 | async runRawCommand() { 143 | const gitManager = this.plugin.gitManager; 144 | if (!(gitManager instanceof SimpleGit)) { 145 | return; 146 | } 147 | const modal = new GeneralModal(this.plugin, { 148 | placeholder: "push origin master", 149 | allowEmpty: false, 150 | }); 151 | const command = await modal.openAndGetResult(); 152 | if (command === undefined) return; 153 | 154 | this.plugin.promiseQueue.addTask(async () => { 155 | const notice = new Notice(`Running '${command}'...`, 999_999); 156 | 157 | try { 158 | const res = await gitManager.rawCommand(command); 159 | if (res) { 160 | notice.setMessage(res); 161 | window.setTimeout(() => notice.hide(), 5000); 162 | } else { 163 | notice.hide(); 164 | } 165 | } catch (e) { 166 | notice.hide(); 167 | throw e; 168 | } 169 | }); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/ui/diff/diffView.ts: -------------------------------------------------------------------------------- 1 | import { html } from "diff2html"; 2 | import type { EventRef, ViewStateResult, WorkspaceLeaf } from "obsidian"; 3 | import { ItemView, Platform } from "obsidian"; 4 | import { DIFF_VIEW_CONFIG } from "src/constants"; 5 | import { SimpleGit } from "src/gitManager/simpleGit"; 6 | import type ObsidianGit from "src/main"; 7 | import type { DiffViewState } from "src/types"; 8 | 9 | export default class DiffView extends ItemView { 10 | parser: DOMParser; 11 | gettingDiff = false; 12 | state: DiffViewState; 13 | gitRefreshRef: EventRef; 14 | gitViewRefreshRef: EventRef; 15 | 16 | constructor( 17 | leaf: WorkspaceLeaf, 18 | private plugin: ObsidianGit 19 | ) { 20 | super(leaf); 21 | this.parser = new DOMParser(); 22 | this.navigation = true; 23 | this.gitRefreshRef = this.app.workspace.on( 24 | "obsidian-git:status-changed", 25 | () => { 26 | this.refresh().catch(console.error); 27 | } 28 | ); 29 | } 30 | 31 | getViewType(): string { 32 | return DIFF_VIEW_CONFIG.type; 33 | } 34 | 35 | getDisplayText(): string { 36 | if (this.state?.bFile != null) { 37 | let fileName = this.state.bFile.split("/").last(); 38 | if (fileName?.endsWith(".md")) fileName = fileName.slice(0, -3); 39 | 40 | return `Diff: ${fileName}`; 41 | } 42 | return DIFF_VIEW_CONFIG.name; 43 | } 44 | 45 | getIcon(): string { 46 | return DIFF_VIEW_CONFIG.icon; 47 | } 48 | 49 | async setState(state: DiffViewState, _: ViewStateResult): Promise { 50 | this.state = state; 51 | 52 | if (Platform.isMobile) { 53 | //Update view title on mobile only to show the file name of the diff 54 | this.leaf.view.titleEl.textContent = this.getDisplayText(); 55 | } 56 | 57 | await this.refresh(); 58 | } 59 | 60 | getState(): Record { 61 | return this.state as unknown as Record; 62 | } 63 | 64 | onClose(): Promise { 65 | this.app.workspace.offref(this.gitRefreshRef); 66 | this.app.workspace.offref(this.gitViewRefreshRef); 67 | return super.onClose(); 68 | } 69 | 70 | async onOpen(): Promise { 71 | await this.refresh(); 72 | return super.onOpen(); 73 | } 74 | 75 | async refresh(): Promise { 76 | if (this.state?.bFile && !this.gettingDiff && this.plugin.gitManager) { 77 | this.gettingDiff = true; 78 | try { 79 | let diff = await this.plugin.gitManager.getDiffString( 80 | this.state.bFile, 81 | this.state.aRef == "HEAD", 82 | this.state.bRef 83 | ); 84 | this.contentEl.empty(); 85 | 86 | const vaultPath = this.plugin.gitManager.getRelativeVaultPath( 87 | this.state.bFile 88 | ); 89 | if (!diff) { 90 | if ( 91 | this.plugin.gitManager instanceof SimpleGit && 92 | (await this.plugin.gitManager.isTracked( 93 | this.state.bFile 94 | )) 95 | ) { 96 | // File is tracked but no changes 97 | diff = [ 98 | `--- ${this.state.aFile}`, 99 | `+++ ${this.state.bFile}`, 100 | "", 101 | ].join("\n"); 102 | } else if (await this.app.vault.adapter.exists(vaultPath)) { 103 | const content = 104 | await this.app.vault.adapter.read(vaultPath); 105 | const header = `--- /dev/null 106 | +++ ${this.state.bFile} 107 | @@ -0,0 +1,${content.split("\n").length} @@`; 108 | 109 | diff = [ 110 | ...header.split("\n"), 111 | ...content.split("\n").map((line) => `+${line}`), 112 | ].join("\n"); 113 | } 114 | } 115 | 116 | if (diff) { 117 | const diffEl = this.parser 118 | .parseFromString(html(diff), "text/html") 119 | .querySelector(".d2h-file-diff"); 120 | this.contentEl.append(diffEl!); 121 | } else { 122 | const div = this.contentEl.createDiv({ 123 | cls: "obsidian-git-center", 124 | }); 125 | div.createSpan({ 126 | text: "⚠️", 127 | attr: { style: "font-size: 2em" }, 128 | }); 129 | div.createEl("br"); 130 | div.createSpan({ 131 | text: "File not found: " + this.state.bFile, 132 | }); 133 | } 134 | } finally { 135 | this.gettingDiff = false; 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/ui/history/components/logComponent.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 | 44 | 45 |
46 | 117 |
118 | 119 | 121 | -------------------------------------------------------------------------------- /src/ui/history/components/logFileComponent.svelte: -------------------------------------------------------------------------------- 1 | 60 | 61 | 62 | 63 | 64 |
{ 67 | event.stopPropagation(); 68 | if (event.button == 2) 69 | mayTriggerFileMenu( 70 | view.app, 71 | event, 72 | diff.vaultPath, 73 | view.leaf, 74 | "git-history" 75 | ); 76 | else mainClick(event); 77 | }} 78 | class="tree-item nav-file" 79 | > 80 | 105 |
106 | 107 | 114 | -------------------------------------------------------------------------------- /src/ui/history/components/logTreeComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 31 | 32 |
33 | {#each hierarchy.children as entity} 34 | {#if entity.data} 35 |
36 | 37 |
38 | {:else} 39 | 89 | {/if} 90 | {/each} 91 |
92 | 93 | 101 | -------------------------------------------------------------------------------- /src/ui/history/historyView.svelte: -------------------------------------------------------------------------------- 1 | 101 | 102 | 103 | 104 |
105 | 131 | 132 | 144 |
145 | 146 | 148 | -------------------------------------------------------------------------------- /src/ui/history/historyView.ts: -------------------------------------------------------------------------------- 1 | import type { HoverParent, HoverPopover, WorkspaceLeaf } from "obsidian"; 2 | import { ItemView } from "obsidian"; 3 | import { HISTORY_VIEW_CONFIG } from "src/constants"; 4 | import type ObsidianGit from "src/main"; 5 | import HistoryViewComponent from "./historyView.svelte"; 6 | import { mount, unmount } from "svelte"; 7 | 8 | export default class HistoryView extends ItemView implements HoverParent { 9 | plugin: ObsidianGit; 10 | private _view: Record | undefined; 11 | hoverPopover: HoverPopover | null; 12 | 13 | constructor(leaf: WorkspaceLeaf, plugin: ObsidianGit) { 14 | super(leaf); 15 | this.plugin = plugin; 16 | this.hoverPopover = null; 17 | } 18 | 19 | getViewType(): string { 20 | return HISTORY_VIEW_CONFIG.type; 21 | } 22 | 23 | getDisplayText(): string { 24 | return HISTORY_VIEW_CONFIG.name; 25 | } 26 | 27 | getIcon(): string { 28 | return HISTORY_VIEW_CONFIG.icon; 29 | } 30 | 31 | onClose(): Promise { 32 | if (this._view) { 33 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 34 | unmount(this._view); 35 | } 36 | return super.onClose(); 37 | } 38 | 39 | reload(): void { 40 | if (this._view) { 41 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 42 | unmount(this._view); 43 | } 44 | this._view = mount(HistoryViewComponent, { 45 | target: this.contentEl, 46 | props: { 47 | plugin: this.plugin, 48 | view: this, 49 | }, 50 | }); 51 | } 52 | 53 | onOpen(): Promise { 54 | this.reload(); 55 | return super.onOpen(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/modals/branchModal.ts: -------------------------------------------------------------------------------- 1 | import { FuzzySuggestModal } from "obsidian"; 2 | import type ObsidianGit from "src/main"; 3 | 4 | export class BranchModal extends FuzzySuggestModal { 5 | resolve: ( 6 | value: string | undefined | PromiseLike 7 | ) => void; 8 | 9 | constructor( 10 | plugin: ObsidianGit, 11 | private readonly branches: string[] 12 | ) { 13 | super(plugin.app); 14 | this.setPlaceholder("Select branch to checkout"); 15 | } 16 | 17 | getItems(): string[] { 18 | return this.branches; 19 | } 20 | getItemText(item: string): string { 21 | return item; 22 | } 23 | onChooseItem(item: string, _: MouseEvent | KeyboardEvent): void { 24 | this.resolve(item); 25 | } 26 | 27 | openAndGetReslt(): Promise { 28 | return new Promise((resolve) => { 29 | this.resolve = resolve; 30 | this.open(); 31 | }); 32 | } 33 | 34 | onClose() { 35 | //onClose gets called before onChooseItem 36 | void new Promise((resolve) => setTimeout(resolve, 10)).then(() => { 37 | if (this.resolve) this.resolve(undefined); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ui/modals/changedFilesModal.ts: -------------------------------------------------------------------------------- 1 | import { FuzzySuggestModal } from "obsidian"; 2 | import type ObsidianGit from "src/main"; 3 | import type { FileStatusResult } from "src/types"; 4 | 5 | export class ChangedFilesModal extends FuzzySuggestModal { 6 | plugin: ObsidianGit; 7 | changedFiles: FileStatusResult[]; 8 | 9 | constructor(plugin: ObsidianGit, changedFiles: FileStatusResult[]) { 10 | super(plugin.app); 11 | this.plugin = plugin; 12 | this.changedFiles = changedFiles; 13 | this.setPlaceholder( 14 | "Not supported files will be opened by default app!" 15 | ); 16 | } 17 | 18 | getItems(): FileStatusResult[] { 19 | return this.changedFiles; 20 | } 21 | 22 | getItemText(item: FileStatusResult): string { 23 | if (item.index == "U" && item.workingDir == "U") { 24 | return `Untracked | ${item.vaultPath}`; 25 | } 26 | 27 | let workingDir = ""; 28 | let index = ""; 29 | 30 | if (item.workingDir != " ") 31 | workingDir = `Working Dir: ${item.workingDir} `; 32 | if (item.index != " ") index = `Index: ${item.index}`; 33 | 34 | return `${workingDir}${index} | ${item.vaultPath}`; 35 | } 36 | 37 | onChooseItem(item: FileStatusResult, _: MouseEvent | KeyboardEvent): void { 38 | if ( 39 | this.plugin.app.metadataCache.getFirstLinkpathDest( 40 | item.vaultPath, 41 | "" 42 | ) == null 43 | ) { 44 | this.app.openWithDefaultApp(item.vaultPath); 45 | } else { 46 | void this.plugin.app.workspace.openLinkText(item.vaultPath, "/"); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ui/modals/customMessageModal.ts: -------------------------------------------------------------------------------- 1 | import { moment, SuggestModal } from "obsidian"; 2 | import type ObsidianGit from "src/main"; 3 | 4 | export class CustomMessageModal extends SuggestModal { 5 | resolve: 6 | | ((value: string | PromiseLike | undefined) => void) 7 | | null = null; 8 | constructor(private readonly plugin: ObsidianGit) { 9 | super(plugin.app); 10 | this.setPlaceholder( 11 | "Type your message and select optional the version with the added date." 12 | ); 13 | } 14 | 15 | openAndGetResult(): Promise { 16 | return new Promise((resolve) => { 17 | this.resolve = resolve; 18 | this.open(); 19 | }); 20 | } 21 | 22 | onClose() { 23 | // onClose gets called before onChooseItem 24 | void new Promise((resolve) => setTimeout(resolve, 10)).then(() => { 25 | if (this.resolve) this.resolve(undefined); 26 | }); 27 | } 28 | 29 | getSuggestions(query: string): string[] { 30 | const date = moment().format(this.plugin.settings.commitDateFormat); 31 | if (query == "") query = "..."; 32 | return [query, `${date}: ${query}`, `${query}: ${date}`]; 33 | } 34 | 35 | renderSuggestion(value: string, el: HTMLElement): void { 36 | el.innerText = value; 37 | } 38 | 39 | onChooseSuggestion(value: string, __: MouseEvent | KeyboardEvent) { 40 | if (this.resolve) this.resolve(value); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/modals/discardModal.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "obsidian"; 2 | import { Modal } from "obsidian"; 3 | 4 | export class DiscardModal extends Modal { 5 | constructor( 6 | app: App, 7 | private readonly deletion: boolean, 8 | private readonly filename: string 9 | ) { 10 | super(app); 11 | } 12 | resolve: ((value: boolean | PromiseLike) => void) | null = null; 13 | myOpen() { 14 | this.open(); 15 | return new Promise((resolve) => { 16 | this.resolve = resolve; 17 | }); 18 | } 19 | onOpen() { 20 | const { contentEl, titleEl } = this; 21 | titleEl.setText(`${this.deletion ? "Delete" : "Discard"} this file?`); 22 | contentEl 23 | .createEl("p") 24 | .setText( 25 | `Do you really want to ${ 26 | this.deletion ? "delete" : "discard the changes of" 27 | } "${this.filename}"` 28 | ); 29 | const div = contentEl.createDiv({ cls: "modal-button-container" }); 30 | 31 | const discard = div.createEl("button", { 32 | cls: "mod-warning", 33 | text: this.deletion ? "Delete" : "Discard", 34 | }); 35 | discard.addEventListener("click", () => { 36 | if (this.resolve) this.resolve(true); 37 | this.close(); 38 | }); 39 | discard.addEventListener("keypress", () => { 40 | if (this.resolve) this.resolve(true); 41 | this.close(); 42 | }); 43 | 44 | const close = div.createEl("button", { 45 | text: "Cancel", 46 | }); 47 | close.addEventListener("click", () => { 48 | if (this.resolve) this.resolve(false); 49 | return this.close(); 50 | }); 51 | close.addEventListener("keypress", () => { 52 | if (this.resolve) this.resolve(false); 53 | return this.close(); 54 | }); 55 | } 56 | 57 | onClose() { 58 | const { contentEl } = this; 59 | contentEl.empty(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/modals/generalModal.ts: -------------------------------------------------------------------------------- 1 | import { SuggestModal } from "obsidian"; 2 | import type ObsidianGit from "src/main"; 3 | 4 | export interface OptionalGeneralModalConfig { 5 | options?: string[]; 6 | placeholder?: string; 7 | allowEmpty?: boolean; 8 | onlySelection?: boolean; 9 | initialValue?: string; 10 | obscure?: boolean; 11 | } 12 | interface GeneralModalConfig { 13 | options: string[]; 14 | placeholder: string; 15 | allowEmpty: boolean; 16 | onlySelection: boolean; 17 | initialValue?: string; 18 | obscure: boolean; 19 | } 20 | 21 | const generalModalConfigDefaults: GeneralModalConfig = { 22 | options: [], 23 | placeholder: "", 24 | allowEmpty: false, 25 | onlySelection: false, 26 | initialValue: undefined, 27 | obscure: false, 28 | }; 29 | 30 | export class GeneralModal extends SuggestModal { 31 | resolve: ( 32 | value: string | undefined | PromiseLike 33 | ) => void; 34 | config: GeneralModalConfig; 35 | 36 | constructor(plugin: ObsidianGit, config: OptionalGeneralModalConfig) { 37 | super(plugin.app); 38 | this.config = { ...generalModalConfigDefaults, ...config }; 39 | this.setPlaceholder(this.config.placeholder); 40 | if (this.config.obscure) { 41 | this.inputEl.type = "password"; 42 | const promptContainer = this.containerEl.querySelector( 43 | ".prompt-input-container" 44 | )!; 45 | promptContainer.addClass("git-obscure-prompt"); 46 | promptContainer.setAttr("git-is-obscured", "true"); 47 | const obscureSwitchButton = promptContainer?.createDiv({ 48 | cls: "search-input-clear-button", 49 | }); 50 | obscureSwitchButton.style.marginRight = "32px"; 51 | obscureSwitchButton.id = "git-show-password"; 52 | obscureSwitchButton.addEventListener("click", () => { 53 | const isObscured = promptContainer.getAttr("git-is-obscured"); 54 | if (isObscured === "true") { 55 | this.inputEl.type = "text"; 56 | promptContainer.setAttr("git-is-obscured", "false"); 57 | } else { 58 | this.inputEl.type = "password"; 59 | promptContainer.setAttr("git-is-obscured", "true"); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | openAndGetResult(): Promise { 66 | return new Promise((resolve) => { 67 | this.resolve = resolve; 68 | this.open(); 69 | if (this.config.initialValue != undefined) { 70 | this.inputEl.value = this.config.initialValue; 71 | this.inputEl.dispatchEvent(new Event("input")); 72 | } 73 | }); 74 | } 75 | 76 | onClose() { 77 | void new Promise((resolve) => setTimeout(resolve, 10)).then(() => { 78 | if (this.resolve) this.resolve(undefined); 79 | }); 80 | } 81 | 82 | getSuggestions(query: string): string[] { 83 | if (this.config.onlySelection) { 84 | return this.config.options; 85 | } else if (this.config.allowEmpty) { 86 | return [query.length > 0 ? query : " ", ...this.config.options]; 87 | } else { 88 | return [query.length > 0 ? query : "...", ...this.config.options]; 89 | } 90 | } 91 | 92 | renderSuggestion(value: string, el: HTMLElement): void { 93 | if (this.config.obscure) { 94 | el.hide(); 95 | } else { 96 | el.setText(value); 97 | } 98 | } 99 | 100 | onChooseSuggestion(value: string, _: MouseEvent | KeyboardEvent) { 101 | if (this.resolve) { 102 | let res; 103 | if (this.config.allowEmpty && value === " ") res = ""; 104 | else if (value === "...") res = undefined; 105 | else res = value; 106 | this.resolve(res); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/ui/modals/ignoreModal.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "obsidian"; 2 | import { Modal } from "obsidian"; 3 | 4 | export class IgnoreModal extends Modal { 5 | resolve: 6 | | ((value: string | PromiseLike | undefined) => void) 7 | | null = null; 8 | constructor( 9 | app: App, 10 | private content: string 11 | ) { 12 | super(app); 13 | } 14 | 15 | openAndGetReslt(): Promise { 16 | return new Promise((resolve) => { 17 | this.resolve = resolve; 18 | this.open(); 19 | }); 20 | } 21 | 22 | onOpen() { 23 | const { contentEl, titleEl } = this; 24 | titleEl.setText("Edit .gitignore"); 25 | const div = contentEl.createDiv(); 26 | 27 | const text = div.createEl("textarea", { 28 | text: this.content, 29 | cls: ["obsidian-git-textarea"], 30 | attr: { rows: 10, cols: 30, wrap: "off" }, 31 | }); 32 | 33 | div.createEl("button", { 34 | cls: ["mod-cta", "obsidian-git-center-button"], 35 | text: "Save", 36 | }).addEventListener("click", () => { 37 | this.resolve!(text.value); 38 | this.close(); 39 | }); 40 | } 41 | 42 | onClose() { 43 | const { contentEl } = this; 44 | contentEl.empty(); 45 | if (this.resolve) this.resolve(undefined); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ui/sourceControl/components/fileComponent.svelte: -------------------------------------------------------------------------------- 1 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 |
{ 116 | event.stopPropagation(); 117 | if (event.button == 2) 118 | mayTriggerFileMenu( 119 | view.app, 120 | event, 121 | change.vaultPath, 122 | view.leaf, 123 | "git-source-control" 124 | ); 125 | else mainClick(event); 126 | }} 127 | class="tree-item nav-file" 128 | > 129 | 175 |
176 | 177 | 184 | -------------------------------------------------------------------------------- /src/ui/sourceControl/components/pulledFileComponent.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 39 | 40 |
{ 44 | event.stopPropagation(); 45 | if (event.button == 2) 46 | mayTriggerFileMenu( 47 | view.app, 48 | event, 49 | change.vaultPath, 50 | view.leaf, 51 | "git-source-control" 52 | ); 53 | else open(event); 54 | }} 55 | class="tree-item nav-file" 56 | > 57 | 72 |
73 | 74 | 81 | -------------------------------------------------------------------------------- /src/ui/sourceControl/components/stagedFileComponent.svelte: -------------------------------------------------------------------------------- 1 | 79 | 80 | 81 | 82 | 83 | 84 |
{ 88 | event.stopPropagation(); 89 | if (event.button == 2) 90 | mayTriggerFileMenu( 91 | view.app, 92 | event, 93 | change.vaultPath, 94 | view.leaf, 95 | "git-source-control" 96 | ); 97 | else mainClick(event); 98 | }} 99 | class="tree-item nav-file" 100 | > 101 | 132 |
133 | 134 | 141 | -------------------------------------------------------------------------------- /src/ui/sourceControl/components/tooManyFilesComponent.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 |
12 | {#if files.length > 500} 13 | 23 | {/if} 24 |
25 | -------------------------------------------------------------------------------- /src/ui/sourceControl/sourceControl.ts: -------------------------------------------------------------------------------- 1 | import type { HoverParent, HoverPopover, WorkspaceLeaf } from "obsidian"; 2 | import { ItemView } from "obsidian"; 3 | import { SOURCE_CONTROL_VIEW_CONFIG } from "src/constants"; 4 | import type ObsidianGit from "src/main"; 5 | import SourceControlViewComponent from "./sourceControl.svelte"; 6 | import { mount, unmount } from "svelte"; 7 | 8 | export default class GitView extends ItemView implements HoverParent { 9 | plugin: ObsidianGit; 10 | private _view: Record | undefined; 11 | hoverPopover: HoverPopover | null; 12 | 13 | constructor(leaf: WorkspaceLeaf, plugin: ObsidianGit) { 14 | super(leaf); 15 | this.plugin = plugin; 16 | this.hoverPopover = null; 17 | } 18 | 19 | getViewType(): string { 20 | return SOURCE_CONTROL_VIEW_CONFIG.type; 21 | } 22 | 23 | getDisplayText(): string { 24 | return SOURCE_CONTROL_VIEW_CONFIG.name; 25 | } 26 | 27 | getIcon(): string { 28 | return SOURCE_CONTROL_VIEW_CONFIG.icon; 29 | } 30 | 31 | onClose(): Promise { 32 | if (this._view) { 33 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 34 | unmount(this._view); 35 | } 36 | return super.onClose(); 37 | } 38 | 39 | reload(): void { 40 | if (this._view) { 41 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 42 | unmount(this._view); 43 | } 44 | this._view = mount(SourceControlViewComponent, { 45 | target: this.contentEl, 46 | props: { 47 | plugin: this.plugin, 48 | view: this, 49 | }, 50 | }); 51 | } 52 | 53 | onOpen(): Promise { 54 | this.reload(); 55 | return super.onOpen(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/statusBar/branchStatusBar.ts: -------------------------------------------------------------------------------- 1 | import type ObsidianGit from "src/main"; 2 | 3 | export class BranchStatusBar { 4 | constructor( 5 | private statusBarEl: HTMLElement, 6 | private readonly plugin: ObsidianGit 7 | ) { 8 | this.statusBarEl.addClass("mod-clickable"); 9 | this.statusBarEl.onClickEvent((_) => { 10 | this.plugin.switchBranch().catch((e) => plugin.displayError(e)); 11 | }); 12 | } 13 | 14 | async display() { 15 | if (this.plugin.gitReady) { 16 | const branchInfo = await this.plugin.gitManager.branchInfo(); 17 | if (branchInfo.current != undefined) { 18 | this.statusBarEl.setText(branchInfo.current); 19 | } else { 20 | this.statusBarEl.empty(); 21 | } 22 | } else { 23 | this.statusBarEl.empty(); 24 | } 25 | } 26 | 27 | remove() { 28 | this.statusBarEl.remove(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as cssColorConverter from "css-color-converter"; 2 | import deepEqual from "deep-equal"; 3 | import type { App, RGB, WorkspaceLeaf } from "obsidian"; 4 | import { Keymap, Menu, moment, TFile } from "obsidian"; 5 | import { BINARY_EXTENSIONS } from "./constants"; 6 | 7 | export const worthWalking = (filepath: string, root?: string) => { 8 | if (filepath === "." || root == null || root.length === 0 || root === ".") { 9 | return true; 10 | } 11 | if (root.length >= filepath.length) { 12 | return root.startsWith(filepath); 13 | } else { 14 | return filepath.startsWith(root); 15 | } 16 | }; 17 | 18 | export function getNewLeaf( 19 | app: App, 20 | event?: MouseEvent 21 | ): WorkspaceLeaf | undefined { 22 | let leaf: WorkspaceLeaf | undefined; 23 | if (event) { 24 | if (event.button === 0 || event.button === 1) { 25 | const type = Keymap.isModEvent(event); 26 | leaf = app.workspace.getLeaf(type); 27 | } 28 | } else { 29 | leaf = app.workspace.getLeaf(false); 30 | } 31 | return leaf; 32 | } 33 | 34 | export function mayTriggerFileMenu( 35 | app: App, 36 | event: MouseEvent, 37 | filePath: string, 38 | view: WorkspaceLeaf, 39 | source: string 40 | ) { 41 | if (event.button == 2) { 42 | const file = app.vault.getAbstractFileByPath(filePath); 43 | if (file != null) { 44 | const fileMenu = new Menu(); 45 | app.workspace.trigger("file-menu", fileMenu, file, source, view); 46 | fileMenu.showAtPosition({ x: event.pageX, y: event.pageY }); 47 | } else { 48 | const fileMenu = new Menu(); 49 | app.workspace.trigger( 50 | "obsidian-git:menu", 51 | fileMenu, 52 | filePath, 53 | source, 54 | view 55 | ); 56 | fileMenu.showAtPosition({ x: event.pageX, y: event.pageY }); 57 | } 58 | } 59 | } 60 | 61 | /** 62 | * Creates a type-error, if this function is in a possible branch. 63 | * 64 | * Use this to ensure exhaustive switch cases. 65 | * 66 | * During runtime, an error will be thrown, if executed. 67 | */ 68 | export function impossibleBranch(x: never): never { 69 | /* eslint-disable-next-line @typescript-eslint/restrict-plus-operands */ 70 | throw new Error("Impossible branch: " + x); 71 | } 72 | 73 | export function rgbToString(rgb: RGB): string { 74 | return `rgb(${rgb.r},${rgb.g},${rgb.b})`; 75 | } 76 | 77 | export function convertToRgb(str: string): RGB | undefined { 78 | const color = cssColorConverter.fromString(str)?.toRgbaArray(); 79 | if (color === undefined) { 80 | return undefined; 81 | } 82 | const [r, g, b] = color; 83 | return { r, g, b }; 84 | } 85 | 86 | export function momentToEpochSeconds(instant: moment.Moment): number { 87 | return instant.diff(moment.unix(0), "seconds"); 88 | } 89 | 90 | export function median(array: number[]): number | undefined { 91 | if (array.length === 0) return undefined; 92 | return array.slice().sort()[Math.floor(array.length / 2)]; 93 | } 94 | 95 | export function strictDeepEqual(a: T, b: T): boolean { 96 | return deepEqual(a, b, { strict: true }); 97 | } 98 | 99 | export function arrayProxyWithNewLength(array: T[], length: number): T[] { 100 | return new Proxy(array, { 101 | get(target, prop) { 102 | if (prop === "length") { 103 | return Math.min(length, target.length); 104 | } 105 | return target[prop as keyof T[]]; 106 | }, 107 | }); 108 | } 109 | 110 | export function resizeToLength( 111 | original: string, 112 | desiredLength: number, 113 | fillChar: string 114 | ): string { 115 | if (original.length <= desiredLength) { 116 | const prefix = new Array(desiredLength - original.length) 117 | .fill(fillChar) 118 | .join(""); 119 | return prefix + original; 120 | } else { 121 | return original.substring(original.length - desiredLength); 122 | } 123 | } 124 | 125 | export function prefixOfLengthAsWhitespace( 126 | toBeRenderedText: string, 127 | whitespacePrefixLength: number 128 | ): string { 129 | if (whitespacePrefixLength <= 0) return toBeRenderedText; 130 | 131 | const whitespacePrefix = new Array(whitespacePrefixLength) 132 | .fill(" ") 133 | .join(""); 134 | const originalSuffix = toBeRenderedText.substring( 135 | whitespacePrefixLength, 136 | toBeRenderedText.length 137 | ); 138 | return whitespacePrefix + originalSuffix; 139 | } 140 | 141 | export function between(l: number, x: number, r: number) { 142 | return l <= x && x <= r; 143 | } 144 | export function splitRemoteBranch( 145 | remoteBranch: string 146 | ): readonly [string, string | undefined] { 147 | const [remote, ...branch] = remoteBranch.split("/"); 148 | return [remote, branch.length === 0 ? undefined : branch.join("/")]; 149 | } 150 | 151 | export function getDisplayPath(path: string): string { 152 | if (path.endsWith("/")) return path; 153 | return path.split("/").last()!.replace(/\.md$/, ""); 154 | } 155 | 156 | export function formatMinutes(minutes: number): string { 157 | if (minutes === 1) return "1 minute"; 158 | return `${minutes} minutes`; 159 | } 160 | 161 | export function getExtensionFromPath(path: string): string { 162 | const dotIndex = path.lastIndexOf("."); 163 | return path.substring(dotIndex + 1); 164 | } 165 | 166 | /** 167 | * Decides if a file is binary based on its extension. 168 | */ 169 | export function fileIsBinary(path: string): boolean { 170 | // This is the case for the most files so we can save some time 171 | if (path.endsWith(".md")) return false; 172 | 173 | const ext = getExtensionFromPath(path); 174 | 175 | return BINARY_EXTENSIONS.includes(ext); 176 | } 177 | 178 | export function formatRemoteUrl(url: string): string { 179 | if ( 180 | url.startsWith("https://github.com/") || 181 | url.startsWith("https://gitlab.com/") 182 | ) { 183 | if (!url.endsWith(".git")) { 184 | url = url + ".git"; 185 | } 186 | } 187 | return url; 188 | } 189 | 190 | export function fileOpenableInObsidian( 191 | relativeVaultPath: string, 192 | app: App 193 | ): boolean { 194 | const file = app.vault.getAbstractFileByPath(relativeVaultPath); 195 | if (!(file instanceof TFile)) { 196 | return false; 197 | } 198 | try { 199 | // Internal Obsidian API function 200 | // If a view type is registired for the file extension, it can be opened in Obsidian. 201 | // Just checking if Obsidian tracks the file is not enough, 202 | // because it can also track files, it can only open externally. 203 | return !!app.viewRegistry.getTypeByExtension(file.extension); 204 | } catch { 205 | // If the function doesn't exist anymore, it will throw an error. In that case, just skip the check. 206 | return true; 207 | } 208 | } 209 | 210 | export function convertPathToAbsoluteGitignoreRule({ 211 | isFolder, 212 | gitRelativePath, 213 | }: { 214 | isFolder?: boolean; 215 | gitRelativePath: string; 216 | }): string { 217 | // Add a leading slash to set the rule as absolute from root, so it only excludes that exact path 218 | let composedPath = "/"; 219 | 220 | composedPath += gitRelativePath; 221 | 222 | // Add an explicit folder rule, so that the same path doesn't also apply for files with that same name 223 | if (isFolder) { 224 | composedPath += "/"; 225 | } 226 | 227 | // Escape special characters, so that git treats them as literal characters. 228 | const escaped = composedPath.replace(/([\\!#*?[\]])/g, String.raw`\$1`); 229 | 230 | // Then escape each trailing whitespace character individually, because git trims trailing whitespace from the end of the rule. 231 | // 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. 232 | return escaped.replace(/\s(?=\s*$)/g, String.raw`\ `); 233 | } 234 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "isolatedModules": true, 11 | "moduleResolution": "node", 12 | "strictNullChecks": true, 13 | "importHelpers": true, 14 | "allowSyntheticDefaultImports": true, 15 | "verbatimModuleSyntax": true, 16 | "lib": [ 17 | "DOM", 18 | "ES5", 19 | "ES6", 20 | "ES7" 21 | ], 22 | "skipLibCheck": true, 23 | "noUnusedLocals": false 24 | }, 25 | "include": [ 26 | "**/*.ts", 27 | "eslint.config.mjs", 28 | "**/*.svelte" 29 | ] 30 | } 31 | --------------------------------------------------------------------------------