Compare commits

...

148 commits
0.0.2 ... main

Author SHA1 Message Date
Silvano Cerza
2ebd201da9 Add brief contributing instructions in README.md 2025-05-25 16:52:44 +02:00
Silvano Cerza
ee9c2ee236 Bump version to 1.0.7 2025-05-22 19:43:02 +02:00
Silvano Cerza
b6eb9ed62d Fix conflict view not being scrollable 2025-05-22 19:34:41 +02:00
Silvano Cerza
59f951f2b8 Fix sync not working if files have been deleted, moved, or renamed 2025-05-22 16:50:02 +02:00
Silvano Cerza
c62afe938b Fix build workflow 2025-05-13 09:42:03 +02:00
Silvano Cerza
69841aa574 Fix log file being synced if settings sync is enabled 2025-04-20 21:25:24 +02:00
Silvano Cerza
345826443f Fix issue reading binary files on iOS causing sync to fail 2025-04-20 17:33:13 +02:00
Silvano Cerza
e33de48075 Create remote manifest on first sync with local manifest content 2025-04-20 15:42:14 +02:00
Silvano Cerza
b2f3db6050 Fix first sync marked as done on failure 2025-04-20 13:16:57 +02:00
Silvano Cerza
be547d8691 Fix trying to sync deleted files on first sync 2025-04-20 13:16:53 +02:00
Silvano Cerza
4fa2526322 Fix release workflow marking beta version as prerelease 2025-04-18 19:43:23 +02:00
Silvano Cerza
786cefaf4d Correctly update metadata when extracting zip 2025-04-18 19:41:16 +02:00
Silvano Cerza
654fa6b455 Add some more logs 2025-04-18 18:14:36 +02:00
Silvano Cerza
1e16d01adf Skip hidden files when unzipping repo on first sync from remote 2025-04-18 18:06:20 +02:00
Silvano Cerza
56bd6dee4c Fix crashes when unzipping repo on first sync from remote 2025-04-18 18:05:30 +02:00
Silvano Cerza
b6be53ca16 Add retry to most requests made by SyncManager 2025-04-17 12:57:57 +02:00
Silvano Cerza
53651f5b88 Adjust benchmark script with new imports and add some more progress logs 2025-04-17 12:57:57 +02:00
Silvano Cerza
263623dad7 Fix hasTextExtension util function 2025-04-17 12:57:57 +02:00
Silvano Cerza
13383dc65c Change all GithubClient method to support retry and slightly change arguments type 2025-04-17 12:57:57 +02:00
Silvano Cerza
15cc51014c Add utility method to retry a function with exponential backoff 2025-04-17 12:57:53 +02:00
Silvano Cerza
96da957f18 Fix benchmark script and Obsidian mock 2025-04-17 10:02:23 +02:00
Silvano Cerza
457a0b6d24 Fix repo unzip 2025-04-17 09:57:17 +02:00
Silvano Cerza
1a2eb64959 Simplify logic that determines if a file is text or binary 2025-04-16 16:58:23 +02:00
Silvano Cerza
5ab20cf482 Update README with logs instructions 2025-04-16 16:26:32 +02:00
Silvano Cerza
d0f04d5217 Add utility buttons in settings to copy or clean logs 2025-04-16 16:25:02 +02:00
Silvano Cerza
7e7eddaec3 Remove unnecessary import 2025-04-16 15:02:38 +02:00
Silvano Cerza
6c9b8625c3 Enhance first sync from remote to make less requests 2025-04-16 15:02:14 +02:00
Silvano Cerza
271d90b7f9 Update compatibility FAQ 2025-04-14 18:15:06 +02:00
Silvano Cerza
de1c814260 Update README with FAQ section and clarify sync compatibility 2025-04-14 18:14:26 +02:00
Silvano Cerza
32dee2db4c Rework release workflow to create beta releases 2025-04-02 15:09:30 +02:00
Silvano Cerza
415532ff07 Add manifest for beta testing 2025-04-02 14:57:44 +02:00
Silvano Cerza
5879d22a52 Fix text files being treated as binaries if their size is big 2025-04-01 13:09:46 +02:00
Silvano Cerza
bbe05f716a Add more benchmark cases and cleanup some stuff 2025-04-01 13:03:50 +02:00
Silvano Cerza
17f869f9b3 Change generateRandomFile to create all files with specified size 2025-03-31 15:56:16 +02:00
Silvano Cerza
b26853fb3a Fix cleanup 2025-03-31 14:56:28 +02:00
Silvano Cerza
91137ebf82 Fix metadata missing 2025-03-31 10:55:15 +02:00
Silvano Cerza
035f223aef Add benchmark script 2025-03-30 18:19:24 +02:00
Silvano Cerza
f2452b1c60 Fix mocked config dir and requestUrl 2025-03-30 17:54:13 +02:00
Silvano Cerza
4f018043f2 Mock Obsidian internals 2025-03-30 11:11:18 +02:00
Silvano Cerza
e0fca4dc7a Add benchmark_result.json to gitignore 2025-03-30 11:10:56 +02:00
Silvano Cerza
eec44d8190 Add dependencies to run benchmarks 2025-03-30 11:10:15 +02:00
Silvano Cerza
acf5157971 Remove unnecessary import 2025-03-30 11:10:02 +02:00
Silvano Cerza
03758866fb Change install instructions 2025-03-27 10:14:46 +01:00
Silvano Cerza
b7dbbba7b8 Bump version to 1.0.6 2025-03-21 17:03:38 +01:00
Silvano Cerza
5f0c8b1ed2 Fix binary files failing to upload to GitHub 2025-03-21 17:03:13 +01:00
Silvano Cerza
c1d1db6df3 Bump version to 1.0.5 2025-03-20 09:54:43 +01:00
Silvano Cerza
7a7c13fb5b Correctly set all settings section headings 2025-03-20 09:53:39 +01:00
Silvano Cerza
6fbf79cc87 Remove top-level heading in settings tab 2025-03-20 09:51:40 +01:00
Silvano Cerza
083e8db779 Correctly use sentence case in UI 2025-03-20 09:49:49 +01:00
Silvano Cerza
65f77c5720 Correctly register listener events 2025-03-20 09:49:01 +01:00
Silvano Cerza
cb4b3bea24 Bump version to 1.0.4 2025-03-09 19:26:14 +01:00
Silvano Cerza
45481a9ff9 Change enable logging setting default to false 2025-03-09 19:25:55 +01:00
Silvano Cerza
038a36f03c Bump version to 1.0.3 2025-03-09 19:21:13 +01:00
Silvano Cerza
cccf97b0c4 Change sync config setting default to false 2025-03-09 19:20:53 +01:00
Silvano Cerza
0affcbb31e Bump version to 1.0.2 2025-03-09 19:14:16 +01:00
Silvano Cerza
73654d5df0 Add styles.css to release files 2025-03-09 19:13:47 +01:00
Silvano Cerza
ba541d9390 Remove hardcoded styles 2025-03-09 19:13:37 +01:00
Silvano Cerza
59f93d0aba Update name in README.md: 2025-03-09 18:57:02 +01:00
Silvano Cerza
3979e73708 Bump version to 1.0.1 2025-03-09 18:55:52 +01:00
Silvano Cerza
3f16253487 Rename plugin 2025-03-09 18:55:32 +01:00
Silvano Cerza
c21e728e3c Bump version to 1.0.0 2025-03-09 18:32:32 +01:00
Silvano Cerza
b20cece211 Update README.md 2025-03-09 18:29:55 +01:00
Silvano Cerza
12feb2c90f Add button to reset plugin settings and metadata 2025-03-09 18:13:59 +01:00
Silvano Cerza
42c15f4479 Move diff highlight for split view in editor theme 2025-03-09 17:20:18 +01:00
Silvano Cerza
928ad73696 Remove unnecessary import 2025-03-09 16:48:05 +01:00
Silvano Cerza
8732cdc1f0 Ditch old/new nomenclature in favour of remote/local to avoid confusion 2025-03-09 16:47:08 +01:00
Silvano Cerza
41642b5174 Add settings to choose diff view mode 2025-03-09 16:39:25 +01:00
Silvano Cerza
937e576bcc Restructure conflict view files and add some documentation 2025-03-09 16:22:36 +01:00
Silvano Cerza
8dfa1fe592 Fix type checking issue 2025-03-09 15:43:27 +01:00
Silvano Cerza
bb21bf6c2e Add conflict resolution in unified view 2025-03-09 15:10:03 +01:00
Silvano Cerza
68c3b4e6df Handle conflict resolution 2025-03-06 17:37:00 +01:00
Silvano Cerza
d8ebee26e8 Move line styles to theme 2025-03-06 17:12:10 +01:00
Silvano Cerza
46186d8d23 Styled some stuff 2025-03-06 17:07:11 +01:00
Silvano Cerza
d061ab4803 Handle some errors when user tries to open unified view while already open 2025-03-06 16:32:57 +01:00
Silvano Cerza
e8c95d2824 Handle merging ranges 2025-03-05 18:58:03 +01:00
Silvano Cerza
dc2962cec8 Avoid creating line mapping by creating ranges directly 2025-03-05 17:18:43 +01:00
Silvano Cerza
08d5d05331 Adjust some stuff 2025-03-05 16:46:11 +01:00
Silvano Cerza
132ffff36e Other stuff that works 2025-03-05 15:31:04 +01:00
Silvano Cerza
e2a536c945 Testing things, kinda works 2025-03-03 16:17:11 +01:00
Silvano Cerza
f5ed38f341 Restructure diff view to get ready for mobile view 2025-03-01 18:44:48 +01:00
Silvano Cerza
f72ce44504 Some renames in the conflict view 2025-03-01 18:24:06 +01:00
Silvano Cerza
b263e24941 Move conflict view in root view and adjust style 2025-03-01 18:11:43 +01:00
Silvano Cerza
0ca42124db Respect user settings on conflict 2025-03-01 17:13:43 +01:00
Silvano Cerza
7bca8262f4 Add settings to hide button to open conflicts view 2025-03-01 17:05:09 +01:00
Silvano Cerza
fc890b5d95 Purge onboarding flow
The onboarding flow and UI was a bit clunky and made it annoying to handle
failures, backtracking, errors made by the user, etc.
I decided to remove it completely and just show a notice to the user if
the plugin is not correctly configured, forcing them to take action.
Documentation is going to be key in teaching the user how to configure
the plugin properly.
2025-03-01 16:43:25 +01:00
Silvano Cerza
b9133477c0 Add custom error for GithubClient failures 2025-03-01 16:40:53 +01:00
Silvano Cerza
cd0f54a117 Fix logger not creating log file 2025-02-28 17:51:55 +01:00
Silvano Cerza
28abd1c1cc Clarifies what metadata sha is 2025-02-28 17:40:47 +01:00
Silvano Cerza
7d389f8e0c Handle corner case in conflict check 2025-02-28 17:39:06 +01:00
Silvano Cerza
e4409cb96e Properly decode emoji chars 2025-02-28 17:35:01 +01:00
Silvano Cerza
731920d5a1 Adjust conflict files tabs 2025-02-28 17:15:49 +01:00
Silvano Cerza
b8df463e66 Change filename occurances to filePath for consistency 2025-02-28 17:01:14 +01:00
Silvano Cerza
fa192eb197 Fix some issues with conflict resolution and syncing 2025-02-28 16:57:50 +01:00
Silvano Cerza
a1fea0cb95 Fix issue when rerendering conflict view 2025-02-25 18:50:12 +01:00
Silvano Cerza
2d8ee9f7bd Handle conflicts resolution end to end 2025-02-25 18:47:01 +01:00
Silvano Cerza
4745f91bdc Handle resolved conflicts 2025-02-23 17:30:57 +01:00
Silvano Cerza
cef814f702 Rework how conflicts are passed from SyncManager to ConflictsResolutionView 2025-02-23 16:43:22 +01:00
Silvano Cerza
1d4bc03aac Change GithubClient.getBlob use the sha instead of the URL 2025-02-23 16:42:56 +01:00
Silvano Cerza
d5eec99546 Fix highlight not getting updated when switching files 2025-02-22 17:48:51 +01:00
Silvano Cerza
72a7a7ed62 Rework some stuff 2025-02-22 17:43:45 +01:00
Silvano Cerza
19f797c77a Move FilesTabBar out of DiffView 2025-02-22 17:02:38 +01:00
Silvano Cerza
ca0049c383 Add key for tab elements 2025-02-22 16:50:17 +01:00
Silvano Cerza
c315378fb7 Add tab bar to select files with conflicts 2025-02-22 16:45:23 +01:00
Silvano Cerza
0721aa9d90 Fix annoying TS error 2025-02-22 11:12:55 +01:00
Silvano Cerza
55e1d21e93 Correctly clip the svg paths in the ActionsGutter 2025-02-22 11:08:18 +01:00
Silvano Cerza
88fa756ab9 Handle scrollable editors in the ActionsGutter 2025-02-22 11:03:16 +01:00
Silvano Cerza
64d0223aa0 Clamp diffs lines after merging 2025-02-21 18:40:17 +01:00
Silvano Cerza
bca72c4938 Fix corner case when clamping lines of modify DiffChunks 2025-02-21 18:12:27 +01:00
Silvano Cerza
6c369232e3 Fix action buttons spanning multiple lines not being shown 2025-02-20 19:20:15 +01:00
Silvano Cerza
aae730f0f1 Handle line numbers greater than document lines number 2025-02-20 19:16:01 +01:00
Silvano Cerza
3a7454b86f Better handling diff 2025-02-20 19:10:54 +01:00
Silvano Cerza
f49adaccc8 Remove commented out code 2025-02-20 19:05:18 +01:00
Silvano Cerza
2dcbd11ed0 Fix buttons positioning in case the ribbons spans multiple lines 2025-02-20 19:04:47 +01:00
Silvano Cerza
fd100345a3 Use better diff algorithm 2025-02-20 18:41:16 +01:00
Silvano Cerza
a0f2439b1f Handle action gutter buttons click 2025-02-20 18:36:38 +01:00
Silvano Cerza
fd7cffffab Fix editor height 2025-02-20 18:15:55 +01:00
Silvano Cerza
b5ebe4a638 Move editor content change callback 2025-02-20 18:09:21 +01:00
Silvano Cerza
165f2d277d Fix some sizing stuff 2025-02-20 18:08:46 +01:00
Silvano Cerza
c8908eb7c3 Remove commented out code 2025-02-20 17:57:33 +01:00
Silvano Cerza
9c374f19f2 Restyle actions ribbons in gutter 2025-02-20 17:55:11 +01:00
Silvano Cerza
e024c2c4df Add buttons in actions gutter 2025-02-20 17:54:28 +01:00
Silvano Cerza
8b1d04d110 Handle grid sizes in a nicer way 2025-02-20 14:25:54 +01:00
Silvano Cerza
6ff48b9517 Go with react-codemirror lib 2025-02-20 10:30:22 +01:00
Silvano Cerza
d1b980f031 Throw away dead code 2025-02-19 15:43:36 +01:00
Silvano Cerza
5fd4c94566 Refactor to be more concise 2025-02-19 11:49:09 +01:00
Silvano Cerza
63f9afe738 Bad attempt at diff apply 2025-02-18 11:13:13 +01:00
Silvano Cerza
5edb8ca742 Handle resize 2025-02-13 17:54:59 +01:00
Silvano Cerza
9f62324c2a More progress 2025-02-13 17:23:43 +01:00
Silvano Cerza
6d7c0c8772 Some styling 2025-02-13 15:13:47 +01:00
Silvano Cerza
f409216320 First rought implementation of conflicts resolution view 2025-02-13 14:43:46 +01:00
Silvano Cerza
c0792c79e7 Show notice on sync start, success and failure 2025-02-12 18:34:55 +01:00
Silvano Cerza
3f06dd4f90 Correctly run sync interval as async 2025-02-12 15:25:58 +01:00
Silvano Cerza
6888899e17 Set firstStart to true on mobile 2025-01-27 11:43:08 +01:00
Silvano Cerza
03a3bde3bb Bump version to 0.0.6 2025-01-23 18:19:02 +01:00
Silvano Cerza
98b931ead5 Change GithubClient to use settings directly 2025-01-23 18:18:31 +01:00
Silvano Cerza
8843ae0d17 Bump version to 0.0.5 2025-01-23 18:05:28 +01:00
Silvano Cerza
f64ff13b88 Use mobile compatible util function to convert from base64 string 2025-01-23 18:05:03 +01:00
Silvano Cerza
acefd43f60 Bump version to 0.0.4 2025-01-23 17:35:00 +01:00
Silvano Cerza
5446560b86 Check if syncing is in progress before starting 2025-01-23 17:34:27 +01:00
Silvano Cerza
476b17ba05 Move manifest file name definition 2025-01-23 17:25:44 +01:00
Silvano Cerza
0ba9420b3a Change conflict setting default value and disable selection dropdown 2025-01-23 17:19:16 +01:00
Silvano Cerza
fa995a392e Update README.md with some info about logging 2025-01-23 16:20:00 +01:00
Silvano Cerza
ac1e53f79c Add settings to disable logging 2025-01-23 16:19:47 +01:00
Silvano Cerza
2c4ed3e671 Add logger 2025-01-23 16:12:30 +01:00
Silvano Cerza
0ca0be97f6 Bump version to 0.0.3 2025-01-23 13:13:24 +01:00
Silvano Cerza
3bd65baf7b Fix config sync during onboarding 2025-01-23 13:12:57 +01:00
Silvano Cerza
524c7efed7 Fix syncable check in events listener 2025-01-23 13:12:42 +01:00
Silvano Cerza
f23c00c32a Update README.md 2025-01-23 11:39:50 +01:00
41 changed files with 4818 additions and 979 deletions

View file

@ -3,7 +3,7 @@ name: Release plugin
on: on:
push: push:
tags: tags:
- "[0-9].[0-9]+.[0-9]+" - "[0-9].[0-9]+.[0-9]+*"
jobs: jobs:
build: build:
@ -20,13 +20,21 @@ jobs:
- name: Build plugin - name: Build plugin
run: | run: |
npm install npm install -g pnpm@latest-10
npm run build pnpm install
pnpm build
- name: Create release - name: Create release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release create "${{ github.ref_name }}" \ if [[ "${{ github.ref_name }}" == *-beta* ]]; then
--title="${{ github.ref_name }}" \ gh release create "${{ github.ref_name }}" \
main.js manifest.json --title="${{ github.ref_name }}" \
--prerelease \
main.js manifest.json styles.css
else
gh release create "${{ github.ref_name }}" \
--title="${{ github.ref_name }}" \
main.js manifest.json styles.css
fi

2
.gitignore vendored
View file

@ -156,3 +156,5 @@ data.json
# Exclude macOS Finder (System Explorer) View States # Exclude macOS Finder (System Explorer) View States
.DS_Store .DS_Store
benchmark_result.json

101
README.md
View file

@ -1,10 +1,7 @@
# Obsidian GitHub Sync # GitHub Gitless Sync
Plugin to sync a GitHub repository with an Obsidian vault. Plugin to sync a GitHub repository with an Obsidian vault.
> [!CAUTION]
> This is still in beta.
I highly recommend not using this plugin with another sync service. I highly recommend not using this plugin with another sync service.
This might create problems for this plugin when determining what needs to be synced between remote repository and local vault. This might create problems for this plugin when determining what needs to be synced between remote repository and local vault.
@ -17,37 +14,46 @@ These are the main features of the plugin:
- Multiple vaults sync - Multiple vaults sync
- Automatic sync on fixed interval - Automatic sync on fixed interval
- Manual sync - Manual sync
- Conflict resolution view
- Conflicts handling (TODO 🔨)
- Filtering by file type (TODO 🔨) - Filtering by file type (TODO 🔨)
## Installation ## Installation
The plugin is still in beta so it's still not available in the community plugins. The plugin is available as a community plugin, you can easily search for it in Obsidian and install it from there.
For the time being you can install it with BRAT. If you never used BRAT see [the official quick start guide](https://tfthacker.com/brat-quick-guide). ![Obsidian community plugin settings](./assets/install_instructions.png)
If you already have BRAT installed to install GitHub Sync copy the following link and paste it in the browser address bar. ### Issues
``` If you find any problem please open an issue with as many details as possible.
obsidian://brat?plugin=https://github.com/silvanocerza/obsidian-github-sync
``` Please also provide logs if possible, you can copy them from the settings page. Remember to enable logging first.
![Enable logging](./assets/logs_settings.png)
## Usage ## Usage
### First sync ### First sync
When starting the plugin for the first time a dialog will guide you through the setup process. > [!IMPORTANT]
> The first sync will only work if either the remote repository or the local vault are completely **EMPTY**. If both contain files the first sync will fail.
If you already have files in your vault I strongly recommend you to create a new private GitHub repository and sync with that. You must also configure the plugin settings before syncing.
> [!NOTE] These settings are mandatory:
> Onboarding is currently not supported on mobile.
- Your GitHub Token (see below)
- Repository owner
- Repository name
- Repository branch
If any of this is not set sync won't start.
### Token ### Token
A GitHub Fine-grained token is required to sync with your repository. You can create one by clicking [here](https://github.com/settings/personal-access-tokens/new). A GitHub Fine-grained token is required to sync with your repository. You can create one by clicking [here](https://github.com/settings/personal-access-tokens/new).
The token must have the `Contents` permission set to `Read and write` like in the screenshow below. The token must have the `Contents` permission set to `Read and write` like in the screenshot below.
![GitHub Fine-grained token](./assets/token_permissions.png) ![GitHub Fine-grained token](./assets/token_permissions.png)
@ -64,22 +70,71 @@ If you don't want to see the button you can hide it, just check the plugin setti
The `Sync with GitHub` command is also available. The `Sync with GitHub` command is also available.
### Conflict resolution
When you sync multiple vaults using this plugin you might risk creating conflicts between the remote and a local vault.
This usually happens when the remote has a new update from vault A, but vault B edits the file before syncing with remote.
That creates a conflict, by default we'll open a view to let you resolve the conflict since you should have all the necessary
information to correctly resolve it.
By default the split view will be used on desktop and the unified one on mobile, you can change the settings to always use the one you prefer.
![Split conflict resolution](./assets/split_diff_view.png)
![Unified conflict resolution](./assets/unified_diff_view.png)
If you don't want to resolve them you can change the settings to always prefer either the remote or local version in case of conflicts.
### Config sync ### Config sync
If you want to sync your vault configs with other vault you can enable that. If you want to sync your vault configs with other vault you can enable that.
It will sync the whole folder, that is `.obsidian` by default, including all plugins and themes. It will sync the whole folder, that is `.obsidian` by default, including all plugins and themes.
> [!NOTE] Note that the `.obsidian` folder will always be present, this happens because the plugin
> The `.obsidian` folder will always be in the root of your repository, even if you choose needs to store some metadata to correctly sync
> to not sync vault configs. That happens cause the plugin needs to store some metadata to
> correctly sync. > [!CAUTION]
> DO NOT sync configs if your remote repository is public.
> That will expose the token you used to sync.
### Reset ### Reset
I still have to add a reset button to clean the plugin settings and metadata. If you need to reset the plugin settings and metadata you can easily do that in the settings.
For the time being you can reset the plugin by disabling it in the plugins list and deleting the `github-sync-metadata.json` That will completely wipe all the sync metadata so you'll have to repeat the first sync as if you just enabled the plugin for the first time.
file in your config directory, `.obsidian` by default.
## FAQs
### What's different from other sync plugins?
There are obviously other plugins that let you sync your vault with GitHub or other git hosts, like [`obsidian-git`](https://github.com/Vinzent03/obsidian-git) and [`Obsidian-GitHub-Sync`](https://github.com/kevinmkchin/Obsidian-GitHub-Sync) just to name a couple.
Most of those plugins though require the `git` executable to be present in your system, they might rely on Bash scripts too. This makes them much less portable, it's harder to use on Windows, and mobile is really unstable because most of the times they rely on [`isomorphic-git`](https://isomorphic-git.org/).
This annoyed me because I wanted to have the same experience on every platform, and I wanted especially to support mobile.
So I went a different way and chose to sync **only** with GitHub using their REST APIs, this means I don't rely in anyway on `git` being present in your system. This way I can easily support desktop and mobile with the same identical logic, and some small necessary differences in the UI for a better user experience.
This obviously comes with some limitations. Since `git` is not used you can't interact with your repository locally in any way, and any `git` feature like branching, merging, or rebasing, are not available at all.
Also since this relies only on the GitHub APIs you can only sync with GitHub and no other host.
### Can I use this with other sync plugins?
No.
To work correctly this plugin uses a custom metadata file that is updated every time we sync, if you commit changes outside the plugin that file is not updated properly.
Other plugins don't know about that file, so if you sync with others too you risk losing data.
## Contributing
Contributions are obviously accepted.
Bug fixes are always welcome, new feature must be discussed first. Open a [discussion](https://github.com/silvanocerza/github-gitless-sync/discussions) and let's talk in that case.
Keep PRs as small as possible, I won't review PRs with hundreds of lines if it's mostly code.
Low quality or vibe coded PRs are not welcome. Put some effort in it please.
## License ## License

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
assets/logs_settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/split_diff_view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

280
benchmark.ts Normal file
View file

@ -0,0 +1,280 @@
import * as fs from "fs";
import * as proxyquire from "proxyquire";
import * as obsidianMocks from "./mock-obsidian";
import * as os from "os";
import * as path from "path";
import * as crypto from "crypto";
import { execSync } from "child_process";
const proxyquireNonStrict = proxyquire.noCallThru();
const LoggerModule = proxyquireNonStrict("./src/logger", {
obsidian: obsidianMocks,
});
const MetadataStoreModule = proxyquireNonStrict("./src/metadata-store", {
obsidian: obsidianMocks,
});
const EventsListenerModule = proxyquireNonStrict("./src/events-listener", {
obsidian: obsidianMocks,
"./metadata-store": MetadataStoreModule,
});
const UtilsModule = proxyquireNonStrict("./src/utils", {
obsidian: obsidianMocks,
});
const GithubClientModule = proxyquireNonStrict("./src/github/client", {
obsidian: obsidianMocks,
"src/utils": UtilsModule,
});
const SyncManagerModule = proxyquireNonStrict("./src/sync-manager", {
obsidian: obsidianMocks,
"./metadata-store": MetadataStoreModule,
"./events-listener": EventsListenerModule,
"./github/client": GithubClientModule,
"./utils": UtilsModule,
});
async function runBenchmark(vaultRootDir: string) {
const vault = new obsidianMocks.Vault(vaultRootDir);
// Create a real logger with our mock vault
const logger = new LoggerModule.default(vault, false);
// Settings for the sync manager
const settings = {
githubToken: process.env.GITHUB_TOKEN,
githubOwner: process.env.REPO_OWNER,
githubRepo: process.env.REPO_NAME,
githubBranch: process.env.REPO_BRANCH,
syncConfigDir: false,
};
// We're not going to get any conflicts, this is useless
const onConflicts = async () => {
return [];
};
// Create the sync manager
const SyncManager = SyncManagerModule.default;
const syncManager = new SyncManager(vault, settings, onConflicts, logger);
await syncManager.loadMetadata();
const startTime = performance.now();
await syncManager.firstSync();
return performance.now() - startTime;
}
const generateRandomFiles = (
rootPath: string,
numFiles: number,
maxDepth: number,
fileSize: number,
) => {
const metadata: { lastSync: number; files: { [key: string]: {} } } = {
lastSync: 0,
files: {},
};
// Create root directory if it doesn't exist
if (!fs.existsSync(rootPath)) {
fs.mkdirSync(rootPath, { recursive: true });
}
// Generate folder structure first
const allFolderPaths = [rootPath];
for (let depth = 1; depth <= maxDepth; depth++) {
const numFoldersAtThisDepth = Math.floor(Math.random() * 3) + 1; // 1-3 folders per level
for (let i = 0; i < numFoldersAtThisDepth; i++) {
const parentPath =
allFolderPaths[Math.floor(Math.random() * allFolderPaths.length)];
const currentDepthOfParent =
parentPath.split(path.sep).length - rootPath.split(path.sep).length;
// Only create subfolders if we haven't reached max depth for this path
if (currentDepthOfParent < maxDepth) {
const folderName = crypto.randomBytes(5).toString("hex");
const newFolderPath = path.join(parentPath, folderName);
fs.mkdirSync(newFolderPath, { recursive: true });
allFolderPaths.push(newFolderPath);
}
}
}
// Now generate files
const contentSize = fileSize / 2; // We divide by two as converting bytes to hex doubles the size
for (let i = 0; i < numFiles; i++) {
// Pick a random folder to place the file in
const targetFolder =
allFolderPaths[Math.floor(Math.random() * allFolderPaths.length)];
// Generate random file name
const fileName = crypto.randomBytes(8).toString("hex") + ".md";
const filePath = path.join(targetFolder, fileName);
// Generate random content
const content = crypto.randomBytes(contentSize).toString("hex");
// Write file
fs.writeFileSync(filePath, content);
const relativeFilePath = filePath.replace(`${rootPath}/`, "");
metadata.files[relativeFilePath] = {
path: relativeFilePath,
sha: null,
dirty: true,
justDownloaded: false,
lastModified: Date.now(),
};
}
const metadataFilePath = path.join(
rootPath,
".obsidian",
"github-sync-metadata.json",
);
fs.mkdirSync(path.join(rootPath, ".obsidian"));
fs.writeFileSync(metadataFilePath, JSON.stringify(metadata), { flag: "w" });
};
const cleanupRemote = () => {
const url = `git@github.com:${process.env.REPO_OWNER}/${process.env.REPO_NAME}.git`;
const clonedDir = path.join(os.tmpdir(), "temp-clone");
// Remove the folder in case it already exists
fs.rmSync(clonedDir, { recursive: true, force: true });
try {
// Clone the repository
execSync(`git clone ${url} ${clonedDir}`, { stdio: "ignore" });
const repoExists = fs.existsSync(clonedDir);
if (!repoExists) {
throw Error("Failed to clone repo");
}
// Remove all files except .git
execSync('find . -type f -not -path "./.git*" -delete', {
stdio: "ignore",
cwd: clonedDir,
});
// Commit empty state
execSync("git add -A", { stdio: "ignore", cwd: clonedDir });
execSync('git commit -m "Cleanup"', {
stdio: "ignore",
cwd: clonedDir,
});
// Push changes
execSync("git push", { stdio: "ignore", cwd: clonedDir });
} catch (error) {
console.error(`Error: ${error.message}`);
}
// Remove the folder when everything is done
fs.rmSync(clonedDir, { recursive: true, force: true });
};
const BENCHMARK_DATA = [
{
files: 1,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 10,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 100,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 1000,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 10000,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 100000,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
];
(async () => {
const tmp = os.tmpdir();
const benchmarkRootDir = path.join(tmp, "github-gitless-sync-benchmark");
try {
const results = [];
for (const data of BENCHMARK_DATA) {
console.log(
`Running benchmark with ${data.files} files totaling ${data.fileSize} bytes`,
);
const vaultRootDir = path.join(
benchmarkRootDir,
`${data.files}-${data.maxDepth}-${data.fileSize}`,
);
// Generates random files
generateRandomFiles(
vaultRootDir,
data.files,
data.maxDepth,
data.fileSize,
);
// Run first sync by uploading all local files
console.log("First sync from local");
const uploadTime = await runBenchmark(vaultRootDir);
// Cleanup vault dir completely
fs.rmSync(vaultRootDir, { recursive: true, force: true });
// Run first sync again, this time we download the files we just uploaded
console.log("Second sync from remote");
const downloadTime = await runBenchmark(vaultRootDir);
// Cleanup the remote repo so it's ready for another benchmark
cleanupRemote();
results.push({
data,
uploadTime,
downloadTime,
});
// Cleanup vault dir again, it's not necessary to keep it around
fs.rmSync(vaultRootDir, { recursive: true, force: true });
// Wait 2 seconds between each run just to avoid annoying Github
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log("");
}
fs.writeFileSync("benchmark_result.json", JSON.stringify(results), {
flag: "w",
});
} catch (error) {
console.error("Benchmark failed:", error);
}
fs.rmSync(benchmarkRootDir, { recursive: true, force: true });
})();

11
manifest-beta.json Normal file
View file

@ -0,0 +1,11 @@
{
"id": "github-gitless-sync",
"name": "GitHub Gitless Sync",
"version": "1.0.7-beta",
"minAppVersion": "1.7.7",
"description": "Sync a GitHub repository with vaults on different platforms without requiring git installation",
"author": "Silvano Cerza",
"authorUrl": "https://silvanocerza.com",
"fundingUrl": "https://ko-fi.com/silvanocerza",
"isDesktopOnly": false
}

View file

@ -1,9 +1,9 @@
{ {
"id": "obsidian-github-sync", "id": "github-gitless-sync",
"name": "GitHub Sync", "name": "GitHub Gitless Sync",
"version": "0.0.2", "version": "1.0.7",
"minAppVersion": "1.7.7", "minAppVersion": "1.7.7",
"description": "A plugin to sync a Github repository with an Obsidian vault.", "description": "Sync a GitHub repository with vaults on different platforms without requiring git installation",
"author": "Silvano Cerza", "author": "Silvano Cerza",
"authorUrl": "https://silvanocerza.com", "authorUrl": "https://silvanocerza.com",
"fundingUrl": "https://ko-fi.com/silvanocerza", "fundingUrl": "https://ko-fi.com/silvanocerza",

163
mock-obsidian.ts Normal file
View file

@ -0,0 +1,163 @@
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
promises as fs,
} from "fs";
import * as path from "path";
// Mock Obsidian's Vault class
export class Vault {
configDir: string;
private rootPath: string;
constructor(rootPath: string) {
this.rootPath = rootPath;
this.configDir = ".obsidian";
// Ensure vault directory exists
if (!existsSync(this.rootPath)) {
mkdirSync(this.rootPath, { recursive: true });
}
// Ensure config directory exists
if (!existsSync(path.join(this.rootPath, this.configDir))) {
mkdirSync(path.join(this.rootPath, this.configDir), { recursive: true });
}
}
getRoot() {
return { path: this.rootPath };
}
get adapter() {
return {
read: async (filePath: string) => {
const fullPath = path.join(this.rootPath, filePath);
return readFileSync(fullPath, "utf8");
},
write: async (filePath: string, data: string) => {
const fullPath = path.join(this.rootPath, filePath);
const dir = path.dirname(fullPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(fullPath, data);
},
readBinary: async (filePath: string) => {
const fullPath = path.join(this.rootPath, filePath);
return readFileSync(fullPath);
},
writeBinary: async (filePath: string, data: ArrayBuffer) => {
const fullPath = path.join(this.rootPath, filePath);
const dir = path.dirname(fullPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(fullPath, Buffer.from(data));
},
exists: async (filePath: string) => {
const fullPath = path.join(this.rootPath, filePath);
return existsSync(fullPath);
},
mkdir: async (dirPath: string) => {
const fullPath = path.join(this.rootPath, dirPath);
if (!existsSync(fullPath)) {
mkdirSync(fullPath, { recursive: true });
}
},
remove: async (filePath: string) => {
const fullPath = path.join(this.rootPath, filePath);
if (existsSync(fullPath)) {
await fs.unlink(fullPath);
}
},
list: async (dirPath: string) => {
const fullPath = path.join(this.rootPath, dirPath);
if (!existsSync(fullPath)) {
return { files: [], folders: [] };
}
const entries = await fs.readdir(fullPath, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => path.join(dirPath, entry.name));
const folders = entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dirPath, entry.name));
return { files, folders };
},
};
}
}
// Mock Notice
export class Notice {
constructor(message: string, timeout?: number) {
console.log(`NOTICE: ${message}`);
}
hide() {
// Do nothing in mock
}
}
interface RequestUrlParam {
url: string;
method?: string;
headers?: Record<string, string>;
contentType?: string;
body?: string | ArrayBuffer;
throw?: boolean;
}
export async function requestUrl(options: RequestUrlParam) {
const response = await fetch(options.url, {
method: options.method || "GET",
headers: options.headers,
body: options.body,
});
const isJsonResponse = response.headers
.get("content-type")
?.includes("application/json");
// Convert to expected Obsidian response format
if (isJsonResponse) {
return {
status: response.status,
json: await response.json(),
};
} else {
return {
status: response.status,
arrayBuffer: await response.arrayBuffer(),
};
}
}
// Mock utility functions
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
return Buffer.from(buffer).toString("base64");
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
return Buffer.from(base64, "base64");
}
// Mock Event reference
export type EventRef = string;

View file

@ -1,29 +1,41 @@
{ {
"name": "obsidian-github-sync", "name": "github-gitless-sync",
"version": "0.0.2", "version": "1.0.7",
"description": "A plugin to sync a Github repository with an Obsidian vault", "description": "Sync a GitHub repository with vaults on different platforms without requiring git installation",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs" "version": "node version-bump.mjs",
"benchmark": "tsx benchmark.ts"
}, },
"keywords": [], "keywords": [],
"author": "Silvano Cerza", "author": "Silvano Cerza",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/proxyquire": "^1.3.31",
"@types/react": "^19.0.4", "@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"@types/sinon": "^17.0.4",
"@typescript-eslint/eslint-plugin": "^8.19.0", "@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0", "@typescript-eslint/parser": "^8.19.0",
"builtin-modules": "^4.0.0", "builtin-modules": "^4.0.0",
"esbuild": "^0.24.2", "esbuild": "^0.24.2",
"obsidian": "^1.7.2", "obsidian": "^1.7.2",
"proxyquire": "^2.1.3",
"sinon": "^20.0.0",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsx": "^4.19.3",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.2",
"@uiw/react-codemirror": "^4.23.8",
"@zip.js/zip.js": "^2.7.60",
"codemirror": "^6.0.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
} }

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,8 @@
import { Vault, TAbstractFile, TFolder } from "obsidian"; import { Vault, TAbstractFile, TFolder } from "obsidian";
import MetadataStore from "./metadata-store"; import MetadataStore, { MANIFEST_FILE_NAME } from "./metadata-store";
import { GitHubSyncSettings } from "./settings/settings"; import { GitHubSyncSettings } from "./settings/settings";
import Logger, { LOG_FILE_NAME } from "./logger";
import GitHubSyncPlugin from "./main";
/** /**
* Tracks changes to local sync directory and updates files metadata. * Tracks changes to local sync directory and updates files metadata.
@ -10,18 +12,24 @@ export default class EventsListener {
private vault: Vault, private vault: Vault,
private metadataStore: MetadataStore, private metadataStore: MetadataStore,
private settings: GitHubSyncSettings, private settings: GitHubSyncSettings,
private logger: Logger,
) {} ) {}
start() { start(plugin: GitHubSyncPlugin) {
this.vault.on("create", this.onCreate.bind(this)); // We need to register all the events we subscribe to so they can
this.vault.on("delete", this.onDelete.bind(this)); // be correctly detached when the plugin is unloaded too.
this.vault.on("modify", this.onModify.bind(this)); // If we don't they might be left hanging and cause issues.
this.vault.on("rename", this.onRename.bind(this)); plugin.registerEvent(this.vault.on("create", this.onCreate.bind(this)));
plugin.registerEvent(this.vault.on("delete", this.onDelete.bind(this)));
plugin.registerEvent(this.vault.on("modify", this.onModify.bind(this)));
plugin.registerEvent(this.vault.on("rename", this.onRename.bind(this)));
} }
private async onCreate(file: TAbstractFile) { private async onCreate(file: TAbstractFile) {
await this.logger.info("Received create event", file.path);
if (!this.isSyncable(file.path)) { if (!this.isSyncable(file.path)) {
// The file has not been created in directory that we're syncing with GitHub // The file has not been created in directory that we're syncing with GitHub
await this.logger.info("Skipped created file", file.path);
return; return;
} }
if (file instanceof TFolder) { if (file instanceof TFolder) {
@ -35,6 +43,7 @@ export default class EventsListener {
// It's enough to mark it as non just downloaded. // It's enough to mark it as non just downloaded.
this.metadataStore.data.files[file.path].justDownloaded = false; this.metadataStore.data.files[file.path].justDownloaded = false;
await this.metadataStore.save(); await this.metadataStore.save();
await this.logger.info("Updated just downloaded created file", file.path);
return; return;
} }
@ -47,14 +56,16 @@ export default class EventsListener {
lastModified: Date.now(), lastModified: Date.now(),
}; };
await this.metadataStore.save(); await this.metadataStore.save();
await this.logger.info("Updated created file", file.path);
} }
private async onDelete(file: TAbstractFile | string) { private async onDelete(file: TAbstractFile | string) {
const filePath = file instanceof TAbstractFile ? file.path : file;
await this.logger.info("Received delete event", filePath);
if (file instanceof TFolder) { if (file instanceof TFolder) {
// Skip folders // Skip folders
return; return;
} }
const filePath = file instanceof TAbstractFile ? file.path : file;
if (!this.isSyncable(filePath)) { if (!this.isSyncable(filePath)) {
// The file was not in directory that we're syncing with GitHub // The file was not in directory that we're syncing with GitHub
return; return;
@ -63,11 +74,14 @@ export default class EventsListener {
this.metadataStore.data.files[filePath].deleted = true; this.metadataStore.data.files[filePath].deleted = true;
this.metadataStore.data.files[filePath].deletedAt = Date.now(); this.metadataStore.data.files[filePath].deletedAt = Date.now();
await this.metadataStore.save(); await this.metadataStore.save();
await this.logger.info("Updated deleted file", filePath);
} }
private async onModify(file: TAbstractFile) { private async onModify(file: TAbstractFile) {
await this.logger.info("Received modify event", file.path);
if (!this.isSyncable(file.path)) { if (!this.isSyncable(file.path)) {
// The file has not been create in directory that we're syncing with GitHub // The file has not been create in directory that we're syncing with GitHub
await this.logger.info("Skipped modified file", file.path);
return; return;
} }
if (file instanceof TFolder) { if (file instanceof TFolder) {
@ -80,14 +94,20 @@ export default class EventsListener {
// It's enough to makr it as non just downloaded. // It's enough to makr it as non just downloaded.
this.metadataStore.data.files[file.path].justDownloaded = false; this.metadataStore.data.files[file.path].justDownloaded = false;
await this.metadataStore.save(); await this.metadataStore.save();
await this.logger.info(
"Updated just downloaded modified file",
file.path,
);
return; return;
} }
this.metadataStore.data.files[file.path].lastModified = Date.now(); this.metadataStore.data.files[file.path].lastModified = Date.now();
this.metadataStore.data.files[file.path].dirty = true; this.metadataStore.data.files[file.path].dirty = true;
await this.metadataStore.save(); await this.metadataStore.save();
await this.logger.info("Updated modified file", file.path);
} }
private async onRename(file: TAbstractFile, oldPath: string) { private async onRename(file: TAbstractFile, oldPath: string) {
await this.logger.info("Received rename event", file.path);
if (file instanceof TFolder) { if (file instanceof TFolder) {
// Skip folders // Skip folders
return; return;
@ -116,13 +136,27 @@ export default class EventsListener {
} }
private isSyncable(filePath: string) { private isSyncable(filePath: string) {
return ( if (filePath === `${this.vault.configDir}/${MANIFEST_FILE_NAME}`) {
filePath === `${this.vault.configDir}/github-sync-metadata.json` || // Manifest file must always be synced
(this.settings.syncConfigDir && return true;
filePath.startsWith(this.vault.configDir)) || } else if (
// Obsidian recommends not syncing the workspace files
filePath === `${this.vault.configDir}/workspace.json` || filePath === `${this.vault.configDir}/workspace.json` ||
filePath === `${this.vault.configDir}/workspace-mobile.json` filePath === `${this.vault.configDir}/workspace-mobile.json`
); ) {
// Obsidian recommends not syncing the workspace files
return false;
} else if (filePath === `${this.vault.configDir}/${LOG_FILE_NAME}`) {
// Don't sync the log file, doesn't make sense
return false;
} else if (
this.settings.syncConfigDir &&
filePath.startsWith(this.vault.configDir)
) {
// Sync configs only if the user explicitly wants to
return true;
} else {
// All other files can be synced
return true;
}
} }
} }

View file

@ -1,4 +1,7 @@
import { requestUrl } from "obsidian"; import { requestUrl } from "obsidian";
import Logger from "src/logger";
import { GitHubSyncSettings } from "src/settings/settings";
import { retryUntil } from "src/utils";
export type RepoContent = { export type RepoContent = {
files: { [key: string]: GetTreeResponseItem }; files: { [key: string]: GetTreeResponseItem };
@ -25,6 +28,13 @@ export type NewTreeRequestItem = {
content?: string; content?: string;
}; };
/**
* Response received when we create a new binary blob on GitHub
*/
export type CreatedBlob = {
sha: string;
};
/** /**
* Represents a git blob response from the GitHub API. * Represents a git blob response from the GitHub API.
*/ */
@ -37,18 +47,28 @@ export type BlobFile = {
encoding: string; encoding: string;
}; };
/**
* Custom error to make some stuff easier
*/
class GithubAPIError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
export default class GithubClient { export default class GithubClient {
constructor( constructor(
private token: string, private settings: GitHubSyncSettings,
private owner: string, private logger: Logger,
private repo: string,
private branch: string,
) {} ) {}
headers() { headers() {
return { return {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.token}`, Authorization: `Bearer ${this.settings.githubToken}`,
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
}; };
} }
@ -56,14 +76,35 @@ export default class GithubClient {
/** /**
* Gets the content of the repo. * Gets the content of the repo.
* *
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns Array of files in the directory in the remote repo * @returns Array of files in the directory in the remote repo
*/ */
async getRepoContent(): Promise<RepoContent> { async getRepoContent({
const res = await requestUrl({ retry = false,
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${this.branch}?recursive=1`, maxRetries = 5,
headers: this.headers(), } = {}): Promise<RepoContent> {
}); const response = await retryUntil(
const files = res.json.tree async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/trees/${this.settings.githubBranch}?recursive=1`,
headers: this.headers(),
throw: false,
});
},
(res) => res.status !== 422, // Retry condition: only retry on 422 status
retry ? maxRetries : 0, // Use 0 retries if retry is false
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to get repo content", response);
throw new GithubAPIError(
response.status,
`Failed to get repo content, status ${response.status}`,
);
}
const files = response.json.tree
.filter((file: GetTreeResponseItem) => file.type === "blob") .filter((file: GetTreeResponseItem) => file.type === "blob")
.reduce( .reduce(
( (
@ -72,81 +113,337 @@ export default class GithubClient {
) => ({ ...acc, [file.path]: file }), ) => ({ ...acc, [file.path]: file }),
{}, {},
); );
return { files, sha: res.json.sha }; return { files, sha: response.json.sha };
}
async createTree(tree: { tree: NewTreeRequestItem[]; base_tree: string }) {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees`,
headers: this.headers(),
method: "POST",
body: JSON.stringify(tree),
});
return res.json.sha;
}
async createCommit(message: string, treeSha: string, parent: string) {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/commits`,
headers: this.headers(),
method: "POST",
body: JSON.stringify({
message: message,
tree: treeSha,
parents: [parent],
}),
});
return res.json.sha;
}
async getBranchHeadSha() {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/refs/heads/${this.branch}`,
headers: this.headers(),
});
return res.json.object.sha;
}
async updateBranchHead(sha: string) {
await requestUrl({
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/refs/heads/${this.branch}`,
headers: this.headers(),
method: "PATCH",
body: JSON.stringify({
sha: sha,
}),
});
} }
/** /**
* Gets a blob from a blob url * Creates a new tree in the GitHub repository.
* @param url blob url *
* @param tree The tree object to create
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The SHA of the created tree
*/ */
async getBlob(url: string): Promise<BlobFile> { async createTree({
const res = await requestUrl({ tree,
url: url, retry = false,
headers: this.headers(), maxRetries = 5,
}); }: {
return res.json; tree: { tree: NewTreeRequestItem[]; base_tree: string };
retry?: boolean;
maxRetries?: number;
}) {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/trees`,
headers: this.headers(),
method: "POST",
body: JSON.stringify(tree),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to create tree", response);
throw new GithubAPIError(
response.status,
`Failed to create tree, status ${response.status}`,
);
}
return response.json.sha;
}
/**
* Creates a new commit in the repository.
*
* @param message The commit message
* @param treeSha The SHA of the tree
* @param parent The SHA of the parent commit
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The SHA of the created commit
*/
async createCommit({
message,
treeSha,
parent,
retry = false,
maxRetries = 5,
}: {
message: string;
treeSha: string;
parent: string;
retry?: boolean;
maxRetries?: number;
}): Promise<string> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/commits`,
headers: this.headers(),
method: "POST",
body: JSON.stringify({
message: message,
tree: treeSha,
parents: [parent],
}),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to create commit", response);
throw new GithubAPIError(
response.status,
`Failed to create commit, status ${response.status}`,
);
}
return response.json.sha;
}
/**
* Gets the SHA of the branch head.
*
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The SHA of the branch head
*/
async getBranchHeadSha({ retry = false, maxRetries = 5 } = {}) {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/refs/heads/${this.settings.githubBranch}`,
headers: this.headers(),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to get branch head sha", response);
throw new GithubAPIError(
response.status,
`Failed to get branch head sha, status ${response.status}`,
);
}
return response.json.object.sha;
}
/**
* Updates the branch head to point to a new commit.
*
* @param sha The SHA of the commit to point to
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
*/
async updateBranchHead({
sha,
retry = false,
maxRetries = 5,
}: {
sha: string;
retry?: boolean;
maxRetries?: number;
}) {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/refs/heads/${this.settings.githubBranch}`,
headers: this.headers(),
method: "PATCH",
body: JSON.stringify({
sha: sha,
}),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to update branch head sha", response);
throw new GithubAPIError(
response.status,
`Failed to update branch head sha, status ${response.status}`,
);
}
}
/**
* Creates a new blob in the GitHub remote, this is mainly used to upload binary files.
*
* @param content The content of the blob to upload
* @param encoding Content encoding, can be "utf-8" or "base64". Defaults to "base64"
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The SHA of the newly uploaded blob
*/
async createBlob({
content,
encoding = "base64",
retry = false,
maxRetries = 5,
}: {
content: string;
encoding?: "utf-8" | "base64";
retry?: boolean;
maxRetries?: number;
}): Promise<CreatedBlob> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/blobs`,
headers: this.headers(),
method: "POST",
body: JSON.stringify({ content, encoding }),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to create blob", response);
throw new GithubAPIError(
response.status,
`Failed to create blob, status ${response.status}`,
);
}
return {
sha: response.json["sha"],
};
}
/**
* Gets a blob from its sha
*
* @param sha The SHA of the blob
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The blob file
*/
async getBlob({
sha,
retry = false,
maxRetries = 5,
}: {
sha: string;
retry?: boolean;
maxRetries?: number;
}): Promise<BlobFile> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/blobs/${sha}`,
headers: this.headers(),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to get blob", response);
throw new GithubAPIError(
response.status,
`Failed to get blob, status ${response.status}`,
);
}
return response.json;
} }
/** /**
* Create a new file in the repo, the content must be base64 encoded or the request will fail. * Create a new file in the repo, the content must be base64 encoded or the request will fail.
* *
* @param message commit message * @param path Path to create in the repo
* @param path path to create in the repo * @param content Base64 encoded content of the file
* @param content base64 encoded content of the file * @param message Commit message
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
*/ */
async createFile(path: string, content: string, message: string) { async createFile({
await requestUrl({ path,
url: `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${path}`, content,
headers: this.headers(), message,
method: "PUT", retry = false,
body: JSON.stringify({ maxRetries = 5,
message: message, }: {
content: content, path: string;
branch: this.branch, content: string;
}), message: string;
}); retry?: boolean;
maxRetries?: number;
}) {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/contents/${path}`,
headers: this.headers(),
method: "PUT",
body: JSON.stringify({
message: message,
content: content,
branch: this.settings.githubBranch,
}),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to create file", response);
throw new GithubAPIError(
response.status,
`Failed to create file, status ${response.status}`,
);
}
}
/**
* Downloads the repository as a ZIP archive from GitHub.
*
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The archive contents as an ArrayBuffer
*/
async downloadRepositoryArchive({
retry = false,
maxRetries = 5,
} = {}): Promise<ArrayBuffer> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/zipball/${this.settings.githubBranch}`,
headers: this.headers(),
method: "GET",
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to download zip archive", response);
throw new GithubAPIError(
response.status,
`Failed to download zip archive, status ${response.status}`,
);
}
return response.arrayBuffer;
} }
} }

70
src/logger.ts Normal file
View file

@ -0,0 +1,70 @@
import { Vault, normalizePath } from "obsidian";
export const LOG_FILE_NAME = "github-sync.log" as const;
export default class Logger {
private logFile: string;
constructor(
private vault: Vault,
private enabled: boolean,
) {
this.logFile = normalizePath(`${vault.configDir}/${LOG_FILE_NAME}`);
}
async init() {
// Create the log file in case it doesn't exist
if (await this.vault.adapter.exists(this.logFile)) {
return;
}
this.vault.adapter.write(this.logFile, "");
}
private async write(
level: string,
message: string,
data?: any,
): Promise<void> {
if (!this.enabled) return;
const logEntry = {
timestamp: new Date().toISOString(),
level,
message,
additional_data: data,
};
await this.vault.adapter.append(
this.logFile,
JSON.stringify(logEntry) + "\n",
);
}
async read(): Promise<string> {
return await this.vault.adapter.read(this.logFile);
}
async clean(): Promise<void> {
return await this.vault.adapter.write(this.logFile, "");
}
enable(): void {
this.enabled = true;
}
disable(): void {
this.enabled = false;
}
async info(message: string, data?: any): Promise<void> {
await this.write("INFO", message, data);
}
async warn(message: string, data?: any): Promise<void> {
await this.write("WARN", message, data);
}
async error(message: string, data?: any): Promise<void> {
await this.write("ERROR", message, data);
}
}

View file

@ -1,40 +1,100 @@
import { EventRef, Plugin, Platform } from "obsidian"; import {
EventRef,
Plugin,
WorkspaceLeaf,
normalizePath,
Notice,
} from "obsidian";
import { GitHubSyncSettings, DEFAULT_SETTINGS } from "./settings/settings"; import { GitHubSyncSettings, DEFAULT_SETTINGS } from "./settings/settings";
import GitHubSyncSettingsTab from "./settings/tab"; import GitHubSyncSettingsTab from "./settings/tab";
import SyncManager from "./sync-manager"; import SyncManager, { ConflictFile, ConflictResolution } from "./sync-manager";
import { FileMetadata } from "./metadata-store"; import Logger from "./logger";
import { OnboardingDialog } from "./views/onboarding/view"; import {
ConflictsResolutionView,
CONFLICTS_RESOLUTION_VIEW_TYPE,
} from "./views/conflicts-resolution/view";
export default class GitHubSyncPlugin extends Plugin { export default class GitHubSyncPlugin extends Plugin {
settings: GitHubSyncSettings; settings: GitHubSyncSettings;
syncManager: SyncManager; syncManager: SyncManager;
logger: Logger;
statusBarItem: HTMLElement | null = null; statusBarItem: HTMLElement | null = null;
uploadModifiedFilesRibbonIcon: HTMLElement | null = null; syncRibbonIcon: HTMLElement | null = null;
conflictsRibbonIcon: HTMLElement | null = null;
activeLeafChangeListener: EventRef | null = null; activeLeafChangeListener: EventRef | null = null;
vaultCreateListener: EventRef | null = null; vaultCreateListener: EventRef | null = null;
vaultModifyListener: EventRef | null = null; vaultModifyListener: EventRef | null = null;
// Called in ConflictResolutionView when the user solves all the conflicts.
// This is initialized every time we open the view to set new conflicts so
// we can notify the SyncManager that everything has been resolved and the sync
// process can continue on.
conflictsResolver: ((resolutions: ConflictResolution[]) => void) | null =
null;
// We keep track of the sync conflicts in here too in case the
// conflicts view must be rebuilt, or the user closes the view
// and it gets destroyed.
// By keeping them here we can recreate it easily.
private conflicts: ConflictFile[] = [];
async onUserEnable() { async onUserEnable() {
if (Platform.isMobile) { if (
// TODO: Implement onboarding for mobile this.settings.githubToken === "" ||
return; this.settings.githubOwner === "" ||
this.settings.githubRepo === "" ||
this.settings.githubBranch === ""
) {
new Notice("Go to settings to configure syncing");
} }
if (this.settings.firstStart) { }
new OnboardingDialog(this).open();
getConflictsView(): ConflictsResolutionView | null {
const leaves = this.app.workspace.getLeavesOfType(
CONFLICTS_RESOLUTION_VIEW_TYPE,
);
if (leaves.length === 0) {
return null;
} }
return leaves[0].view as ConflictsResolutionView;
}
async activateView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(CONFLICTS_RESOLUTION_VIEW_TYPE);
if (leaves.length > 0) {
leaf = leaves[0];
} else {
leaf = workspace.getLeaf(false)!;
await leaf.setViewState({
type: CONFLICTS_RESOLUTION_VIEW_TYPE,
active: true,
});
}
workspace.revealLeaf(leaf);
} }
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
this.logger = new Logger(this.app.vault, this.settings.enableLogging);
this.logger.init();
this.registerView(
CONFLICTS_RESOLUTION_VIEW_TYPE,
(leaf) => new ConflictsResolutionView(leaf, this, this.conflicts),
);
this.addSettingTab(new GitHubSyncSettingsTab(this.app, this)); this.addSettingTab(new GitHubSyncSettingsTab(this.app, this));
this.syncManager = new SyncManager( this.syncManager = new SyncManager(
this.app.vault, this.app.vault,
this.settings, this.settings,
this.onConflicts.bind(this), this.onConflicts.bind(this),
this.logger,
); );
await this.syncManager.loadMetadata(); await this.syncManager.loadMetadata();
@ -47,7 +107,7 @@ export default class GitHubSyncPlugin extends Plugin {
// getting spammed with create events. // getting spammed with create events.
// See the official Obsidian docs: // See the official Obsidian docs:
// https://docs.obsidian.md/Reference/TypeScript+API/Vault/on('create') // https://docs.obsidian.md/Reference/TypeScript+API/Vault/on('create')
await this.syncManager.startEventsListener(); this.syncManager.startEventsListener(this);
// Load the ribbons after layout is ready so they're shown after the core // Load the ribbons after layout is ready so they're shown after the core
// buttons // buttons
@ -55,6 +115,10 @@ export default class GitHubSyncPlugin extends Plugin {
this.showStatusBarItem(); this.showStatusBarItem();
} }
if (this.settings.showConflictsRibbonButton) {
this.showConflictsRibbonIcon();
}
if (this.settings.showSyncRibbonButton) { if (this.settings.showSyncRibbonButton) {
this.showSyncRibbonIcon(); this.showSyncRibbonIcon();
} }
@ -65,11 +129,46 @@ export default class GitHubSyncPlugin extends Plugin {
name: "Sync with GitHub", name: "Sync with GitHub",
repeatable: false, repeatable: false,
icon: "refresh-cw", icon: "refresh-cw",
callback: async () => { callback: this.sync.bind(this),
await this.syncManager.sync();
this.updateStatusBarItem();
},
}); });
this.addCommand({
id: "merge",
name: "Open sync conflicts view",
repeatable: false,
icon: "refresh-cw",
callback: this.openConflictsView.bind(this),
});
}
async sync() {
if (
this.settings.githubToken === "" ||
this.settings.githubOwner === "" ||
this.settings.githubRepo === "" ||
this.settings.githubBranch === ""
) {
new Notice("Sync plugin not configured");
return;
}
if (this.settings.firstSync) {
const notice = new Notice("Syncing...");
try {
await this.syncManager.firstSync();
this.settings.firstSync = false;
this.saveSettings();
// Shown only if sync doesn't fail
new Notice("Sync successful", 5000);
} catch (err) {
// Show the error to the user, it's not automatically dismissed to make sure
// the user sees it.
new Notice(`Error syncing. ${err}`);
}
notice.hide();
} else {
await this.syncManager.sync();
}
this.updateStatusBarItem();
} }
async onunload() { async onunload() {
@ -128,44 +227,51 @@ export default class GitHubSyncPlugin extends Plugin {
} }
showSyncRibbonIcon() { showSyncRibbonIcon() {
if (this.uploadModifiedFilesRibbonIcon) { if (this.syncRibbonIcon) {
return; return;
} }
this.uploadModifiedFilesRibbonIcon = this.addRibbonIcon( this.syncRibbonIcon = this.addRibbonIcon(
"refresh-cw", "refresh-cw",
"Sync with GitHub", "Sync with GitHub",
async () => { this.sync.bind(this),
await this.syncManager.sync();
this.updateStatusBarItem();
},
); );
} }
hideUploadModifiedFilesRibbonIcon() { hideSyncRibbonIcon() {
this.uploadModifiedFilesRibbonIcon?.remove(); this.syncRibbonIcon?.remove();
this.uploadModifiedFilesRibbonIcon = null; this.syncRibbonIcon = null;
} }
async onConflicts( showConflictsRibbonIcon() {
conflicts: { remoteFile: FileMetadata; localFile: FileMetadata }[], if (this.conflictsRibbonIcon) {
): Promise<boolean[]> { return;
return await Promise.all( }
conflicts.map( this.conflictsRibbonIcon = this.addRibbonIcon(
async ({ "merge",
remoteFile, "Open sync conflicts view",
localFile, this.openConflictsView.bind(this),
}: {
remoteFile: FileMetadata;
localFile: FileMetadata;
}) => {
// TODO: Add a proper conflict resolution view
// This way remote files are always preferred
return true;
},
),
); );
} }
hideConflictsRibbonIcon() {
this.conflictsRibbonIcon?.remove();
this.conflictsRibbonIcon = null;
}
async openConflictsView() {
await this.activateView();
this.getConflictsView()?.setConflictFiles(this.conflicts);
}
async onConflicts(conflicts: ConflictFile[]): Promise<ConflictResolution[]> {
this.conflicts = conflicts;
return await new Promise(async (resolve) => {
this.conflictsResolver = resolve;
await this.activateView();
this.getConflictsView()?.setConflictFiles(conflicts);
});
}
async loadSettings() { async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
} }
@ -191,4 +297,10 @@ export default class GitHubSyncPlugin extends Plugin {
this.syncManager.stopSyncInterval(); this.syncManager.stopSyncInterval();
this.syncManager.startSyncInterval(this.settings.syncInterval); this.syncManager.startSyncInterval(this.settings.syncInterval);
} }
async reset() {
this.settings = DEFAULT_SETTINGS;
this.saveSettings();
await this.syncManager.resetMetadata();
}
} }

View file

@ -1,5 +1,7 @@
import { Vault, normalizePath } from "obsidian"; import { Vault, normalizePath } from "obsidian";
export const MANIFEST_FILE_NAME = "github-sync-metadata.json" as const;
/** /**
* A file metadata. * A file metadata.
* Store info that makes easier to track a file locally and in the remote repo. * Store info that makes easier to track a file locally and in the remote repo.
@ -10,6 +12,10 @@ export interface FileMetadata {
// SHA of the file in the remote repository. // SHA of the file in the remote repository.
// This is necessary to update the file remotely. // This is necessary to update the file remotely.
// If this is null the file has not yet been pushed to the remote repository. // If this is null the file has not yet been pushed to the remote repository.
// This doesn't change when the file is manually edited by the user but only
// when uploading or downloading this file.
// In short this is the SHA of the remote file at the time of the last sync,
// as far as the local environment is aware.
sha: string | null; sha: string | null;
// Whether the file has been modified locally. // Whether the file has been modified locally.
dirty: boolean; dirty: boolean;
@ -42,7 +48,7 @@ export default class MetadataStore {
constructor(private vault: Vault) { constructor(private vault: Vault) {
this.metadataFile = normalizePath( this.metadataFile = normalizePath(
`${this.vault.configDir}/github-sync-metadata.json`, `${this.vault.configDir}/${MANIFEST_FILE_NAME}`,
); );
} }
@ -71,4 +77,8 @@ export default class MetadataStore {
}); });
return this.writeQueue; return this.writeQueue;
} }
reset() {
this.data = { lastSync: 0, files: {} };
}
} }

View file

@ -1,5 +1,5 @@
export interface GitHubSyncSettings { export interface GitHubSyncSettings {
firstStart: boolean; firstSync: boolean;
githubToken: string; githubToken: string;
githubOwner: string; githubOwner: string;
githubRepo: string; githubRepo: string;
@ -8,13 +8,16 @@ export interface GitHubSyncSettings {
syncInterval: number; syncInterval: number;
syncOnStartup: boolean; syncOnStartup: boolean;
syncConfigDir: boolean; syncConfigDir: boolean;
conflictHandling: "ignore" | "ask" | "overwrite"; conflictHandling: "overwriteLocal" | "ask" | "overwriteRemote";
conflictViewMode: "default" | "unified" | "split";
showStatusBarItem: boolean; showStatusBarItem: boolean;
showSyncRibbonButton: boolean; showSyncRibbonButton: boolean;
showConflictsRibbonButton: boolean;
enableLogging: boolean;
} }
export const DEFAULT_SETTINGS: GitHubSyncSettings = { export const DEFAULT_SETTINGS: GitHubSyncSettings = {
firstStart: true, firstSync: true,
githubToken: "", githubToken: "",
githubOwner: "", githubOwner: "",
githubRepo: "", githubRepo: "",
@ -22,8 +25,11 @@ export const DEFAULT_SETTINGS: GitHubSyncSettings = {
syncStrategy: "manual", syncStrategy: "manual",
syncInterval: 1, syncInterval: 1,
syncOnStartup: false, syncOnStartup: false,
syncConfigDir: true, syncConfigDir: false,
conflictHandling: "ask", conflictHandling: "ask",
conflictViewMode: "default",
showStatusBarItem: true, showStatusBarItem: true,
showSyncRibbonButton: true, showSyncRibbonButton: true,
showConflictsRibbonButton: true,
enableLogging: false,
}; };

View file

@ -1,5 +1,13 @@
import { PluginSettingTab, App, Setting, TextComponent } from "obsidian"; import {
PluginSettingTab,
App,
Setting,
TextComponent,
Modal,
Notice,
} from "obsidian";
import GitHubSyncPlugin from "src/main"; import GitHubSyncPlugin from "src/main";
import { copyToClipboard } from "src/utils";
export default class GitHubSyncSettingsTab extends PluginSettingTab { export default class GitHubSyncSettingsTab extends PluginSettingTab {
plugin: GitHubSyncPlugin; plugin: GitHubSyncPlugin;
@ -14,13 +22,11 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
containerEl.empty(); containerEl.empty();
containerEl.createEl("h1", { text: "GitHub Sync Settings" }); new Setting(containerEl).setName("Remote Repository").setHeading();
containerEl.createEl("h2", { text: "Remote Repository" });
let tokenInput: TextComponent; let tokenInput: TextComponent;
new Setting(containerEl) new Setting(containerEl)
.setName("GitHub Token") .setName("GitHub token")
.setDesc( .setDesc(
"A personal access token or a fine-grained token with read and write access to your repository", "A personal access token or a fine-grained token with read and write access to your repository",
) )
@ -85,7 +91,7 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
}), }),
); );
containerEl.createEl("h2", { text: "Sync" }); new Setting(containerEl).setName("Sync").setHeading();
const syncStrategies = { const syncStrategies = {
manual: "Manually", manual: "Manually",
@ -163,16 +169,15 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
}); });
const conflictHandlingOptions = { const conflictHandlingOptions = {
ignore: "Ignore remote file", overwriteLocal: "Overwrite local file",
ask: "Ask", ask: "Ask",
overwrite: "Overwrite local file", overwriteRemote: "Overwrite remote file",
}; };
new Setting(containerEl) new Setting(containerEl)
.setName("Conflict handling") .setName("Conflict handling")
.setDesc( .setDesc(
`What to do in case remote and local files conflict `What to do in case remote and local files conflict
when downloading from GitHub repository when downloading from GitHub repository`,
`,
) )
.addDropdown((dropdown) => { .addDropdown((dropdown) => {
dropdown dropdown
@ -184,7 +189,7 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
}); });
}); });
containerEl.createEl("h2", { text: "Interface" }); new Setting(containerEl).setName("Interface").setHeading();
new Setting(containerEl) new Setting(containerEl)
.setName("Show status bar item") .setName("Show status bar item")
@ -210,9 +215,123 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
if (value) { if (value) {
this.plugin.showSyncRibbonIcon(); this.plugin.showSyncRibbonIcon();
} else { } else {
this.plugin.hideUploadModifiedFilesRibbonIcon(); this.plugin.hideSyncRibbonIcon();
} }
}); });
}); });
new Setting(containerEl)
.setName("Show conflicts view button")
.setDesc("Displays a ribbon button that opens the conflicts view")
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.showConflictsRibbonButton)
.onChange((value) => {
this.plugin.settings.showConflictsRibbonButton = value;
this.plugin.saveSettings();
if (value) {
this.plugin.showConflictsRibbonIcon();
} else {
this.plugin.hideConflictsRibbonIcon();
}
});
});
const diffModeOptions = {
default: "Default",
unified: "Unified",
split: "Split",
};
new Setting(containerEl)
.setName("Conflict resolution view mode")
.setDesc("Set which diff view mode should be shown in case of conflicts")
.addDropdown((dropdown) => {
dropdown
.addOptions(diffModeOptions)
.setValue(this.plugin.settings.conflictViewMode)
.onChange(async (value: keyof typeof diffModeOptions) => {
this.plugin.settings.conflictViewMode = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl).setName("Extra").setHeading();
new Setting(containerEl)
.setName("Enable logging")
.setDesc(
"If enabled logs from this plugin will be saved in a file in your config directory.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.enableLogging)
.onChange((value) => {
this.plugin.settings.enableLogging = value;
if (value) {
this.plugin.logger.enable();
} else {
this.plugin.logger.disable();
}
this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Copy logs")
.setDesc("Copy the log file content, this is useful to report bugs.")
.addButton((button) => {
button.setButtonText("Copy").onClick(async () => {
const logs: string = await this.plugin.logger.read();
try {
await copyToClipboard(logs);
new Notice("Logs copied", 5000);
} catch (err) {
new Notice(`Failed copying logs: ${err}`, 10000);
}
});
});
new Setting(containerEl)
.setName("Clean logs")
.setDesc("Delete all existing logs.")
.addButton((button) => {
button.setButtonText("Clean").onClick(async () => {
await this.plugin.logger.clean();
});
});
new Setting(containerEl)
.setName("Reset")
.setDesc("Reset the plugin settings and metadata")
.addButton((button) => {
button
.setButtonText("RESET")
.setCta()
.onClick(() => {
const modal = new Modal(this.plugin.app);
modal.setTitle("Are you sure?");
modal.setContent(
"This will completely delete all sync metadata and plugin settings.\n" +
"You'll have to repeat the first sync if you want to use the plugin again.",
);
new Setting(modal.contentEl);
new Setting(modal.contentEl)
.addButton((btn) =>
btn
.setButtonText("Reset")
.setCta()
.onClick(async () => {
await this.plugin.reset();
modal.close();
}),
)
.addButton((btn) =>
btn.setButtonText("Cancel").onClick(() => {
modal.close();
}),
);
modal.open();
});
});
} }
} }

File diff suppressed because it is too large Load diff

99
src/utils.ts Normal file
View file

@ -0,0 +1,99 @@
import { base64ToArrayBuffer } from "obsidian";
const TEXT_EXTENSIONS = [
".css",
".md",
".json",
".txt",
".csv",
".js",
".log",
] as const;
/**
* Decodes a base64 encoded string, this properly
* handles emojis and other non ASCII chars.
*
* @param s base64 encoded string
* @returns Decoded string
*/
export function decodeBase64String(s: string): string {
const buffer = base64ToArrayBuffer(s);
const decoder = new TextDecoder();
return decoder.decode(buffer);
}
/**
* Copies the provided text to the system clipboard.
* Uses the modern Clipboard API with a fallback to older APIs.
*
* @param text The string to be copied to clipboard
* @returns A promise that resolves when the text has been copied
*/
export async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
// Fallback for devices like iOS that don't support Clipboard API
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
/**
* Checks if a file path has one of the predefined text extensions.
* This is a best guess at best.
*
* @param filePath The path of the file to check
* @returns True if the file has a text extension, false otherwise
*/
export function hasTextExtension(filePath: string) {
for (const extension of TEXT_EXTENSIONS) {
if (filePath.endsWith(extension)) {
return true;
}
}
return false;
}
/**
* Retries an async function until its return value satisfies a condition or max retries is reached.
* Uses exponential backoff between retry attempts.
*
* @param fn - The async function to execute and potentially retry
* @param condition - Function that evaluates if the result is acceptable
* @param maxRetries - Maximum number of retry attempts (default: 5)
* @param initialDelay - Initial delay in ms before first retry (default: 1000)
* @param backoffFactor - Multiplicative factor for delay between retries (default: 2)
* @returns The result of the function execution
*/
export async function retryUntil<T>(
fn: () => Promise<T>,
condition: (result: T) => boolean,
maxRetries: number = 5,
initialDelay: number = 1000,
backoffFactor: number = 2,
): Promise<T> {
let retries = 0;
let delay = initialDelay;
while (true) {
const result = await fn();
if (condition(result) || retries >= maxRetries) {
return result;
}
retries++;
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= backoffFactor;
}
}

View file

@ -0,0 +1,157 @@
export interface DiffChunk {
type: "add" | "remove" | "modify";
// These use the line number, so they start at 1.
// If the DiffChunk is a single line the end will be start + 1.
// If start and end are identical it means the line from left must
// be added to right, and viceversa.
startLeftLine: number;
endLeftLine: number;
startRightLine: number;
endRightLine: number;
}
function diff(oldText: string, newText: string): DiffChunk[] {
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const result: DiffChunk[] = [];
let i = 0;
let j = 0;
while (i < oldLines.length || j < newLines.length) {
// Skip identical lines
while (
i < oldLines.length &&
j < newLines.length &&
oldLines[i] === newLines[j]
) {
i++;
j++;
}
if (i < oldLines.length || j < newLines.length) {
let nextMatchOld = i;
let nextMatchNew = j;
let found = false;
// Look ahead for next match
for (let lookAhead = 1; lookAhead < 3; lookAhead++) {
if (
i + lookAhead < oldLines.length &&
newLines[j] === oldLines[i + lookAhead]
) {
nextMatchOld = i + lookAhead;
nextMatchNew = j;
found = true;
break;
}
if (
j + lookAhead < newLines.length &&
oldLines[i] === newLines[j + lookAhead]
) {
nextMatchOld = i;
nextMatchNew = j + lookAhead;
found = true;
break;
}
}
if (found) {
if (nextMatchOld > i) {
result.push({
type: "remove",
startLeftLine: i + 1,
endLeftLine: nextMatchOld + 1,
startRightLine: j + 1,
endRightLine: j + 1,
});
}
if (nextMatchNew > j) {
result.push({
type: "add",
startLeftLine: i + 1,
endLeftLine: i + 1,
startRightLine: j + 1,
endRightLine: nextMatchNew + 1,
});
}
i = nextMatchOld;
j = nextMatchNew;
} else {
result.push({
type: "modify",
startLeftLine: i + 1,
endLeftLine: i + 2,
startRightLine: j + 1,
endRightLine: j + 2,
});
i++;
j++;
}
}
}
return mergeDiffs(result).map((chunk) =>
clampChunkLines(chunk, oldLines.length, newLines.length),
);
}
// Clamp lines in the change and change its type accordingly
function clampChunkLines(
chunk: DiffChunk,
oldLinesLength: number,
newLinesLength: number,
): DiffChunk {
const startLeftLine = clampLine(chunk.startLeftLine, oldLinesLength);
const endLeftLine = clampLine(chunk.endLeftLine, oldLinesLength);
const startRightLine = clampLine(chunk.startRightLine, newLinesLength);
const endRightLine = clampLine(chunk.endRightLine, newLinesLength);
let type = chunk.type;
if (type === "modify") {
// If we're here it means that the type was modify,
// if the lines on one side are the same number
// we nee to change the type or the change won't be shown correctly.
if (startLeftLine === endLeftLine) {
type = "add";
} else if (startRightLine === endRightLine) {
type = "remove";
}
}
return {
type,
startLeftLine,
endLeftLine,
startRightLine,
endRightLine,
};
}
// Use to make sure the line number in the diff chunk doesn't exceed
// the number of lines of the document.
function clampLine(line: number, maxLines: number): number {
return Math.min(Math.max(1, line), maxLines + 1);
}
function mergeDiffs(chunks: DiffChunk[]): DiffChunk[] {
if (chunks.length <= 1) return chunks;
return chunks.reduce((merged: DiffChunk[], current) => {
const previous = merged[merged.length - 1];
if (
previous &&
(previous.endLeftLine === current.startLeftLine ||
previous.endRightLine === current.startRightLine)
) {
previous.endLeftLine = current.endLeftLine;
previous.endRightLine = current.endRightLine;
previous.type = "modify";
return merged;
}
merged.push({ ...current });
return merged;
}, []);
}
export default diff;

View file

@ -0,0 +1,59 @@
interface ButtonProps {
size?: number | string;
tooltipText: string;
onClick: () => void;
className?: string;
}
const BaseButton: React.FC<ButtonProps & { children: React.ReactNode }> = ({
size = 24,
tooltipText,
onClick,
children,
className,
}) => (
<div
style={{ padding: 0 }}
aria-label={tooltipText}
data-tooltip-position="top"
data-tooltip-delay="300"
className="clickable-icon"
onClick={onClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
style={{
width: `${size}`,
height: `${size}`,
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
}}
viewBox={`0 0 ${size} ${size}`}
className={className}
>
{children}
</svg>
</div>
);
export const ButtonCross: React.FC<ButtonProps> = (props) => (
<BaseButton {...props} className="lucide lucide-x">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</BaseButton>
);
export const ButtonLeftArrow: React.FC<ButtonProps> = (props) => (
<BaseButton {...props} className="lucide arrow-big-left">
<path d="M18 15h-6v4l-7-7 7-7v4h6v6z" />
</BaseButton>
);
export const ButtonRightArrow: React.FC<ButtonProps> = (props) => (
<BaseButton {...props} className="lucide lucide-arrow-big-right">
<path d="M6 9h6V5l7 7-7 7v-4H6V9z" />
</BaseButton>
);

View file

@ -0,0 +1,200 @@
import * as React from "react";
import { DiffChunk } from "../diff";
import {
ButtonCross,
ButtonLeftArrow,
ButtonRightArrow,
} from "./action-button";
interface ActionsGutterProps {
diffChunks: DiffChunk[];
// This is essential to correctly draw the lines between
// the left and right editor
lineHeight: number;
// These two properties are necessary to handle editors scrolling
leftEditorTopOffset: number;
rightEditorTopLineOffset: number;
onAcceptLeft: (chunk: DiffChunk) => void;
onAcceptRight: (chunk: DiffChunk) => void;
onReject: (chunk: DiffChunk) => void;
}
const ActionsGutter: React.FC<ActionsGutterProps> = ({
diffChunks,
lineHeight,
leftEditorTopOffset: leftEditorTopLineOffset,
rightEditorTopLineOffset: rightEditorTopOffset,
onAcceptLeft,
onAcceptRight,
onReject,
}) => {
const [actualWidth, setActualWidth] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
setActualWidth(entries[0].contentRect.width);
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, []);
const drawChunk = (chunk: DiffChunk, index: number) => {
// The offsets are substracted to compensate the editors scrolling
const topLeft =
(chunk.startLeftLine - 1) * lineHeight - leftEditorTopLineOffset;
const bottomLeft =
(chunk.endLeftLine - 1) * lineHeight - leftEditorTopLineOffset;
const topRight =
(chunk.startRightLine - 1) * lineHeight - rightEditorTopOffset;
const bottomRight =
(chunk.endRightLine - 1) * lineHeight - rightEditorTopOffset;
const color =
chunk.type == "add"
? "var(--color-green)"
: chunk.type == "remove"
? "var(--color-red)"
: "var(--color-yellow)";
let buttons: React.JSX.Element;
if (chunk.type === "add") {
buttons = (
<foreignObject
x={actualWidth - 48}
y={topRight + lineHeight / 2 - 12}
width="48"
height="24"
>
<div style={{ display: "flex", flexDirection: "row" }}>
<ButtonCross
tooltipText="Delete lines"
onClick={() => onReject(chunk)}
/>
<ButtonLeftArrow
tooltipText="Add lines"
onClick={() => onAcceptLeft(chunk)}
/>
</div>
</foreignObject>
);
} else if (chunk.type === "remove") {
buttons = (
<foreignObject
x={0}
y={topLeft + lineHeight / 2 - 12}
width="48"
height="24"
>
<div style={{ display: "flex", flexDirection: "row" }}>
<ButtonRightArrow
tooltipText="Add lines"
onClick={() => onAcceptRight(chunk)}
/>
<ButtonCross
tooltipText="Delete lines"
onClick={() => onReject(chunk)}
/>
</div>
</foreignObject>
);
} else if (chunk.type === "modify") {
buttons = (
<foreignObject
x={0}
y={Math.min(topLeft, topRight)}
width={actualWidth}
height={
Math.max(bottomRight, bottomLeft) - Math.min(topLeft, topRight) + 24
}
>
<div style={{ position: "relative", height: "100%" }}>
<div
style={{
position: "absolute",
left: 0,
top:
topLeft - Math.min(topLeft, topRight) + lineHeight / 2 - 12,
}}
>
<ButtonRightArrow
tooltipText="Overwrite right lines"
onClick={() => onAcceptRight(chunk)}
/>
</div>
<div
style={{
position: "absolute",
right: 0,
top:
topRight - Math.min(topLeft, topRight) + lineHeight / 2 - 12,
}}
>
<ButtonLeftArrow
tooltipText="Overwrite left lines"
onClick={() => onAcceptLeft(chunk)}
/>
</div>
</div>
</foreignObject>
);
} else {
// Just in case we add other types in the future
throw Error("Unknown chunk type");
}
return (
<g key={index}>
<path
d={`
M 0 ${topLeft}
L 48 ${topLeft}
C ${actualWidth * 0.4} ${topLeft}, ${actualWidth * 0.6} ${topRight}, ${actualWidth - 48} ${topRight}
L ${actualWidth} ${topRight}
L ${actualWidth} ${bottomRight}
L ${actualWidth - 48} ${bottomRight}
C ${actualWidth * 0.6} ${bottomRight}, ${actualWidth * 0.4} ${bottomLeft}, 48 ${bottomLeft}
L 0 ${bottomLeft}
Z
`}
fill={color}
fillOpacity="0.1"
stroke={color}
strokeWidth="1"
/>
{buttons}
</g>
);
};
return (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
position: "relative",
backgroundColor: "var(--background-secondary)",
}}
>
<svg
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
>
<defs>
<clipPath id="gutter-clip">
<rect x="0" y="0" width="100%" height="100%" />
</clipPath>
</defs>
<g clipPath="url(#gutter-clip)">{diffChunks.map(drawChunk)}</g>
</svg>
</div>
);
};
export default ActionsGutter;

View file

@ -0,0 +1,75 @@
import {
ViewPlugin,
Decoration,
DecorationSet,
EditorView,
ViewUpdate,
} from "@codemirror/view";
import { DiffChunk } from "../diff";
export interface DiffHighlightPluginSpec {
diff: DiffChunk[];
isOriginal: boolean;
}
export function createDiffHighlightPlugin(spec: DiffHighlightPluginSpec) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const decorations = [];
const doc = view.state.doc;
for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i);
const diffResult = spec.diff.find((chunk) => {
if (spec.isOriginal) {
return (
(chunk.type === "remove" || chunk.type === "modify") &&
i >= chunk.startLeftLine &&
i < chunk.endLeftLine
);
}
return (
(chunk.type === "add" || chunk.type === "modify") &&
i >= chunk.startRightLine &&
i < chunk.endRightLine
);
});
if (diffResult) {
const className =
diffResult.type === "modify"
? "diff-modify-background"
: diffResult.type === "add"
? "diff-add-background"
: "diff-remove-background";
decorations.push(
Decoration.line({
class: className,
}).range(line.from),
);
}
}
return Decoration.set(decorations, true);
}
},
{
decorations: (v) => v.decorations,
},
);
}

View file

@ -0,0 +1,158 @@
import { EditorView } from "@codemirror/view";
import diff, { DiffChunk } from "../diff";
import EditorPane from "./editor-pane";
import ActionsGutter from "./actions-gutter";
import * as React from "react";
interface DiffViewProps {
remoteText: string;
localText: string;
onRemoteTextChange: (content: string) => void;
onLocalTextChange: (content: string) => void;
onConflictResolved: () => void;
}
const DiffView: React.FC<DiffViewProps> = ({
remoteText,
localText,
onRemoteTextChange,
onLocalTextChange,
onConflictResolved,
}) => {
// We need to know the line height to correctly draw the ribbon between the left
// and right editor in the actions gutter
const [lineHeight, setLineHeight] = React.useState<number>(0);
const handleEditorReady = (editor: EditorView) => {
setLineHeight(editor.defaultLineHeight);
};
const [leftEditorTopOffset, setLeftEditorTopOffset] =
React.useState<number>(0);
const [rightEditorTopOffset, setRightEditorTopOffset] =
React.useState<number>(0);
const diffs = diff(remoteText, localText);
return (
<div
style={{
height: "100%",
display: "flex",
}}
>
<div style={{ flex: 1 }}>
<EditorPane
content={remoteText}
highlightPluginSpec={{
diff: diffs,
isOriginal: true,
}}
onEditorUpdate={handleEditorReady}
onContentChange={onRemoteTextChange}
onScrollTopUpdate={setLeftEditorTopOffset}
/>
</div>
<div style={{ minWidth: "160px", width: "auto" }}>
{diffs.length === 0 && (
<button
style={{
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1,
backgroundColor: "var(--interactive-accent)",
color: "var(--text-on-accent)",
}}
onClick={onConflictResolved}
>
Resolve conflict
</button>
)}
<ActionsGutter
diffChunks={diffs}
lineHeight={lineHeight}
leftEditorTopOffset={leftEditorTopOffset}
rightEditorTopLineOffset={rightEditorTopOffset}
onAcceptLeft={(chunk: DiffChunk) => {
if (chunk.type === "add") {
const remoteLines = remoteText.split("\n");
remoteLines.splice(
chunk.startLeftLine - 1,
0,
...localText
.split("\n")
.slice(chunk.startRightLine - 1, chunk.endRightLine - 1),
);
onRemoteTextChange(remoteLines.join("\n"));
} else if (chunk.type === "modify") {
const remoteLines = remoteText.split("\n");
remoteLines.splice(
chunk.startLeftLine - 1,
chunk.endLeftLine - chunk.startLeftLine,
...localText
.split("\n")
.slice(chunk.startRightLine - 1, chunk.endRightLine - 1),
);
onRemoteTextChange(remoteLines.join("\n"));
}
}}
onAcceptRight={(chunk: DiffChunk) => {
if (chunk.type === "remove") {
const localLines = localText.split("\n");
localLines.splice(
chunk.startRightLine - 1,
0,
...remoteText
.split("\n")
.slice(chunk.startLeftLine - 1, chunk.endLeftLine - 1),
);
onLocalTextChange(localLines.join("\n"));
} else if (chunk.type === "modify") {
const localLines = localText.split("\n");
localLines.splice(
chunk.startRightLine - 1,
chunk.endRightLine - chunk.startRightLine,
...remoteText
.split("\n")
.slice(chunk.startLeftLine - 1, chunk.endLeftLine - 1),
);
onLocalTextChange(localLines.join("\n"));
}
}}
onReject={(chunk: DiffChunk) => {
if (chunk.type === "add") {
const localLines = localText.split("\n");
localLines.splice(
chunk.startRightLine - 1,
chunk.endRightLine - chunk.startRightLine,
);
onLocalTextChange(localLines.join("\n"));
} else if (chunk.type === "remove") {
const remoteLines = remoteText.split("\n");
remoteLines.splice(
chunk.startLeftLine - 1,
chunk.endLeftLine - chunk.startLeftLine,
);
onRemoteTextChange(remoteLines.join("\n"));
}
}}
/>
</div>
<div style={{ flex: 1 }}>
<EditorPane
content={localText}
highlightPluginSpec={{
diff: diffs,
isOriginal: false,
}}
onContentChange={onLocalTextChange}
onScrollTopUpdate={setRightEditorTopOffset}
/>
</div>
</div>
);
};
export default DiffView;

View file

@ -0,0 +1,95 @@
import { markdown } from "@codemirror/lang-markdown";
import { EditorView, ViewUpdate } from "@codemirror/view";
import * as React from "react";
import {
DiffHighlightPluginSpec,
createDiffHighlightPlugin,
} from "./diff-highlight-plugin";
import CodeMirror from "@uiw/react-codemirror";
interface EditorPaneProps {
content: string;
highlightPluginSpec: DiffHighlightPluginSpec;
onEditorUpdate?: (editor: EditorView) => void;
onContentChange: (content: string) => void;
// Used to track the offset of the first line when the editor scrolls
onScrollTopUpdate?: (topOffset: number) => void;
}
const EditorPane: React.FC<EditorPaneProps> = (props) => {
const {
content,
highlightPluginSpec,
onEditorUpdate,
onContentChange,
onScrollTopUpdate,
} = props;
const extensions = React.useMemo(() => {
return [
// basicSetup minus line numbers
// EditorView.lineWrapping,
EditorView.editable.of(true),
createDiffHighlightPlugin(highlightPluginSpec),
EditorView.theme({
"&": {
backgroundColor: "var(--background-primary)",
color: "var(--text-normal)",
},
".cm-content": {
padding: 0,
caretColor: "var(--caret-color)",
fontSize: "var(--font-text-size)",
fontFamily: "var(--font-text)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
background: "var(--text-selection)",
},
"&.cm-focused": {
outline: 0,
},
"&.cm-focused .cm-cursor": {
borderLeftColor: "var(--text-normal)",
},
".diff-modify-background": {
backgroundColor: "rgba(var(--color-yellow-rgb), 0.1)",
},
".diff-add-background": {
backgroundColor: "rgba(var(--color-green-rgb), 0.1)",
},
".diff-remove-background": {
backgroundColor: "rgba(var(--color-red-rgb), 0.1)",
},
}),
markdown(),
EditorView.domEventObservers({
scroll(event) {
const target = event.target as HTMLElement;
onScrollTopUpdate?.(target.scrollTop);
},
}),
];
}, [highlightPluginSpec]);
return (
<CodeMirror
value={content}
style={{
height: "100%",
}}
theme={"none"}
width={"100%"}
height={"100%"}
basicSetup={false}
extensions={extensions}
onChange={(value: string) => {
onContentChange(value);
}}
onUpdate={(viewUpdate: ViewUpdate) => {
// We want to know when it updates in case the line height changes
onEditorUpdate?.(viewUpdate.view);
}}
/>
);
};
export default EditorPane;

View file

@ -0,0 +1,133 @@
import { Menu } from "obsidian";
import * as React from "react";
interface FilesTabBarProps {
files: string[];
currentFile: string;
setCurrentFileIndex: (index: number) => void;
}
const FilesTabBar: React.FC<FilesTabBarProps> = ({
files,
currentFile,
setCurrentFileIndex: setCurrentFile,
}) => {
const createTab = (filePath: string, index: number) => {
return (
<div
key={filePath}
style={{
content: "none",
maxWidth: "6rem",
}}
className={`workspace-tab-header tappable`}
aria-label={filePath}
data-tooltip-delay="300"
onClick={() => setCurrentFile(index)}
>
<div
style={{
color:
filePath === currentFile ? "var(--tab-text-color-focused)" : "",
backgroundColor:
filePath === currentFile
? "var(--background-modifier-hover)"
: "",
}}
className="workspace-tab-header-inner"
>
<div
style={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
direction: "rtl",
textAlign: "center",
}}
>
{filePath}
</div>
</div>
</div>
);
};
// Makes it easier to position the tab bar menu
const divRef = React.useRef<HTMLDivElement>(null);
return (
<div
style={{
display: "flex",
backgroundColor: "var(--background-primary)",
height: "var(--header-height)",
borderBottom: "var(--tab-outline-width) solid var(--tab-outline-color)",
flex: "0 0 auto",
paddingLeft: "var(--size-4-2)",
paddingRight: "var(--size-4-2)",
position: "relative",
}}
>
<div
style={{
animationDuration: "250ms",
display: "flex",
flex: "0 1 auto",
overflow: "auto",
padding: "1px 0 7px",
margin: "6px 0 0 0",
gap: "3px",
}}
>
{files.map(createTab)}
</div>
<div
style={{
display: "flex",
flexGrow: 1,
}}
/>
<div
ref={divRef}
style={{
marginInlineEnd: "var(--size-4-1)",
padding: "var(--size-4-2) 0 var(--size-2-3)",
}}
onClick={() => {
const menu = new Menu();
files.forEach((filename: string, index: number) => {
menu.addItem((item) => {
item.setTitle(filename).onClick(() => setCurrentFile(index));
});
});
// We use the divRef to force the position to be relative to this div. We want the position
// to always be the same so using the click event is not feasible as that depends on the
// coordinates of the user click.
// The event target is not usable either as depending where the user clicks the target
// might be this div or its children thus having different positions.
const rect = divRef.current!.getBoundingClientRect();
menu.showAtPosition({ x: rect.left, y: rect.bottom });
}}
>
<span className="clickable-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="svg-icon lucide-chevron-down"
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</span>
</div>
</div>
);
};
export default FilesTabBar;

View file

@ -0,0 +1,120 @@
import * as React from "react";
import { ConflictFile, ConflictResolution } from "src/sync-manager";
import DiffView from "./diff-view";
import FilesTabBar from "./files-tab-bar";
const SplitView = ({
initialFiles,
onResolveAllConflicts,
}: {
initialFiles: ConflictFile[];
onResolveAllConflicts: (resolutions: ConflictResolution[]) => void;
}) => {
const [files, setFiles] = React.useState(initialFiles);
const [resolvedConflicts, setResolvedConflicts] = React.useState<
ConflictResolution[]
>([]);
const [currentFileIndex, setCurrentFileIndex] = React.useState(0);
const currentFile = files.at(currentFileIndex);
const onConflictResolved = () => {
// Remove the file from the conflicts to resolve
const remainingFiles = files.filter(
(_, index) => index !== currentFileIndex,
);
setFiles(remainingFiles);
// Keep track of the resolved conflicts
const newResolvedConflicts = [
...resolvedConflicts,
{
filePath: currentFile!.filePath,
content: currentFile!.localContent,
},
];
setResolvedConflicts(newResolvedConflicts);
// Select the previous file only if we're not already at the start
if (currentFileIndex > 0) {
setCurrentFileIndex(currentFileIndex - 1);
}
if (remainingFiles.length === 0) {
// We solved all conflicts, we can resume syncing
onResolveAllConflicts(newResolvedConflicts);
}
};
return (
<React.StrictMode>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
justifyContent: "center",
}}
>
{files.length === 0 ? (
<div
style={{
position: "relative",
textAlign: "center",
alignSelf: "center",
}}
>
<div
style={{
margin: "20px 0",
fontWeight: "var(--h2-weight)",
fontSize: "var(--h2-size)",
lineHeight: "var(--line-height-tight)",
}}
>
No conflicts to resolve
</div>
<div
style={{
margin: "20px 0",
fontSize: "var(--font-text-size)",
color: "var(--text-muted)",
lineHeight: "var(--line-height-tight)",
}}
>
That's good, keep going
</div>
</div>
) : (
<>
<FilesTabBar
files={files.map((f) => f.filePath)}
currentFile={currentFile?.filePath || ""}
setCurrentFileIndex={setCurrentFileIndex}
/>
<div
style={{
overflow: "auto",
flex: 1,
}}
>
<DiffView
remoteText={currentFile?.remoteContent || ""}
localText={currentFile?.localContent || ""}
onRemoteTextChange={(content: string) => {
const tempFiles = [...files];
tempFiles[currentFileIndex].remoteContent = content;
setFiles(tempFiles);
}}
onLocalTextChange={(content: string) => {
const tempFiles = [...files];
tempFiles[currentFileIndex].localContent = content;
setFiles(tempFiles);
}}
onConflictResolved={onConflictResolved}
/>
</div>
</>
)}
</div>
</React.StrictMode>
);
};
export default SplitView;

View file

@ -0,0 +1,9 @@
/// Defines where the range of the current document shown
/// in the editor comes from.
/// Both from and to and indexes of chars in the document,
/// usually pointing at the start and end of the line respectively.
interface ConflictRange {
from: number;
to: number;
source: "remote" | "local" | "both";
}

View file

@ -0,0 +1,266 @@
import {
EditorState,
Range,
RangeSetBuilder,
StateField,
} from "@uiw/react-codemirror";
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
import { createRoot } from "react-dom/client";
import UnifiedResolutionBar from "./unified-resolution-bar";
import { UpdateRangesEffect } from "./ranges-state-field";
interface ResolutionWidgetProps {
onAccept?: () => void;
onDiscard?: () => void;
onAcceptAbove?: () => void;
onAcceptBelow?: () => void;
onAcceptBoth?: () => void;
onDiscardBoth?: () => void;
}
/// Widget that show some buttons to the user so they can resolve the conflicts.
/// This is usually drawn between two conflicting ranges in the document, if
/// the conflict comes only from a single document this will be shown on top.
///
/// If onAccept and onDiscard are set only the accept and discard buttons will be shown,
/// this is used when the conflict comes from a single document.
/// If either is not set all the other buttons will be shown.
class ResolutionWidget extends WidgetType {
onAccept?: () => void;
onDiscard?: () => void;
onAcceptAbove?: () => void;
onAcceptBelow?: () => void;
onAcceptBoth?: () => void;
onDiscardBoth?: () => void;
constructor(props: ResolutionWidgetProps) {
super();
({
onAccept: this.onAccept,
onDiscard: this.onDiscard,
onAcceptAbove: this.onAcceptAbove,
onAcceptBelow: this.onAcceptBelow,
onAcceptBoth: this.onAcceptBoth,
onDiscardBoth: this.onDiscardBoth,
} = props);
}
toDOM(): HTMLElement {
const div = document.createElement("div");
const root = createRoot(div);
root.render(
<UnifiedResolutionBar
onAccept={this.onAccept}
onDiscard={this.onDiscard}
onAcceptAbove={this.onAcceptAbove}
onAcceptBelow={this.onAcceptBelow}
onAcceptBoth={this.onAcceptBoth}
onDiscardBoth={this.onDiscardBoth}
/>,
);
return div;
}
}
/// Create a line decoration for CodeMirror editor that highlights
/// the ranges set by the ranges state field to show where the text comes from.
/// Text coming from remote will be shown as red and local as green.
export const createLineDecorations = (
rangesStateField: StateField<ConflictRange[]>,
) => {
return EditorView.decorations.compute(["doc", rangesStateField], (state) => {
const ranges = state.field(rangesStateField);
const builder = new RangeSetBuilder<Decoration>();
for (const range of ranges) {
const startLine = state.doc.lineAt(range.from);
const endLine = state.doc.lineAt(range.to);
for (let i = 0; i <= endLine.number - startLine.number; i += 1) {
const line = state.doc.line(startLine.number + i);
if (range.source === "remote") {
builder.add(
line.from,
line.from,
Decoration.line({ class: "cm-deletedLine" }),
);
} else if (range.source === "local") {
builder.add(
line.from,
line.from,
Decoration.line({ class: "cm-addedLine" }),
);
}
}
}
return builder.finish();
});
};
/// Create the resolution buttons depending on the ranges state field.
/// We need the view to dispatch the transaction to update the document
/// and delete the ranges when the user click the buttons.
export const createResolutionDecorations = (
rangesStateField: StateField<ConflictRange[]>,
getView: () => EditorView,
) => {
return EditorView.decorations.compute(
[rangesStateField],
(state: EditorState) => {
const ranges = state.field(rangesStateField);
let widgets: Range<Decoration>[] = [];
ranges.forEach((range: ConflictRange, index: number) => {
if (range.source === "both") {
return;
}
const previousRange = ranges.at(index - 1);
const nextRange = ranges.at(index + 1);
if (range.source === "remote") {
const nextRangeIsNew = nextRange?.source === "local";
if (nextRangeIsNew) {
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAcceptAbove: () => {
getView().dispatch({
changes: {
from: range.to,
to: nextRange.to,
insert: "",
},
effects: [
UpdateRangesEffect.of({
index,
newSource: "both",
}),
UpdateRangesEffect.of({
index: index + 1,
}),
],
});
},
onAcceptBelow: () => {
getView().dispatch({
changes: {
from: range.from,
to: nextRange?.from || range.to,
insert: "",
},
effects: [
UpdateRangesEffect.of({
index,
}),
UpdateRangesEffect.of({
index: index + 1,
newSource: "both",
}),
],
});
},
onAcceptBoth: () => {
getView().dispatch({
effects: [
UpdateRangesEffect.of({
index,
newSource: "both",
}),
UpdateRangesEffect.of({
index: index + 1,
newSource: "both",
}),
],
});
},
onDiscardBoth: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: ranges.at(index + 2)?.from || nextRange.to,
insert: "",
},
effects: [
UpdateRangesEffect.of({
index,
}),
UpdateRangesEffect.of({
index: index + 1,
}),
],
});
},
}),
side: 1,
block: true,
});
widgets.push(deco.range(range.to));
} else {
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAccept: () => {
getView().dispatch({
effects: UpdateRangesEffect.of({
index: index,
newSource: "both",
}),
});
},
onDiscard: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: nextRange?.from || range.to,
insert: "",
},
effects: UpdateRangesEffect.of({
index: index,
}),
});
},
}),
side: -1,
block: true,
});
widgets.push(deco.range(range.from));
}
} else if (
range.source === "local" &&
previousRange?.source !== "remote"
) {
// We draw this only in case the previous range doesn't come from the old document
// since we handle that above
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAccept: () => {
getView().dispatch({
effects: UpdateRangesEffect.of({
index: index,
newSource: "both",
}),
});
},
onDiscard: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: nextRange?.from || range.to,
insert: "",
},
effects: UpdateRangesEffect.of({
index: index,
}),
});
},
}),
side: -1,
block: true,
});
widgets.push(deco.range(range.from));
}
});
return Decoration.set(widgets);
},
);
};

View file

@ -0,0 +1,234 @@
import * as React from "react";
import CodeMirror from "@uiw/react-codemirror";
import { EditorView } from "@codemirror/view";
import diff, { DiffChunk } from "../diff";
import { createRangesStateField } from "./ranges-state-field";
import {
createLineDecorations,
createResolutionDecorations,
} from "./decorations";
interface DiffViewProps {
initialRemoteText: string;
initialLocalText: string;
onConflictResolved: (content: string) => void;
}
/// Create a unique document that combines text from the remote
/// and the local document depending on the diff chunks.
/// This returns a single string of the combined documents and the
/// ranges that specify whether a certain document range comes from
/// remote, local or both documents, so we can highlight them.
const createUnifiedDocument = (
remoteText: string,
localText: string,
diffChunks: DiffChunk[],
): { doc: string; lineRanges: ConflictRange[] } => {
const sortedChunks = [...diffChunks].sort(
(a, b) => a.startLeftLine - b.startLeftLine,
);
const remoteLines = remoteText.split(/\r?\n/);
const localLines = localText.split(/\r?\n/);
let result: string[] = [];
let lineRanges: ConflictRange[] = [];
let linePosition = 0;
let currentRange: ConflictRange | null = null;
let remoteTextLine = 1;
let localTextLine = 1;
const addLine = (line: string, source: "remote" | "local" | "both") => {
result.push(line);
const startPos = linePosition;
const endPos = linePosition + line.length;
if (currentRange && currentRange.source === source) {
// Extend existing range
currentRange.to = endPos;
} else {
// Create new range
if (currentRange) {
lineRanges.push(currentRange);
}
currentRange = { from: startPos, to: endPos, source };
}
// Move position to start of next line
linePosition = endPos + 1; // +1 for newline
};
// Process each chunk
for (const chunk of sortedChunks) {
// Add common lines before the chunk
while (
remoteTextLine < chunk.startLeftLine &&
localTextLine < chunk.startRightLine
) {
addLine(remoteLines[remoteTextLine - 1], "both");
remoteTextLine++;
localTextLine++;
}
// Add removed lines (from old text)
for (let i = remoteTextLine; i < chunk.endLeftLine; i++) {
addLine(remoteLines[i - 1], "remote");
}
// Add added lines (from new text)
for (let i = localTextLine; i < chunk.endRightLine; i++) {
addLine(localLines[i - 1], "local");
}
// Update line pointers
remoteTextLine = chunk.endLeftLine;
localTextLine = chunk.endRightLine;
}
// Add remaining common lines after the last chunk
while (
remoteTextLine <= remoteLines.length &&
localTextLine <= localLines.length
) {
if (
remoteTextLine > remoteLines.length ||
localTextLine > localLines.length
)
break;
addLine(remoteLines[remoteTextLine - 1], "both");
remoteTextLine++;
localTextLine++;
}
// Add the final range if there is one
if (currentRange) {
lineRanges.push(currentRange);
}
return { doc: result.join("\n"), lineRanges };
};
const DiffView: React.FC<DiffViewProps> = ({
initialRemoteText,
initialLocalText,
onConflictResolved,
}) => {
const editorViewRef = React.useRef<EditorView | null>(null);
const diffChunks = diff(initialRemoteText, initialLocalText);
const { doc, lineRanges } = createUnifiedDocument(
initialRemoteText,
initialLocalText,
diffChunks,
);
const [hasConflicts, setHasConflicts] = React.useState(diffChunks.length > 0);
const extensions = React.useMemo(() => {
const conflictRangesField = createRangesStateField(lineRanges);
return [
conflictRangesField,
createLineDecorations(conflictRangesField),
createResolutionDecorations(
conflictRangesField,
() => editorViewRef.current!,
),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const conflictRanges = update.state.field(conflictRangesField);
const allConflictsSolved = conflictRanges.some(
(range) => range.source === "remote" || range.source === "local",
);
if (!allConflictsSolved) {
setHasConflicts(allConflictsSolved);
}
}
}),
EditorView.editable.of(true),
EditorView.theme({
"&": {
backgroundColor: "var(--background-primary)",
color: "var(--text-normal)",
borderTop: "1px solid var(--background-modifier-border)",
borderBottom: "1px solid var(--background-modifier-border)",
},
".cm-content": {
padding: 0,
caretColor: "var(--caret-color)",
fontSize: "var(--font-text-size)",
fontFamily: "var(--font-text)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
background: "var(--text-selection)",
},
"&.cm-focused": {
outline: 0,
},
"&.cm-focused .cm-cursor": {
borderLeftColor: "var(--text-normal)",
},
".cm-addedLine": {
backgroundColor: "rgba(var(--color-green-rgb), 0.1)",
},
".cm-deletedLine": {
backgroundColor: "rgba(var(--color-red-rgb), 0.1)",
},
}),
];
}, [initialRemoteText, initialLocalText]);
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<CodeMirror
value={doc}
height="100%"
theme="none"
basicSetup={false}
extensions={extensions}
onCreateEditor={(view: EditorView) => {
editorViewRef.current = view;
}}
/>
<div
style={{
display: "flex",
justifyContent: "center",
paddingTop: "var(--size-4-4)",
}}
>
<button
style={
hasConflicts
? {}
: {
backgroundColor: "var(--interactive-accent)",
color: "var(--text-on-accent)",
}
}
disabled={hasConflicts}
onClick={() => {
const content = editorViewRef.current!.state.doc.toString();
onConflictResolved(content);
}}
>
Resolve conflict
</button>
</div>
</div>
);
};
export default DiffView;

View file

@ -0,0 +1,119 @@
import { StateEffect, StateField } from "@uiw/react-codemirror";
/// Possible transaction effects we can apply to the ranges state field
/// when it gets updated.
export type RangeUpdateOperation =
| RangeChangeSourceOperation
| RangeRemoveOperation;
export const UpdateRangesEffect = StateEffect.define<RangeUpdateOperation>();
export type RangeChangeSourceOperation = {
index: number;
newSource: "remote" | "local" | "both";
};
export type RangeRemoveOperation = {
index: number;
};
/// Create the ranges state field that we can access and use to highlight and
/// resolve conflicts.
export const createRangesStateField = (
initialRanges: ConflictRange[],
): StateField<ConflictRange[]> => {
return StateField.define<ConflictRange[]>({
create: () => initialRanges,
update: (ranges, tr) => {
const rangeEffects = tr.effects
.filter((e) => e.is(UpdateRangesEffect))
.reduce((acc, e) => {
const operation = e.value as RangeUpdateOperation;
acc.set(operation.index, operation);
return acc;
}, new Map<number, RangeUpdateOperation>());
if (!tr.docChanged && rangeEffects.size === 0) {
return ranges;
}
// Map all positions through the changes and apply any effect
let newRanges = ranges
.map((range, index) => {
let source = range.source;
const effect = rangeEffects.get(index) as RangeChangeSourceOperation;
if (effect) {
source = effect.newSource;
}
return {
from: tr.changes.mapPos(range.from),
to: tr.changes.mapPos(range.to, 1),
source,
};
})
.filter((range, index) => {
return range.from !== range.to && !rangeEffects.has(index);
});
// Sort ranges by start position (leftmost first)
newRanges.sort((a, b) => a.from - b.from);
// Process ranges line by line
const lineToRangeMap = new Map(); // Maps line number to controlling range
// First pass: determine which range controls each line
for (const range of newRanges) {
const startLine = tr.newDoc.lineAt(range.from).number;
const endLine = tr.newDoc.lineAt(range.to).number;
for (let line = startLine; line <= endLine; line++) {
// If this line isn't claimed yet, the leftmost range (processed first) gets it
if (!lineToRangeMap.has(line)) {
lineToRangeMap.set(line, range);
}
}
}
// Second pass: merge ranges that control consecutive lines
const mergedRanges = [];
let currentRange = null;
// Process lines in order
const allLines = Array.from(lineToRangeMap.keys()).sort((a, b) => a - b);
for (const line of allLines) {
const rangeForLine = lineToRangeMap.get(line);
if (!currentRange) {
// Start a new merged range
currentRange = {
from: tr.newDoc.line(line).from,
to: tr.newDoc.line(line).to,
source: rangeForLine.source,
};
} else if (
currentRange.source === rangeForLine.source &&
line === tr.newDoc.lineAt(currentRange.to).number + 1
) {
// Extend current range if it's the same source and consecutive line
currentRange.to = tr.newDoc.line(line).to;
} else {
// Finish current range and start a new one
mergedRanges.push(currentRange);
currentRange = {
from: tr.newDoc.line(line).from,
to: tr.newDoc.line(line).to,
source: rangeForLine.source,
};
}
}
// Add the last range
if (currentRange) {
mergedRanges.push(currentRange);
}
return mergedRanges;
},
});
};

View file

@ -0,0 +1,87 @@
import * as React from "react";
interface UnifiedResolutionBarProps {
onAccept?: () => void;
onDiscard?: () => void;
onAcceptAbove?: () => void;
onAcceptBelow?: () => void;
onAcceptBoth?: () => void;
onDiscardBoth?: () => void;
}
/// Component that shows buttons the user can click to resolve conflicts
const UnifiedResolutionBar: React.FC<UnifiedResolutionBarProps> = ({
onAccept,
onDiscard,
onAcceptAbove,
onAcceptBelow,
onAcceptBoth,
onDiscardBoth,
}) => {
const renderActions = () => {
if (onAccept !== undefined && onDiscard !== undefined) {
return (
<>
<ClickableText text="Accept" onClick={() => onAccept?.()} />
{" | "}
<ClickableText text="Discard" onClick={() => onDiscard?.()} />
</>
);
}
return (
<>
<ClickableText text="Accept above" onClick={() => onAcceptAbove?.()} />
{" | "}
<ClickableText text="Accept below" onClick={() => onAcceptBelow?.()} />
{" | "}
<ClickableText text="Accept both" onClick={() => onAcceptBoth?.()} />
{" | "}
<ClickableText text="Discard both" onClick={() => onDiscardBoth?.()} />
</>
);
};
return (
<div
style={{
display: "flex",
justifyItems: "left",
fontSize: "var(--font-ui-small)",
fontFamily: "var(--font-interface)",
color: "var(--text-muted)",
backgroundColor: "var(--background-secondary)",
padding: "var(--size-2-2)",
paddingLeft: "var(--size-2-3)",
userSelect: "none",
}}
>
{renderActions()}
</div>
);
};
interface ClickableTextProps {
text: string;
onClick: () => void;
}
const ClickableText: React.FC<ClickableTextProps> = ({ text, onClick }) => {
const [hovered, setHovered] = React.useState(false);
return (
<div
style={{
cursor: "pointer",
color: hovered ? "var(--text-accent)" : "inherit",
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={onClick}
>
{text}
</div>
);
};
export default UnifiedResolutionBar;

View file

@ -0,0 +1,117 @@
import * as React from "react";
import { ConflictFile, ConflictResolution } from "src/sync-manager";
import DiffView from "./diff-view";
const UnifiedView = ({
initialFiles,
onResolveAllConflicts,
}: {
initialFiles: ConflictFile[];
onResolveAllConflicts: (resolutions: ConflictResolution[]) => void;
}) => {
const [files, setFiles] = React.useState(initialFiles);
const [resolvedConflicts, setResolvedConflicts] = React.useState<
ConflictResolution[]
>([]);
const onConflictResolved = (fileIndex: number, content: string) => {
// Remove the file from the conflicts to resolve
const remainingFiles = files.filter((_, index) => index !== fileIndex);
setFiles(remainingFiles);
// Keep track of the resolved conflicts
const newResolvedConflicts = [
...resolvedConflicts,
{
filePath: files[fileIndex].filePath,
content,
},
];
setResolvedConflicts(newResolvedConflicts);
if (remainingFiles.length === 0) {
// We solved all conflicts, we can resume syncing
onResolveAllConflicts(newResolvedConflicts);
}
};
const renderConflict = (file: ConflictFile, index: number) => {
return (
<div
key={file.filePath}
style={{
width: "100%",
paddingTop: "var(--size-4-4)",
paddingBottom: "var(--size-4-4)",
borderBottom: "1px solid var(--background-modifier-border)",
display: "flex",
flexDirection: "column",
}}
>
<div
className="inline-title"
style={{
width: "100%",
paddingLeft: "var(--size-4-8)",
paddingRight: "var(--size-4-8)",
}}
>
{file.filePath}
</div>
<DiffView
initialRemoteText={file.remoteContent || ""}
initialLocalText={file.localContent || ""}
onConflictResolved={(content: string) => {
onConflictResolved(index, content);
}}
/>
</div>
);
};
return (
<React.StrictMode>
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
}}
>
{files.length === 0 ? (
<div
style={{
position: "relative",
textAlign: "center",
alignSelf: "center",
}}
>
<div
style={{
margin: "20px 0",
fontWeight: "var(--h2-weight)",
fontSize: "var(--h2-size)",
lineHeight: "var(--line-height-tight)",
}}
>
No conflicts to resolve
</div>
<div
style={{
margin: "20px 0",
fontSize: "var(--font-text-size)",
color: "var(--text-muted)",
lineHeight: "var(--line-height-tight)",
}}
>
That's good, keep going
</div>
</div>
) : (
files.map(renderConflict)
)}
</div>
</React.StrictMode>
);
};
export default UnifiedView;

View file

@ -0,0 +1,91 @@
import { IconName, ItemView, Menu, Platform, WorkspaceLeaf } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import GitHubSyncPlugin from "src/main";
import { ConflictFile, ConflictResolution } from "src/sync-manager";
import SplitView from "./split-view/split-view";
import UnifiedView from "./unified-view/unified-view";
export const CONFLICTS_RESOLUTION_VIEW_TYPE = "conflicts-resolution-view";
export class ConflictsResolutionView extends ItemView {
icon: IconName = "merge";
private root: Root | null = null;
constructor(
leaf: WorkspaceLeaf,
private plugin: GitHubSyncPlugin,
private conflicts: ConflictFile[],
) {
super(leaf);
}
getViewType() {
return CONFLICTS_RESOLUTION_VIEW_TYPE;
}
getDisplayText() {
return "Conflicts resolution";
}
private resolveAllConflicts(resolutions: ConflictResolution[]) {
if (this.plugin.conflictsResolver) {
this.plugin.conflictsResolver(resolutions);
this.plugin.conflictsResolver = null;
}
}
setConflictFiles(conflicts: ConflictFile[]) {
this.conflicts = conflicts;
this.render(conflicts);
}
async onOpen() {
this.render(this.conflicts);
}
private render(conflicts: ConflictFile[]) {
if (!this.root) {
// Hides the navigation header
(this.containerEl.children[0] as HTMLElement).className =
"hidden-navigation-header";
const container = this.containerEl.children[1];
container.empty();
// We don't want any padding, the DiffView component will handle that
(container as HTMLElement).className = "padless-conflicts-view-container";
this.root = createRoot(container);
}
let diffMode = "default";
if (this.plugin.settings.conflictViewMode === "default") {
if (Platform.isMobile) {
diffMode = "unified";
} else {
diffMode = "split";
}
} else if (this.plugin.settings.conflictViewMode === "split") {
diffMode = "split";
} else if (this.plugin.settings.conflictViewMode === "unified") {
diffMode = "unified";
}
if (diffMode === "split") {
this.root.render(
<SplitView
initialFiles={conflicts}
onResolveAllConflicts={this.resolveAllConflicts.bind(this)}
/>,
);
} else {
this.root.render(
<UnifiedView
initialFiles={conflicts}
onResolveAllConflicts={this.resolveAllConflicts.bind(this)}
/>,
);
}
}
async onClose() {
// Nothing to clean up.
}
}

View file

@ -1,545 +0,0 @@
import { useState, useEffect } from "react";
import SyncManager from "src/sync-manager";
import { usePlugin } from "src/views/hooks";
import { DEFAULT_SETTINGS, GitHubSyncSettings } from "src/settings/settings";
const STEPS = ["welcome", "repo", "token", "sync", "first_sync"] as const;
type Step = (typeof STEPS)[number];
type StepData = {
repo: { owner: string; repo: string; branch: string };
token: { token: string };
sync: {
mode: "manual" | "interval";
syncOnStart: boolean;
syncConfigDir: boolean;
onConflict: "ignore" | "ask" | "overwrite";
};
};
const DEFAULT_STEP_DATA: StepData = {
repo: { owner: "", repo: "", branch: "" },
token: { token: "" },
sync: {
mode: "manual",
syncOnStart: false,
syncConfigDir: true,
onConflict: "overwrite",
},
};
const WelcomeStepComponent = () => {
return (
<div
style={{
display: "flex",
flexFlow: "column",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<h1 style={{ textAlign: "center" }}>First setup</h1>
<p style={{ textAlign: "center" }}>
This plugin is still in beta so not all features are available yet. If
you find any issues report that.
</p>
<p style={{ textAlign: "center" }}>
You'll be guided through some steps to get you started.
</p>
</div>
);
};
const RepoStepComponent = ({
values,
onChange,
}: {
values: { owner: string; repo: string; branch: string };
onChange: (values: { owner: string; repo: string; branch: string }) => void;
}) => {
return (
<div
style={{
display: "flex",
flexFlow: "column",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<p
style={{
textAlign: "center",
}}
>
First we need the repository owner, name and branch.
</p>
<p
style={{
textAlign: "center",
}}
>
You must use a repository that you have read and write access to. If
your vault already has content I suggest creating a new private
repository to sync.
</p>
<div
style={{
display: "flex",
flexFlow: "column",
gap: "var(--size-4-2)",
}}
>
<input
type="text"
spellCheck="false"
placeholder="Owner"
style={{ width: "100%" }}
value={values.owner}
onChange={(e) => onChange({ ...values, owner: e.target.value })}
/>
<input
type="text"
spellCheck="false"
placeholder="Repository"
style={{ width: "100%" }}
value={values.repo}
onChange={(e) => onChange({ ...values, repo: e.target.value })}
/>
<input
type="text"
spellCheck="false"
placeholder="Branch"
style={{ width: "100%" }}
value={values.branch}
onChange={(e) => onChange({ ...values, branch: e.target.value })}
/>
</div>
</div>
);
};
const TokenStepComponent = ({
values,
onChange,
}: {
values: { token: string };
onChange: (values: { token: string }) => void;
}) => {
return (
<div
style={{
display: "flex",
flexFlow: "column",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<p
style={{
textAlign: "center",
}}
>
Now we need a GitHub Personal Access Token.
</p>
<p
style={{
textAlign: "center",
}}
>
The token is used to get, create and update the files between your vault
and your GitHub repository. Click{" "}
<a href="https://github.com/settings/personal-access-tokens">here</a> to
create a new token. For more information about the tokens see{" "}
<a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token">
the official GitHub documentation.
</a>
</p>
<p
style={{
textAlign: "center",
}}
>
The token must have the Contents Read and Write permissions for the same
repository you set in the previous step.
</p>
<input
type="text"
spellCheck="false"
placeholder="Token"
style={{ width: "100%" }}
value={values.token}
onChange={(e) => onChange({ token: e.target.value })}
/>
</div>
);
};
const SyncSettingsStepComponent = ({
values,
onChange,
}: {
values: {
mode: "manual" | "interval";
syncOnStart: boolean;
syncConfigDir: boolean;
onConflict: "ignore" | "ask" | "overwrite";
};
onChange: (values: {
mode: "manual" | "interval";
syncOnStart: boolean;
syncConfigDir: boolean;
onConflict: "ignore" | "ask" | "overwrite";
}) => void;
}) => {
return (
<div
style={{
display: "flex",
flexFlow: "column",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<p>
You can sync your vault manually or every minute. You can change the
interval at a later time.
</p>
<select
className="dropdown"
value={values.mode}
onChange={(e) =>
onChange({
...values,
mode: e.target.value as typeof values.mode,
})
}
>
<option value="manual">Manually</option>
<option value="interval">On Interval</option>
</select>
<p>Optionally you can sync every time you open Obsidian.</p>
<div
className={`checkbox-container ${values.syncOnStart ? "is-enabled" : ""}`}
onClick={() =>
onChange({ ...values, syncOnStart: !values.syncOnStart })
}
>
<input type="checkbox" readOnly={true} checked={values.syncOnStart} />
</div>
<p>
If you want different configs for different vaults you can disable
config syncing.
</p>
<div
className={`checkbox-container ${values.syncConfigDir ? "is-enabled" : ""}`}
onClick={() =>
onChange({ ...values, syncConfigDir: !values.syncConfigDir })
}
>
<input type="checkbox" readOnly={true} checked={values.syncConfigDir} />
</div>
<p>In case of conflicts you can choose how to handle them.</p>
<p>
This is not yet implemented so remote files will always overwrite local
files in case of conflicts.
</p>
<select
className="dropdown"
disabled={true}
value={values.onConflict}
onChange={(e) =>
onChange({
...values,
onConflict: e.target.value as typeof values.onConflict,
})
}
>
<option value="ignore">Ignore remote file</option>
<option value="ask">Ask</option>
<option value="overwrite">Overwrite local file</option>
</select>
</div>
);
};
const FirstSyncStepComponent = ({
stepData,
onClose,
}: {
stepData: StepData;
onClose: () => Promise<void>;
}) => {
const [syncing, setSyncing] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const plugin = usePlugin();
if (!plugin) {
// Unlikely to happen, makes TS happy though
throw new Error("Plugin is not initialized");
}
useEffect(() => {
(async () => {
const settings: GitHubSyncSettings = {
...DEFAULT_SETTINGS,
githubToken: stepData.token.token,
githubOwner: stepData.repo.owner,
githubRepo: stepData.repo.repo,
githubBranch: stepData.repo.branch,
syncStrategy: stepData.sync.mode,
syncOnStartup: stepData.sync.syncOnStart,
syncConfigDir: stepData.sync.syncConfigDir,
conflictHandling: stepData.sync.onConflict,
};
// We don't want to save the settings yet, so we create a new sync manager
// used only for the first sync.
// After that's sucessful we'll save the settings.
const syncManager = new SyncManager(
plugin.app.vault,
settings,
// There can't be any conflicts at this point so we don't need to handle them
async (_) => {
return [];
},
);
await syncManager.loadMetadata();
try {
await syncManager.firstSync();
} catch (e) {
setError(e.message);
setSuccess(false);
setSyncing(false);
return;
}
plugin.settings = settings;
await plugin.saveSettings();
setSyncing(false);
setSuccess(true);
})();
}, [stepData]);
const loadingBarStyle = () => {
if (error) {
return { width: 0 };
} else if (success) {
return { width: "100%" };
} else {
return {};
}
};
return (
<div
style={{
display: "flex",
flexFlow: "column",
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<p style={{ textAlign: "center" }}>
Now we can try and sync your vault with your repository. This might take
a few minutes depending on the number of files.
</p>
<div className="progress-bar">
<div className="progress-bar-message u-center-text">
{syncing ? "Syncing..." : null}
{error ? "Syncing failed" : null}
{success ? "Syncing complete" : null}
</div>
<div className="progress-bar-indicator" style={{ width: "100%" }}>
<div className="progress-bar-line"></div>
<div
className={`progress-bar-subline ${syncing ? "mod-increase" : ""}`}
style={loadingBarStyle()}
></div>
<div
className={`progress-bar-subline ${syncing ? "mod-decrease" : ""}`}
style={loadingBarStyle()}
></div>
</div>
<div
className="progress-bar-context"
style={{
position: "relative",
top: 0,
}}
>
{error ? <div>{error}</div> : null}
{success ? <div>Everything is set up and ready to go.</div> : null}
</div>
</div>
{success ? (
<div className="modal-button-container">
<button className="mod-cta" onClick={async () => await onClose()}>
Close
</button>
</div>
) : null}
</div>
);
};
const OnBoardingComponent = ({ onClose }: { onClose: () => Promise<void> }) => {
// It starts as true because the first step is just a welcome message
// so the button is already enabled.
const [isValid, setIsValid] = useState<boolean>(true);
const [step, setStep] = useState<Step>("welcome");
const [stepData, setStepData] = useState<StepData>(DEFAULT_STEP_DATA);
const updateStepData = (step: Step, data: StepData[keyof StepData]) => {
setStepData((stepData) => {
return {
...stepData,
[step]: data,
};
});
};
const isStepValid = (step: Step) => {
switch (step) {
// Only these steps have required data, the others don't
// so the step is automatically valid
case "repo":
const { owner, repo, branch } = stepData.repo ?? {};
return Boolean(owner && repo && branch);
case "token":
return Boolean(stepData.token?.token);
case "sync":
default:
return true;
}
};
useEffect(() => {
setIsValid(isStepValid(step));
}, [step, stepData]);
const previousStep = () => {
setStep((currentStep) => {
const currentIndex = STEPS.indexOf(currentStep);
return STEPS[currentIndex - 1] ?? STEPS[0];
});
};
const nextStep = () => {
setStep((currentStep) => {
const currentIndex = STEPS.indexOf(currentStep);
return STEPS[currentIndex + 1] ?? STEPS[STEPS.length - 1];
});
};
const currentStepComponent = () => {
switch (step) {
case "welcome":
return <WelcomeStepComponent />;
case "repo":
return (
<RepoStepComponent
values={stepData.repo}
onChange={(data) => updateStepData("repo", data)}
/>
);
case "token":
return (
<TokenStepComponent
values={stepData.token}
onChange={(data) => updateStepData("token", data)}
/>
);
case "sync":
return (
<SyncSettingsStepComponent
values={stepData.sync}
onChange={(data) => updateStepData("sync", data)}
/>
);
case "first_sync":
return <FirstSyncStepComponent stepData={stepData} onClose={onClose} />;
}
};
return (
<div className="modal-content">
<div
style={{
padding: "var(--size-4-18)",
display: "flex",
flexFlow: "column",
flexGrow: 1,
position: "relative",
}}
>
<button
className="mod-cta"
style={{
borderRadius: "50%",
border: "none",
padding: "8px",
width: "fit-content",
height: "fit-content",
position: "absolute",
left: "var(--size-4-4)",
top: "50%",
display: step === "welcome" ? "none" : "",
}}
onClick={previousStep}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
style={{ stroke: "var(--text-on-accent)" }}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-left"
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
{currentStepComponent()}
<button
className="mod-cta"
style={{
borderRadius: "50%",
border: "none",
padding: "8px",
width: "fit-content",
height: "fit-content",
position: "absolute",
right: "var(--size-4-4)",
top: "50%",
display: step === "first_sync" ? "none" : "",
}}
disabled={!isValid}
onClick={nextStep}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
style={{ stroke: "var(--text-on-accent)" }}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-right"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
</div>
);
};
export default OnBoardingComponent;

View file

@ -1,42 +0,0 @@
import { Modal } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import GitHubSyncPlugin from "src/main";
import { PluginContext } from "src/views/hooks";
import OnboardingDialogComponent from "./component";
export class OnboardingDialog extends Modal {
root: Root | null = null;
constructor(private plugin: GitHubSyncPlugin) {
super(plugin.app);
}
onOpen() {
this.root = createRoot(this.modalEl);
// Make the dialog look like the settings modal
this.modalEl.addClass("mod-settings");
this.modalEl.addClass("mod-sidebar-layout");
this.root.render(
<PluginContext.Provider value={this.plugin}>
<OnboardingDialogComponent
onClose={async () => {
this.plugin.settings.firstStart = false;
// Save the settings only after a successful first sync
await this.plugin.saveSettings();
// Reload metadata after sync to be sure that the main sync manager
// uses the latest version
await this.plugin.syncManager.loadMetadata();
this.close();
}}
/>
</PluginContext.Provider>,
);
}
async onClose() {
const { contentEl } = this;
this.root?.unmount();
contentEl.empty();
}
}

9
styles.css Normal file
View file

@ -0,0 +1,9 @@
.hidden-navigation-header {
display: none;
}
.padless-conflicts-view-container {
padding: 0;
height: 100%;
width: 100%;
}

View file

@ -1,5 +1,17 @@
{ {
"0.0.0": "1.7.7", "0.0.0": "1.7.7",
"0.0.1": "1.7.7", "0.0.1": "1.7.7",
"0.0.2": "1.7.7" "0.0.2": "1.7.7",
"0.0.3": "1.7.7",
"0.0.4": "1.7.7",
"0.0.5": "1.7.7",
"0.0.6": "1.7.7",
"1.0.0": "1.7.7",
"1.0.1": "1.7.7",
"1.0.2": "1.7.7",
"1.0.3": "1.7.7",
"1.0.4": "1.7.7",
"1.0.5": "1.7.7",
"1.0.6": "1.7.7",
"1.0.7": "1.7.7"
} }