mirror of
https://github.com/silvanocerza/github-gitless-sync.git
synced 2026-07-22 12:10:28 +00:00
Compare commits
156 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ebd201da9 | ||
|
|
ee9c2ee236 | ||
|
|
b6eb9ed62d | ||
|
|
59f951f2b8 | ||
|
|
c62afe938b | ||
|
|
69841aa574 | ||
|
|
345826443f | ||
|
|
e33de48075 | ||
|
|
b2f3db6050 | ||
|
|
be547d8691 | ||
|
|
4fa2526322 | ||
|
|
786cefaf4d | ||
|
|
654fa6b455 | ||
|
|
1e16d01adf | ||
|
|
56bd6dee4c | ||
|
|
b6be53ca16 | ||
|
|
53651f5b88 | ||
|
|
263623dad7 | ||
|
|
13383dc65c | ||
|
|
15cc51014c | ||
|
|
96da957f18 | ||
|
|
457a0b6d24 | ||
|
|
1a2eb64959 | ||
|
|
5ab20cf482 | ||
|
|
d0f04d5217 | ||
|
|
7e7eddaec3 | ||
|
|
6c9b8625c3 | ||
|
|
271d90b7f9 | ||
|
|
de1c814260 | ||
|
|
32dee2db4c | ||
|
|
415532ff07 | ||
|
|
5879d22a52 | ||
|
|
bbe05f716a | ||
|
|
17f869f9b3 | ||
|
|
b26853fb3a | ||
|
|
91137ebf82 | ||
|
|
035f223aef | ||
|
|
f2452b1c60 | ||
|
|
4f018043f2 | ||
|
|
e0fca4dc7a | ||
|
|
eec44d8190 | ||
|
|
acf5157971 | ||
|
|
03758866fb | ||
|
|
b7dbbba7b8 | ||
|
|
5f0c8b1ed2 | ||
|
|
c1d1db6df3 | ||
|
|
7a7c13fb5b | ||
|
|
6fbf79cc87 | ||
|
|
083e8db779 | ||
|
|
65f77c5720 | ||
|
|
cb4b3bea24 | ||
|
|
45481a9ff9 | ||
|
|
038a36f03c | ||
|
|
cccf97b0c4 | ||
|
|
0affcbb31e | ||
|
|
73654d5df0 | ||
|
|
ba541d9390 | ||
|
|
59f93d0aba | ||
|
|
3979e73708 | ||
|
|
3f16253487 | ||
|
|
c21e728e3c | ||
|
|
b20cece211 | ||
|
|
12feb2c90f | ||
|
|
42c15f4479 | ||
|
|
928ad73696 | ||
|
|
8732cdc1f0 | ||
|
|
41642b5174 | ||
|
|
937e576bcc | ||
|
|
8dfa1fe592 | ||
|
|
bb21bf6c2e | ||
|
|
68c3b4e6df | ||
|
|
d8ebee26e8 | ||
|
|
46186d8d23 | ||
|
|
d061ab4803 | ||
|
|
e8c95d2824 | ||
|
|
dc2962cec8 | ||
|
|
08d5d05331 | ||
|
|
132ffff36e | ||
|
|
e2a536c945 | ||
|
|
f5ed38f341 | ||
|
|
f72ce44504 | ||
|
|
b263e24941 | ||
|
|
0ca42124db | ||
|
|
7bca8262f4 | ||
|
|
fc890b5d95 | ||
|
|
b9133477c0 | ||
|
|
cd0f54a117 | ||
|
|
28abd1c1cc | ||
|
|
7d389f8e0c | ||
|
|
e4409cb96e | ||
|
|
731920d5a1 | ||
|
|
b8df463e66 | ||
|
|
fa192eb197 | ||
|
|
a1fea0cb95 | ||
|
|
2d8ee9f7bd | ||
|
|
4745f91bdc | ||
|
|
cef814f702 | ||
|
|
1d4bc03aac | ||
|
|
d5eec99546 | ||
|
|
72a7a7ed62 | ||
|
|
19f797c77a | ||
|
|
ca0049c383 | ||
|
|
c315378fb7 | ||
|
|
0721aa9d90 | ||
|
|
55e1d21e93 | ||
|
|
88fa756ab9 | ||
|
|
64d0223aa0 | ||
|
|
bca72c4938 | ||
|
|
6c369232e3 | ||
|
|
aae730f0f1 | ||
|
|
3a7454b86f | ||
|
|
f49adaccc8 | ||
|
|
2dcbd11ed0 | ||
|
|
fd100345a3 | ||
|
|
a0f2439b1f | ||
|
|
fd7cffffab | ||
|
|
b5ebe4a638 | ||
|
|
165f2d277d | ||
|
|
c8908eb7c3 | ||
|
|
9c374f19f2 | ||
|
|
e024c2c4df | ||
|
|
8b1d04d110 | ||
|
|
6ff48b9517 | ||
|
|
d1b980f031 | ||
|
|
5fd4c94566 | ||
|
|
63f9afe738 | ||
|
|
5edb8ca742 | ||
|
|
9f62324c2a | ||
|
|
6d7c0c8772 | ||
|
|
f409216320 | ||
|
|
c0792c79e7 | ||
|
|
3f06dd4f90 | ||
|
|
6888899e17 | ||
|
|
03a3bde3bb | ||
|
|
98b931ead5 | ||
|
|
8843ae0d17 | ||
|
|
f64ff13b88 | ||
|
|
acefd43f60 | ||
|
|
5446560b86 | ||
|
|
476b17ba05 | ||
|
|
0ba9420b3a | ||
|
|
fa995a392e | ||
|
|
ac1e53f79c | ||
|
|
2c4ed3e671 | ||
|
|
0ca0be97f6 | ||
|
|
3bd65baf7b | ||
|
|
524c7efed7 | ||
|
|
f23c00c32a | ||
|
|
e5e19259a5 | ||
|
|
396feddc8c | ||
|
|
9b6a626944 | ||
|
|
754418904a | ||
|
|
d20bda9a68 | ||
|
|
923a811a4a | ||
|
|
cd98f0a392 | ||
|
|
6248031eb1 |
41 changed files with 4894 additions and 1197 deletions
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
|
|
@ -3,7 +3,7 @@ name: Release plugin
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9].[0-9]+.[0-9]+"
|
||||
- "[0-9].[0-9]+.[0-9]+*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -20,14 +20,21 @@ jobs:
|
|||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
npm install -g pnpm@latest-10
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--title="${{ github.ref_name }}" \
|
||||
--draft \
|
||||
main.js manifest.json
|
||||
if [[ "${{ github.ref_name }}" == *-beta* ]]; then
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--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
2
.gitignore
vendored
|
|
@ -156,3 +156,5 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
benchmark_result.json
|
||||
|
|
|
|||
105
README.md
105
README.md
|
|
@ -1,9 +1,9 @@
|
|||
# Obsidian GitHub Sync
|
||||
# GitHub Gitless Sync
|
||||
|
||||
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.
|
||||
This might create problems for this plugin when determining what needs to be synced between remote repository and local vault.
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -12,37 +12,48 @@ These are the main features of the plugin:
|
|||
- Desktop and mobile support
|
||||
- Doesn't require `git`
|
||||
- Multiple vaults sync
|
||||
- Partial vault sync
|
||||
- Partial remote repository sync
|
||||
- Automatic sync on fixed interval
|
||||
- Manual sync
|
||||
- Conflict resolution view
|
||||
|
||||
- Conflicts handling (TODO 🔨)
|
||||
- Filtering by file type (TODO 🔨)
|
||||
|
||||
## 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).
|
||||

|
||||
|
||||
If you already have BRAT installed click [here](obsidian://brat?plugin=https://github.com/silvanocerza/obsidian-github-sync) to install GitHub Sync.
|
||||
### Issues
|
||||
|
||||
If you find any problem please open an issue with as many details as possible.
|
||||
|
||||
Please also provide logs if possible, you can copy them from the settings page. Remember to enable logging first.
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
### 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]
|
||||
> Onboarding is currently not supported on mobile.
|
||||
These settings are mandatory:
|
||||
|
||||
- Your GitHub Token (see below)
|
||||
- Repository owner
|
||||
- Repository name
|
||||
- Repository branch
|
||||
|
||||
If any of this is not set sync won't start.
|
||||
|
||||
### 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).
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
|
@ -59,33 +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.
|
||||
|
||||
### Partial sync
|
||||
### Conflict resolution
|
||||
|
||||
Optionally you can choose to sync only a part of your vault and/or your repository.
|
||||
This might be useful if you keep the sources for your blog in your vault as example, though you sync with some other service.
|
||||
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.
|
||||
|
||||
> [!WARNING]
|
||||
> For the time being changing the directories after onboarding is not supported.
|
||||
> I mean, you can do it, though I have not tested it and it's likely to break in unforeseeable ways.
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
If you use another sync service it might be best to disable this option.
|
||||
Note that the `.obsidian` folder will always be present, this happens because the plugin
|
||||
needs to store some metadata to correctly sync
|
||||
|
||||
> [!NOTE]
|
||||
> The `.obsidian` folder will always be in the root of your repository, even if you choose
|
||||
> 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
|
||||
|
||||
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`
|
||||
file in your config directory, `.obsidian` by default.
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
BIN
assets/install_instructions.png
Normal file
BIN
assets/install_instructions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
assets/logs_settings.png
Normal file
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
BIN
assets/split_diff_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
BIN
assets/unified_diff_view.png
Normal file
BIN
assets/unified_diff_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
280
benchmark.ts
Normal file
280
benchmark.ts
Normal 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
11
manifest-beta.json
Normal 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
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "obsidian-github-sync",
|
||||
"name": "GitHub Sync",
|
||||
"version": "0.0.1",
|
||||
"id": "github-gitless-sync",
|
||||
"name": "GitHub Gitless Sync",
|
||||
"version": "1.0.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",
|
||||
"authorUrl": "https://silvanocerza.com",
|
||||
"fundingUrl": "https://ko-fi.com/silvanocerza",
|
||||
|
|
|
|||
163
mock-obsidian.ts
Normal file
163
mock-obsidian.ts
Normal 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;
|
||||
20
package.json
20
package.json
|
|
@ -1,29 +1,41 @@
|
|||
{
|
||||
"name": "obsidian-github-sync",
|
||||
"version": "0.0.1",
|
||||
"description": "A plugin to sync a Github repository with an Obsidian vault",
|
||||
"name": "github-gitless-sync",
|
||||
"version": "1.0.7",
|
||||
"description": "Sync a GitHub repository with vaults on different platforms without requiring git installation",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs"
|
||||
"version": "node version-bump.mjs",
|
||||
"benchmark": "tsx benchmark.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Silvano Cerza",
|
||||
"license": "AGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/proxyquire": "^1.3.31",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||
"@typescript-eslint/parser": "^8.19.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"obsidian": "^1.7.2",
|
||||
"proxyquire": "^2.1.3",
|
||||
"sinon": "^20.0.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"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-dom": "^19.0.0"
|
||||
}
|
||||
|
|
|
|||
840
pnpm-lock.yaml
840
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,8 @@
|
|||
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 Logger, { LOG_FILE_NAME } from "./logger";
|
||||
import GitHubSyncPlugin from "./main";
|
||||
|
||||
/**
|
||||
* Tracks changes to local sync directory and updates files metadata.
|
||||
|
|
@ -10,18 +12,24 @@ export default class EventsListener {
|
|||
private vault: Vault,
|
||||
private metadataStore: MetadataStore,
|
||||
private settings: GitHubSyncSettings,
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
start() {
|
||||
this.vault.on("create", this.onCreate.bind(this));
|
||||
this.vault.on("delete", this.onDelete.bind(this));
|
||||
this.vault.on("modify", this.onModify.bind(this));
|
||||
this.vault.on("rename", this.onRename.bind(this));
|
||||
start(plugin: GitHubSyncPlugin) {
|
||||
// We need to register all the events we subscribe to so they can
|
||||
// be correctly detached when the plugin is unloaded too.
|
||||
// If we don't they might be left hanging and cause issues.
|
||||
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) {
|
||||
await this.logger.info("Received create event", file.path);
|
||||
if (!this.isSyncable(file.path)) {
|
||||
// The file has not been created in directory that we're syncing with GitHub
|
||||
await this.logger.info("Skipped created file", file.path);
|
||||
return;
|
||||
}
|
||||
if (file instanceof TFolder) {
|
||||
|
|
@ -35,26 +43,12 @@ export default class EventsListener {
|
|||
// It's enough to mark it as non just downloaded.
|
||||
this.metadataStore.data.files[file.path].justDownloaded = false;
|
||||
await this.metadataStore.save();
|
||||
await this.logger.info("Updated just downloaded created file", file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
let remotePath: string;
|
||||
if (file.path.startsWith(this.settings.localContentDir)) {
|
||||
remotePath = file.path.replace(
|
||||
this.settings.localContentDir,
|
||||
this.settings.repoContentDir,
|
||||
);
|
||||
} else if (
|
||||
file.path.startsWith(`${this.vault.configDir}/github-sync-metadata.json`)
|
||||
) {
|
||||
remotePath = file.path;
|
||||
} else {
|
||||
throw new Error("Unexpected file path");
|
||||
}
|
||||
|
||||
this.metadataStore.data.files[file.path] = {
|
||||
localPath: file.path,
|
||||
remotePath: remotePath!,
|
||||
path: file.path,
|
||||
sha: null,
|
||||
dirty: true,
|
||||
// This file has been created by the user
|
||||
|
|
@ -62,14 +56,16 @@ export default class EventsListener {
|
|||
lastModified: Date.now(),
|
||||
};
|
||||
await this.metadataStore.save();
|
||||
await this.logger.info("Updated created file", file.path);
|
||||
}
|
||||
|
||||
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) {
|
||||
// Skip folders
|
||||
return;
|
||||
}
|
||||
const filePath = file instanceof TAbstractFile ? file.path : file;
|
||||
if (!this.isSyncable(filePath)) {
|
||||
// The file was not in directory that we're syncing with GitHub
|
||||
return;
|
||||
|
|
@ -78,11 +74,14 @@ export default class EventsListener {
|
|||
this.metadataStore.data.files[filePath].deleted = true;
|
||||
this.metadataStore.data.files[filePath].deletedAt = Date.now();
|
||||
await this.metadataStore.save();
|
||||
await this.logger.info("Updated deleted file", filePath);
|
||||
}
|
||||
|
||||
private async onModify(file: TAbstractFile) {
|
||||
await this.logger.info("Received modify event", file.path);
|
||||
if (!this.isSyncable(file.path)) {
|
||||
// The file has not been create in directory that we're syncing with GitHub
|
||||
await this.logger.info("Skipped modified file", file.path);
|
||||
return;
|
||||
}
|
||||
if (file instanceof TFolder) {
|
||||
|
|
@ -95,14 +94,20 @@ export default class EventsListener {
|
|||
// It's enough to makr it as non just downloaded.
|
||||
this.metadataStore.data.files[file.path].justDownloaded = false;
|
||||
await this.metadataStore.save();
|
||||
await this.logger.info(
|
||||
"Updated just downloaded modified file",
|
||||
file.path,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.metadataStore.data.files[file.path].lastModified = Date.now();
|
||||
this.metadataStore.data.files[file.path].dirty = true;
|
||||
await this.metadataStore.save();
|
||||
await this.logger.info("Updated modified file", file.path);
|
||||
}
|
||||
|
||||
private async onRename(file: TAbstractFile, oldPath: string) {
|
||||
await this.logger.info("Received rename event", file.path);
|
||||
if (file instanceof TFolder) {
|
||||
// Skip folders
|
||||
return;
|
||||
|
|
@ -131,10 +136,27 @@ export default class EventsListener {
|
|||
}
|
||||
|
||||
private isSyncable(filePath: string) {
|
||||
return (
|
||||
filePath.startsWith(this.settings.localContentDir) ||
|
||||
filePath === `${this.vault.configDir}/github-sync-metadata.json` ||
|
||||
(this.settings.syncConfigDir && filePath.startsWith(this.vault.configDir))
|
||||
);
|
||||
if (filePath === `${this.vault.configDir}/${MANIFEST_FILE_NAME}`) {
|
||||
// Manifest file must always be synced
|
||||
return true;
|
||||
} else if (
|
||||
filePath === `${this.vault.configDir}/workspace.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import Logger from "src/logger";
|
||||
import { GitHubSyncSettings } from "src/settings/settings";
|
||||
import { retryUntil } from "src/utils";
|
||||
|
||||
export type RepoContent = {
|
||||
files: { [key: string]: GetTreeResponseItem };
|
||||
|
|
@ -25,6 +28,13 @@ export type NewTreeRequestItem = {
|
|||
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.
|
||||
*/
|
||||
|
|
@ -37,20 +47,28 @@ export type BlobFile = {
|
|||
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 {
|
||||
constructor(
|
||||
private token: string,
|
||||
private owner: string,
|
||||
private repo: string,
|
||||
private branch: string,
|
||||
private repoContentDir: string,
|
||||
private configDir: string,
|
||||
private settings: GitHubSyncSettings,
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
headers() {
|
||||
return {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Authorization: `Bearer ${this.settings.githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
}
|
||||
|
|
@ -58,14 +76,35 @@ export default class GithubClient {
|
|||
/**
|
||||
* 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
|
||||
*/
|
||||
async getRepoContent(): Promise<RepoContent> {
|
||||
const res = await requestUrl({
|
||||
url: `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${this.branch}?recursive=1`,
|
||||
headers: this.headers(),
|
||||
});
|
||||
const files = res.json.tree
|
||||
async getRepoContent({
|
||||
retry = false,
|
||||
maxRetries = 5,
|
||||
} = {}): Promise<RepoContent> {
|
||||
const response = await retryUntil(
|
||||
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")
|
||||
.reduce(
|
||||
(
|
||||
|
|
@ -74,61 +113,337 @@ export default class GithubClient {
|
|||
) => ({ ...acc, [file.path]: file }),
|
||||
{},
|
||||
);
|
||||
return { files, sha: res.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,
|
||||
}),
|
||||
});
|
||||
return { files, sha: response.json.sha };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a blob from a blob url
|
||||
* @param url blob url
|
||||
* Creates a new tree in the GitHub repository.
|
||||
*
|
||||
* @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> {
|
||||
const res = await requestUrl({
|
||||
url: url,
|
||||
headers: this.headers(),
|
||||
});
|
||||
return res.json;
|
||||
async createTree({
|
||||
tree,
|
||||
retry = false,
|
||||
maxRetries = 5,
|
||||
}: {
|
||||
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.
|
||||
*
|
||||
* @param path Path to create in the repo
|
||||
* @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,
|
||||
content,
|
||||
message,
|
||||
retry = false,
|
||||
maxRetries = 5,
|
||||
}: {
|
||||
path: string;
|
||||
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
70
src/logger.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
194
src/main.ts
194
src/main.ts
|
|
@ -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 GitHubSyncSettingsTab from "./settings/tab";
|
||||
import SyncManager from "./sync-manager";
|
||||
import { FileMetadata } from "./metadata-store";
|
||||
import { OnboardingDialog } from "./views/onboarding/view";
|
||||
import SyncManager, { ConflictFile, ConflictResolution } from "./sync-manager";
|
||||
import Logger from "./logger";
|
||||
import {
|
||||
ConflictsResolutionView,
|
||||
CONFLICTS_RESOLUTION_VIEW_TYPE,
|
||||
} from "./views/conflicts-resolution/view";
|
||||
|
||||
export default class GitHubSyncPlugin extends Plugin {
|
||||
settings: GitHubSyncSettings;
|
||||
syncManager: SyncManager;
|
||||
logger: Logger;
|
||||
|
||||
statusBarItem: HTMLElement | null = null;
|
||||
uploadModifiedFilesRibbonIcon: HTMLElement | null = null;
|
||||
syncRibbonIcon: HTMLElement | null = null;
|
||||
conflictsRibbonIcon: HTMLElement | null = null;
|
||||
|
||||
activeLeafChangeListener: EventRef | null = null;
|
||||
vaultCreateListener: 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() {
|
||||
if (Platform.isMobile) {
|
||||
// TODO: Implement onboarding for mobile
|
||||
return;
|
||||
if (
|
||||
this.settings.githubToken === "" ||
|
||||
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() {
|
||||
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.syncManager = new SyncManager(
|
||||
this.app.vault,
|
||||
this.settings,
|
||||
this.onConflicts.bind(this),
|
||||
this.logger,
|
||||
);
|
||||
await this.syncManager.loadMetadata();
|
||||
|
||||
|
|
@ -47,7 +107,7 @@ export default class GitHubSyncPlugin extends Plugin {
|
|||
// getting spammed with create events.
|
||||
// See the official Obsidian docs:
|
||||
// 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
|
||||
// buttons
|
||||
|
|
@ -55,6 +115,10 @@ export default class GitHubSyncPlugin extends Plugin {
|
|||
this.showStatusBarItem();
|
||||
}
|
||||
|
||||
if (this.settings.showConflictsRibbonButton) {
|
||||
this.showConflictsRibbonIcon();
|
||||
}
|
||||
|
||||
if (this.settings.showSyncRibbonButton) {
|
||||
this.showSyncRibbonIcon();
|
||||
}
|
||||
|
|
@ -65,11 +129,46 @@ export default class GitHubSyncPlugin extends Plugin {
|
|||
name: "Sync with GitHub",
|
||||
repeatable: false,
|
||||
icon: "refresh-cw",
|
||||
callback: async () => {
|
||||
await this.syncManager.sync();
|
||||
this.updateStatusBarItem();
|
||||
},
|
||||
callback: this.sync.bind(this),
|
||||
});
|
||||
|
||||
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() {
|
||||
|
|
@ -128,44 +227,51 @@ export default class GitHubSyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
showSyncRibbonIcon() {
|
||||
if (this.uploadModifiedFilesRibbonIcon) {
|
||||
if (this.syncRibbonIcon) {
|
||||
return;
|
||||
}
|
||||
this.uploadModifiedFilesRibbonIcon = this.addRibbonIcon(
|
||||
this.syncRibbonIcon = this.addRibbonIcon(
|
||||
"refresh-cw",
|
||||
"Sync with GitHub",
|
||||
async () => {
|
||||
await this.syncManager.sync();
|
||||
this.updateStatusBarItem();
|
||||
},
|
||||
this.sync.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
hideUploadModifiedFilesRibbonIcon() {
|
||||
this.uploadModifiedFilesRibbonIcon?.remove();
|
||||
this.uploadModifiedFilesRibbonIcon = null;
|
||||
hideSyncRibbonIcon() {
|
||||
this.syncRibbonIcon?.remove();
|
||||
this.syncRibbonIcon = null;
|
||||
}
|
||||
|
||||
async onConflicts(
|
||||
conflicts: { remoteFile: FileMetadata; localFile: FileMetadata }[],
|
||||
): Promise<boolean[]> {
|
||||
return await Promise.all(
|
||||
conflicts.map(
|
||||
async ({
|
||||
remoteFile,
|
||||
localFile,
|
||||
}: {
|
||||
remoteFile: FileMetadata;
|
||||
localFile: FileMetadata;
|
||||
}) => {
|
||||
// TODO: Add a proper conflict resolution view
|
||||
// This way remote files are always preferred
|
||||
return true;
|
||||
},
|
||||
),
|
||||
showConflictsRibbonIcon() {
|
||||
if (this.conflictsRibbonIcon) {
|
||||
return;
|
||||
}
|
||||
this.conflictsRibbonIcon = this.addRibbonIcon(
|
||||
"merge",
|
||||
"Open sync conflicts view",
|
||||
this.openConflictsView.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
|
@ -191,4 +297,10 @@ export default class GitHubSyncPlugin extends Plugin {
|
|||
this.syncManager.stopSyncInterval();
|
||||
this.syncManager.startSyncInterval(this.settings.syncInterval);
|
||||
}
|
||||
|
||||
async reset() {
|
||||
this.settings = DEFAULT_SETTINGS;
|
||||
this.saveSettings();
|
||||
await this.syncManager.resetMetadata();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
import { Vault, normalizePath } from "obsidian";
|
||||
|
||||
export const MANIFEST_FILE_NAME = "github-sync-metadata.json" as const;
|
||||
|
||||
/**
|
||||
* A file metadata.
|
||||
* Store info that makes easier to track a file locally and in the remote repo.
|
||||
*/
|
||||
export interface FileMetadata {
|
||||
// Local path to the file
|
||||
localPath: string;
|
||||
// Path to the file in the remote repository.
|
||||
remotePath: string;
|
||||
path: string;
|
||||
// SHA of the file in the remote repository.
|
||||
// This is necessary to update the file remotely.
|
||||
// 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;
|
||||
// Whether the file has been modified locally.
|
||||
dirty: boolean;
|
||||
|
|
@ -44,7 +48,7 @@ export default class MetadataStore {
|
|||
|
||||
constructor(private vault: Vault) {
|
||||
this.metadataFile = normalizePath(
|
||||
`${this.vault.configDir}/github-sync-metadata.json`,
|
||||
`${this.vault.configDir}/${MANIFEST_FILE_NAME}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -73,4 +77,8 @@ export default class MetadataStore {
|
|||
});
|
||||
return this.writeQueue;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.data = { lastSync: 0, files: {} };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,35 @@
|
|||
export interface GitHubSyncSettings {
|
||||
firstStart: boolean;
|
||||
firstSync: boolean;
|
||||
githubToken: string;
|
||||
githubOwner: string;
|
||||
githubRepo: string;
|
||||
githubBranch: string;
|
||||
repoContentDir: string;
|
||||
localContentDir: string;
|
||||
syncStrategy: "manual" | "interval";
|
||||
syncInterval: number;
|
||||
syncOnStartup: boolean;
|
||||
syncConfigDir: boolean;
|
||||
conflictHandling: "ignore" | "ask" | "overwrite";
|
||||
conflictHandling: "overwriteLocal" | "ask" | "overwriteRemote";
|
||||
conflictViewMode: "default" | "unified" | "split";
|
||||
showStatusBarItem: boolean;
|
||||
showSyncRibbonButton: boolean;
|
||||
showConflictsRibbonButton: boolean;
|
||||
enableLogging: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: GitHubSyncSettings = {
|
||||
firstStart: true,
|
||||
firstSync: true,
|
||||
githubToken: "",
|
||||
githubOwner: "",
|
||||
githubRepo: "",
|
||||
githubBranch: "main",
|
||||
repoContentDir: "",
|
||||
localContentDir: "",
|
||||
syncStrategy: "manual",
|
||||
syncInterval: 1,
|
||||
syncOnStartup: false,
|
||||
syncConfigDir: true,
|
||||
syncConfigDir: false,
|
||||
conflictHandling: "ask",
|
||||
conflictViewMode: "default",
|
||||
showStatusBarItem: true,
|
||||
showSyncRibbonButton: true,
|
||||
showConflictsRibbonButton: true,
|
||||
enableLogging: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 { copyToClipboard } from "src/utils";
|
||||
|
||||
export default class GitHubSyncSettingsTab extends PluginSettingTab {
|
||||
plugin: GitHubSyncPlugin;
|
||||
|
|
@ -14,13 +22,11 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
|
|||
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl("h1", { text: "GitHub Sync Settings" });
|
||||
|
||||
containerEl.createEl("h2", { text: "Remote Repository" });
|
||||
new Setting(containerEl).setName("Remote Repository").setHeading();
|
||||
|
||||
let tokenInput: TextComponent;
|
||||
new Setting(containerEl)
|
||||
.setName("GitHub Token")
|
||||
.setName("GitHub token")
|
||||
.setDesc(
|
||||
"A personal access token or a fine-grained token with read and write access to your repository",
|
||||
)
|
||||
|
|
@ -85,43 +91,7 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
|
|||
}),
|
||||
);
|
||||
|
||||
containerEl.createEl("h2", { text: "Folders" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Repository content directory")
|
||||
.setDesc(
|
||||
`The repository directory to sync, relative to the repository root.
|
||||
If not set the whole repository will be synced.`,
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Exaple: blog/content")
|
||||
.setValue(this.plugin.settings.repoContentDir)
|
||||
.onChange(async (value) => {
|
||||
// TODO: Change the local path if already fetched
|
||||
this.plugin.settings.repoContentDir = value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Local content directory")
|
||||
.setDesc(
|
||||
`The local directory to sync, relative to the vault root.
|
||||
If not set the whole vault will be synced.`,
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Exaple: folder/blog-posts")
|
||||
.setValue(this.plugin.settings.localContentDir)
|
||||
.onChange(async (value) => {
|
||||
// TODO: Move the folder if already fetched
|
||||
this.plugin.settings.localContentDir = value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
containerEl.createEl("h2", { text: "Sync" });
|
||||
new Setting(containerEl).setName("Sync").setHeading();
|
||||
|
||||
const syncStrategies = {
|
||||
manual: "Manually",
|
||||
|
|
@ -199,16 +169,15 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
const conflictHandlingOptions = {
|
||||
ignore: "Ignore remote file",
|
||||
overwriteLocal: "Overwrite local file",
|
||||
ask: "Ask",
|
||||
overwrite: "Overwrite local file",
|
||||
overwriteRemote: "Overwrite remote file",
|
||||
};
|
||||
new Setting(containerEl)
|
||||
.setName("Conflict handling")
|
||||
.setDesc(
|
||||
`What to do in case remote and local files conflict
|
||||
when downloading from GitHub repository
|
||||
`,
|
||||
when downloading from GitHub repository`,
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
|
|
@ -220,7 +189,7 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("h2", { text: "Interface" });
|
||||
new Setting(containerEl).setName("Interface").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show status bar item")
|
||||
|
|
@ -246,9 +215,123 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
|
|||
if (value) {
|
||||
this.plugin.showSyncRibbonIcon();
|
||||
} 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
99
src/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
157
src/views/conflicts-resolution/diff.ts
Normal file
157
src/views/conflicts-resolution/diff.ts
Normal 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;
|
||||
59
src/views/conflicts-resolution/split-view/action-button.tsx
Normal file
59
src/views/conflicts-resolution/split-view/action-button.tsx
Normal 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>
|
||||
);
|
||||
200
src/views/conflicts-resolution/split-view/actions-gutter.tsx
Normal file
200
src/views/conflicts-resolution/split-view/actions-gutter.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
158
src/views/conflicts-resolution/split-view/diff-view.tsx
Normal file
158
src/views/conflicts-resolution/split-view/diff-view.tsx
Normal 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;
|
||||
95
src/views/conflicts-resolution/split-view/editor-pane.tsx
Normal file
95
src/views/conflicts-resolution/split-view/editor-pane.tsx
Normal 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;
|
||||
133
src/views/conflicts-resolution/split-view/files-tab-bar.tsx
Normal file
133
src/views/conflicts-resolution/split-view/files-tab-bar.tsx
Normal 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;
|
||||
120
src/views/conflicts-resolution/split-view/split-view.tsx
Normal file
120
src/views/conflicts-resolution/split-view/split-view.tsx
Normal 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;
|
||||
|
|
@ -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";
|
||||
}
|
||||
266
src/views/conflicts-resolution/unified-view/decorations.tsx
Normal file
266
src/views/conflicts-resolution/unified-view/decorations.tsx
Normal 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);
|
||||
},
|
||||
);
|
||||
};
|
||||
234
src/views/conflicts-resolution/unified-view/diff-view.tsx
Normal file
234
src/views/conflicts-resolution/unified-view/diff-view.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
117
src/views/conflicts-resolution/unified-view/unified-view.tsx
Normal file
117
src/views/conflicts-resolution/unified-view/unified-view.tsx
Normal 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;
|
||||
91
src/views/conflicts-resolution/view.tsx
Normal file
91
src/views/conflicts-resolution/view.tsx
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -1,616 +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",
|
||||
"folders",
|
||||
"sync",
|
||||
"first_sync",
|
||||
] as const;
|
||||
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
type StepData = {
|
||||
repo: { owner: string; repo: string; branch: string };
|
||||
token: { token: string };
|
||||
folders: { repoFolder: string; vaultFolder: string };
|
||||
sync: {
|
||||
mode: "manual" | "interval";
|
||||
syncOnStart: boolean;
|
||||
syncConfigDir: boolean;
|
||||
onConflict: "ignore" | "ask" | "overwrite";
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_STEP_DATA: StepData = {
|
||||
repo: { owner: "", repo: "", branch: "" },
|
||||
token: { token: "" },
|
||||
folders: { repoFolder: "", vaultFolder: "" },
|
||||
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 FoldersStepComponent = ({
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
values: { repoFolder: string; vaultFolder: string };
|
||||
onChange: (values: { repoFolder: string; vaultFolder: string }) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ textAlign: "center" }}>
|
||||
Optionally you can sync only part of your remote repository or your
|
||||
vault.
|
||||
</p>
|
||||
<p style={{ textAlign: "center" }}>
|
||||
Leave blank to sync the whole repository and the whole vault.
|
||||
</p>
|
||||
<p style={{ textAlign: "center" }}>
|
||||
If you sync only a part of this vault remember you must always reuse the
|
||||
same local path if you want to sync other vaults too.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: "var(--size-4-2)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
spellCheck="false"
|
||||
placeholder="Repository folder"
|
||||
value={values.repoFolder}
|
||||
onChange={(e) => onChange({ ...values, repoFolder: e.target.value })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
spellCheck="false"
|
||||
placeholder="Vault folder"
|
||||
value={values.vaultFolder}
|
||||
onChange={(e) => onChange({ ...values, vaultFolder: e.target.value })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</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,
|
||||
repoContentDir: stepData.folders.repoFolder,
|
||||
localContentDir: stepData.folders.vaultFolder,
|
||||
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">
|
||||
{error ? <div>{error}</div> : null}
|
||||
{success ? <div>Everything is set up and ready to go.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{success ? (
|
||||
<button
|
||||
style={{
|
||||
color: "var(--text-on-accent)",
|
||||
backgroundColor: "var(--interactive-accent)",
|
||||
}}
|
||||
onClick={async () => await onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
) : 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 "folders":
|
||||
return (
|
||||
<FoldersStepComponent
|
||||
values={stepData.folders}
|
||||
onChange={(data) => updateStepData("folders", 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%",
|
||||
}}
|
||||
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;
|
||||
|
|
@ -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
9
styles.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.hidden-navigation-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.padless-conflicts-view-container {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
{
|
||||
"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.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"
|
||||
}
|
||||
Loading…
Reference in a new issue