mirror of
https://github.com/silvanocerza/github-gitless-sync.git
synced 2026-07-22 12:10:28 +00:00
Handle conflicts resolution end to end
This commit is contained in:
parent
4745f91bdc
commit
2d8ee9f7bd
3 changed files with 93 additions and 118 deletions
22
src/main.ts
22
src/main.ts
|
|
@ -1,8 +1,13 @@
|
|||
import { EventRef, Plugin, Platform, WorkspaceLeaf } from "obsidian";
|
||||
import {
|
||||
EventRef,
|
||||
Plugin,
|
||||
Platform,
|
||||
WorkspaceLeaf,
|
||||
normalizePath,
|
||||
} from "obsidian";
|
||||
import { GitHubSyncSettings, DEFAULT_SETTINGS } from "./settings/settings";
|
||||
import GitHubSyncSettingsTab from "./settings/tab";
|
||||
import SyncManager, { ConflictFile, ConflictResolution } from "./sync-manager";
|
||||
import { FileMetadata } from "./metadata-store";
|
||||
import { OnboardingDialog } from "./views/onboarding/view";
|
||||
import Logger from "./logger";
|
||||
import {
|
||||
|
|
@ -29,6 +34,12 @@ export default class GitHubSyncPlugin extends Plugin {
|
|||
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
|
||||
|
|
@ -72,11 +83,11 @@ export default class GitHubSyncPlugin extends Plugin {
|
|||
|
||||
this.registerView(
|
||||
CONFLICTS_RESOLUTION_VIEW_TYPE,
|
||||
(leaf) => new ConflictsResolutionView(leaf, this),
|
||||
(leaf) => new ConflictsResolutionView(leaf, this, this.conflicts),
|
||||
);
|
||||
this.addRibbonIcon("dice", "Activate view", async () => {
|
||||
this.addRibbonIcon("merge", "Open sync conflicts resolution", async () => {
|
||||
await this.activateView();
|
||||
this.getConflictsView()?.setConflictFiles([]);
|
||||
this.getConflictsView()?.setConflictFiles(this.conflicts);
|
||||
});
|
||||
this.logger = new Logger(this.app.vault, this.settings.enableLogging);
|
||||
|
||||
|
|
@ -199,6 +210,7 @@ export default class GitHubSyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async onConflicts(conflicts: ConflictFile[]): Promise<ConflictResolution[]> {
|
||||
this.conflicts = conflicts;
|
||||
return await new Promise(async (resolve) => {
|
||||
this.conflictsResolver = resolve;
|
||||
await this.activateView();
|
||||
|
|
|
|||
|
|
@ -302,39 +302,34 @@ export default class SyncManager {
|
|||
const remoteMetadata: Metadata = JSON.parse(atob(blob.content));
|
||||
|
||||
const conflicts = await this.findConflicts(remoteMetadata.files);
|
||||
let conflictResolutions: SyncAction[] = [];
|
||||
if (conflicts.length > 0) {
|
||||
// TODO: Show conflicts to the user with a callback
|
||||
// Wait for response
|
||||
// Create new action to handle the conflict
|
||||
// Add the new action to the list later on
|
||||
console.log("Conflicts");
|
||||
console.log(conflicts);
|
||||
await this.logger.warn("Found conflicts", conflicts);
|
||||
|
||||
(await this.onConflicts(conflicts)).forEach(
|
||||
(resolution: boolean, index: number) => {
|
||||
if (resolution) {
|
||||
conflictResolutions.push({
|
||||
type: "download",
|
||||
filePath: conflicts[index].remoteFile.path,
|
||||
});
|
||||
} else {
|
||||
conflictResolutions.push({
|
||||
type: "upload",
|
||||
filePath: conflicts[index].localFile.path,
|
||||
});
|
||||
}
|
||||
// We treat every resolved conflict as an upload SyncAction, mainly cause
|
||||
// the user has complete freedom on the edits they can apply to the conflicting files.
|
||||
// So when a conflict is resolved we change the file locally and upload it.
|
||||
// That solves the conflict.
|
||||
let conflictActions: SyncAction[] = [];
|
||||
// We keep track of the conflict resolutions cause we want to update the file
|
||||
// locally only when we're sure the sync was successul. That happens after we
|
||||
// commit the sync.
|
||||
let conflictResolutions: ConflictResolution[] = [];
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
await this.logger.warn("Found conflicts", conflicts);
|
||||
// Here we block the sync process until the user has resolved all the conflicts
|
||||
conflictResolutions = await this.onConflicts(conflicts);
|
||||
conflictActions = conflictResolutions.map(
|
||||
(resolution: ConflictResolution) => {
|
||||
return { type: "upload", filePath: resolution.filename };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const actions = [
|
||||
const actions: SyncAction[] = [
|
||||
...this.determineSyncActions(
|
||||
remoteMetadata.files,
|
||||
this.metadataStore.data.files,
|
||||
),
|
||||
...conflictResolutions,
|
||||
...conflictActions,
|
||||
];
|
||||
|
||||
if (actions.length === 0) {
|
||||
|
|
@ -366,7 +361,15 @@ export default class SyncManager {
|
|||
switch (action.type) {
|
||||
case "upload": {
|
||||
const normalizedPath = normalizePath(action.filePath);
|
||||
const content = await this.vault.adapter.read(normalizedPath);
|
||||
const resolution = conflictResolutions.find(
|
||||
(c: ConflictResolution) => c.filename === action.filePath,
|
||||
);
|
||||
// If the file was conflicting we need to read the content from the
|
||||
// conflict resolution instead of reading it from file since at this point
|
||||
// we still have not updated the local file.
|
||||
const content =
|
||||
resolution?.content ||
|
||||
(await this.vault.adapter.read(normalizedPath));
|
||||
newTreeFiles[action.filePath] = {
|
||||
path: action.filePath,
|
||||
mode: "100644",
|
||||
|
|
@ -578,15 +581,31 @@ export default class SyncManager {
|
|||
*
|
||||
* @param treeFiles Updated list of files in the remote tree
|
||||
* @param baseTreeSha sha of the tree to use as base for the new tree
|
||||
* @param conflictResolutions list of conflicts between remote and local files
|
||||
*/
|
||||
async commitSync(
|
||||
treeFiles: { [key: string]: NewTreeRequestItem },
|
||||
baseTreeSha: string,
|
||||
conflictResolutions: ConflictResolution[] = [],
|
||||
) {
|
||||
// Update local sync time
|
||||
this.metadataStore.data.lastSync = Date.now();
|
||||
const syncTime = Date.now();
|
||||
this.metadataStore.data.lastSync = syncTime;
|
||||
this.metadataStore.save();
|
||||
|
||||
// We update the last modified timestamp for all files that had resolved conflicts
|
||||
// to the the same time as the sync time.
|
||||
// At this time we still have not written the conflict resolution content to file,
|
||||
// so the last modified timestamp doesn't reflect that.
|
||||
// To prevent further conflicts in future syncs and to reflect the content change
|
||||
// on the remote metadata we update the timestamp for the conflicting files here,
|
||||
// just before pushing to remote.
|
||||
// We're going to update the local content when the sync is successful.
|
||||
conflictResolutions.forEach((resolution) => {
|
||||
this.metadataStore.data.files[resolution.filename].lastModified =
|
||||
syncTime;
|
||||
});
|
||||
|
||||
// Update manifest in list of new tree items
|
||||
delete treeFiles[`${this.vault.configDir}/${MANIFEST_FILE_NAME}`].sha;
|
||||
treeFiles[`${this.vault.configDir}/${MANIFEST_FILE_NAME}`].content =
|
||||
|
|
@ -611,6 +630,23 @@ export default class SyncManager {
|
|||
);
|
||||
|
||||
await this.client.updateBranchHead(commitSha);
|
||||
|
||||
// Update the local content of all files that had conflicts we resolved
|
||||
await Promise.all(
|
||||
conflictResolutions.map(async (resolution) => {
|
||||
await this.vault.adapter.write(resolution.filename, resolution.content);
|
||||
// Even though we set the last modified timestamp for all files with conflicts
|
||||
// just before pushing the changes to remote we do it here again because the
|
||||
// write right above would overwrite that.
|
||||
// Since we want to keep the sync timestamp for this file to avoid future conflicts
|
||||
// we update it again.
|
||||
this.metadataStore.data.files[resolution.filename].lastModified =
|
||||
syncTime;
|
||||
}),
|
||||
);
|
||||
// Now that the sync is done and we updated the content for conflicting files
|
||||
// we can save the latest metadata to disk.
|
||||
this.metadataStore.save();
|
||||
await this.logger.info("Sync done");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,78 +8,15 @@ import { ConflictFile, ConflictResolution } from "src/sync-manager";
|
|||
|
||||
export const CONFLICTS_RESOLUTION_VIEW_TYPE = "conflicts-resolution-view";
|
||||
|
||||
// Test Case 1: Simple line changes
|
||||
let oldText1 = `# My Document
|
||||
This is a test
|
||||
Some content here
|
||||
Some line
|
||||
Another line
|
||||
Final line`;
|
||||
|
||||
let newText1 = `# My Document
|
||||
This is a modified test
|
||||
Some new content here
|
||||
Some line
|
||||
Final line`;
|
||||
|
||||
// Test Case 2: Markdown with formatting
|
||||
let oldText2 = `# Title
|
||||
## Subtitle
|
||||
- List item 1
|
||||
- List item 2
|
||||
|
||||
**Bold text** and *italic* text
|
||||
Regular paragraph`;
|
||||
|
||||
let newText2 = `# Modified Title
|
||||
## Subtitle
|
||||
- List item 1
|
||||
- List item 2
|
||||
- New item
|
||||
|
||||
**Bold text** and *italic* text
|
||||
Modified paragraph
|
||||
New paragraph`;
|
||||
|
||||
let oldText3 = `# My Document
|
||||
This is a modified test
|
||||
Some new content here
|
||||
|
||||
This is a test
|
||||
Some content here
|
||||
Some line
|
||||
Another line
|
||||
Final line
|
||||
|
||||
|
||||
asdfasdf`;
|
||||
|
||||
let newText3 = `# My Document
|
||||
This is a modified test
|
||||
Some new content here
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
This is a test
|
||||
Some content here
|
||||
Some line
|
||||
Another line
|
||||
Final line
|
||||
|
||||
asdfasdf`;
|
||||
|
||||
export class ConflictsResolutionView extends ItemView {
|
||||
icon: IconName = "merge";
|
||||
|
||||
constructor(
|
||||
leaf: WorkspaceLeaf,
|
||||
private plugin: GitHubSyncPlugin,
|
||||
private conflicts: ConflictFile[],
|
||||
) {
|
||||
super(leaf);
|
||||
console.log(`Created ${CONFLICTS_RESOLUTION_VIEW_TYPE}`);
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
|
|
@ -90,7 +27,7 @@ export class ConflictsResolutionView extends ItemView {
|
|||
return "Conflicts Resolution";
|
||||
}
|
||||
|
||||
onResolve(resolutions: ConflictResolution[]) {
|
||||
private resolveAllConflicts(resolutions: ConflictResolution[]) {
|
||||
if (this.plugin.conflictsResolver) {
|
||||
this.plugin.conflictsResolver(resolutions);
|
||||
this.plugin.conflictsResolver = null;
|
||||
|
|
@ -98,23 +35,12 @@ export class ConflictsResolutionView extends ItemView {
|
|||
}
|
||||
|
||||
setConflictFiles(conflicts: ConflictFile[]) {
|
||||
const mockFiles: ConflictFile[] = [
|
||||
{ filename: "this", remoteContent: oldText1, localContent: newText1 },
|
||||
{ filename: "that", remoteContent: oldText2, localContent: newText2 },
|
||||
{ filename: "those", remoteContent: oldText3, localContent: newText3 },
|
||||
];
|
||||
this.render(mockFiles);
|
||||
this.conflicts = conflicts;
|
||||
this.render(conflicts);
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
console.log(`Opened ${CONFLICTS_RESOLUTION_VIEW_TYPE}`);
|
||||
|
||||
// const mockFiles: ConflictFile[] = [
|
||||
// { filename: "this", remoteContent: oldText1, localContent: newText1 },
|
||||
// { filename: "that", remoteContent: oldText2, localContent: newText2 },
|
||||
// { filename: "those", remoteContent: oldText3, localContent: newText3 },
|
||||
// ];
|
||||
// this.render(mockFiles);
|
||||
this.render(this.conflicts);
|
||||
}
|
||||
|
||||
private render(conflicts: ConflictFile[]) {
|
||||
|
|
@ -133,24 +59,26 @@ export class ConflictsResolutionView extends ItemView {
|
|||
|
||||
const onConflictResolved = () => {
|
||||
// Remove the file from the conflicts to resolve
|
||||
setFiles(files.filter((_, index) => index !== currentFileIndex));
|
||||
const remainingFiles = files.filter(
|
||||
(_, index) => index !== currentFileIndex,
|
||||
);
|
||||
setFiles(remainingFiles);
|
||||
// Keep track of the resolved conflicts
|
||||
setResolvedConflicts([
|
||||
const newResolvedConflicts = [
|
||||
...resolvedConflicts,
|
||||
{
|
||||
filename: currentFile!.filename,
|
||||
// We could get either the local or remote content at this point since
|
||||
// they're identical
|
||||
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 (files.length === 0) {
|
||||
if (remainingFiles.length === 0) {
|
||||
// We solved all conflicts, we can resume syncing
|
||||
this.onResolve(resolvedConflicts);
|
||||
this.resolveAllConflicts(newResolvedConflicts);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -226,6 +154,5 @@ export class ConflictsResolutionView extends ItemView {
|
|||
|
||||
async onClose() {
|
||||
// Nothing to clean up.
|
||||
console.log(`Closed ${CONFLICTS_RESOLUTION_VIEW_TYPE}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue