mirror of
https://github.com/friebetill/obsidian-file-diff.git
synced 2026-07-22 07:40:25 +00:00
Compare commits
No commits in common. "master" and "1.0.1" have entirely different histories.
22 changed files with 891 additions and 1409 deletions
|
|
@ -22,9 +22,7 @@
|
|||
"rules": {
|
||||
"class-methods-use-this": "off",
|
||||
"no-use-before-define": "off",
|
||||
"no-restricted-syntax": "off",
|
||||
"no-warning-comments": 1,
|
||||
"semi": [2, "always"],
|
||||
"@typescript-eslint/no-use-before-define": ["error"],
|
||||
"react/jsx-filename-extension": [
|
||||
"warn",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Obsidian File Diff
|
||||
|
||||
[](obsidian://show-plugin?id=file-diff)
|
||||
<!-- [](obsidian://show-plugin?id=file-diff) -->
|
||||

|
||||
|
||||
## Commands
|
||||
|
|
@ -9,28 +9,28 @@
|
|||
|
||||
`Compare and merge`: Same as `Compare`, but you have the option to merge changes.
|
||||
|
||||
`Find sync conflicts and merge`: Finds all Syncthing conflicts and shows the merge option for each conflict sequentially.
|
||||
|
||||
<img
|
||||
src="https://user-images.githubusercontent.com/10923085/216749496-27f0b241-c05b-4aec-ba88-a7c8c91938a6.gif"
|
||||
alt="GIF of a demo show this plugin" width="900" />
|
||||
|
||||
## Motivation
|
||||
## Compatibility
|
||||
|
||||
I created this plugin because I use [SyncThing](https://syncthing.net/) and it bothered me to clean up merge conflicts by hand.
|
||||
Compatible with all platforms (Windows, Linux, macOS, Android and iOS). TODO:
|
||||
Test on Android and iOS. Tested with Obsidian v0.15.0 or higher.
|
||||
|
||||
## Installation
|
||||
|
||||
At the moment, the [plugin is in review to be an official plugin](https://github.com/obsidianmd/obsidian-releases/pull/1621) (add a like to get it merged faster). Therefore, it must be installed manually:
|
||||
|
||||
1. Download the `main.js`, `styles.css` and `manifest.json` from [the latest release](https://github.com/friebetill/obsidian-file-diff/releases/latest)
|
||||
2. Move the files to the plugin folder `VaultFolder/.obsidian/plugins/obsidian-file-diff/`
|
||||
|
||||
When the plugin is officially release you can follow these steps to install the plugin:
|
||||
|
||||
1. Search for "File Diff" in the community plugins of Obsidian
|
||||
2. Install the plugin
|
||||
3. Enable the plugin
|
||||
|
||||
## FAQ
|
||||
|
||||
1. How do I adjust the colors?
|
||||
|
||||
Adjust the colors by following [these instructions](https://github.com/friebetill/obsidian-file-diff/issues/1#issuecomment-1425157959).
|
||||
|
||||
## Contributing
|
||||
|
||||
If you have any issues or suggestions, please open an issue on the repository.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "file-diff",
|
||||
"name": "File Diff",
|
||||
"version": "1.1.2",
|
||||
"version": "1.0.1",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Shows the differences between two files..",
|
||||
"author": "Till Friebe",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-file-diff",
|
||||
"version": "1.1.2",
|
||||
"version": "1.0.1",
|
||||
"description": "Shows the differences between two files.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,88 +1,85 @@
|
|||
import { TFile } from 'obsidian';
|
||||
import { Difference } from '../data/difference';
|
||||
import { deleteLines, insertLine, replaceLine } from '../utils/string_utils';
|
||||
import { ActionLineButton } from './action_line_button';
|
||||
import { ActionLineDivider } from './action_line_divider';
|
||||
import { TFile } from 'obsidian'
|
||||
import { Difference } from '../data/difference'
|
||||
import { deleteLines, replaceLine } from '../utils/string_utils'
|
||||
import { ActionLineButton } from './action_line_button'
|
||||
import { ActionLineDivider } from './action_line_divider'
|
||||
|
||||
type VoidCallback = () => void;
|
||||
type VoidCallback = () => void
|
||||
|
||||
export class ActionLine {
|
||||
constructor(args: {
|
||||
difference: Difference;
|
||||
file1: TFile;
|
||||
file2: TFile;
|
||||
file1Content: string;
|
||||
file2Content: string;
|
||||
triggerRebuild: VoidCallback;
|
||||
difference: Difference
|
||||
file1: TFile
|
||||
file2: TFile
|
||||
file1Content: string
|
||||
file2Content: string
|
||||
triggerRebuild: VoidCallback
|
||||
}) {
|
||||
this.difference = args.difference;
|
||||
this.file1 = args.file1;
|
||||
this.file2 = args.file2;
|
||||
this.file1Content = args.file1Content;
|
||||
this.file2Content = args.file2Content;
|
||||
this.triggerRebuild = args.triggerRebuild;
|
||||
this.difference = args.difference
|
||||
this.file1 = args.file1
|
||||
this.file2 = args.file2
|
||||
this.file1Content = args.file1Content
|
||||
this.file2Content = args.file2Content
|
||||
this.triggerRebuild = args.triggerRebuild
|
||||
}
|
||||
|
||||
private difference: Difference;
|
||||
private difference: Difference
|
||||
|
||||
private file1: TFile;
|
||||
private file1: TFile
|
||||
|
||||
private file2: TFile;
|
||||
private file2: TFile
|
||||
|
||||
private file1Content: string;
|
||||
private file1Content: string
|
||||
|
||||
private file2Content: string;
|
||||
private file2Content: string
|
||||
|
||||
private triggerRebuild: VoidCallback;
|
||||
private triggerRebuild: VoidCallback
|
||||
|
||||
build(container: HTMLDivElement): void {
|
||||
const actionLine = container.createDiv({
|
||||
cls: 'flex flex-row gap-1 py-0-5',
|
||||
});
|
||||
const actionLine = container.createDiv({ cls: 'flex-row gap-2 py-2' })
|
||||
|
||||
const hasMinusLines = this.difference.file1Lines.length > 0;
|
||||
const hasPlusLines = this.difference.file2Lines.length > 0;
|
||||
const hasPlusLines = this.difference.lines.some((l) =>
|
||||
l.startsWith('+')
|
||||
)
|
||||
const hasMinusLines = this.difference.lines.some((l) =>
|
||||
l.startsWith('-')
|
||||
)
|
||||
|
||||
if (hasPlusLines && hasMinusLines) {
|
||||
new ActionLineButton({
|
||||
text: 'Accept Top',
|
||||
onClick: (e) => this.acceptTopClick(e, this.difference),
|
||||
}).build(actionLine);
|
||||
ActionLineDivider.build(actionLine);
|
||||
}).build(actionLine)
|
||||
ActionLineDivider.build(actionLine)
|
||||
new ActionLineButton({
|
||||
text: 'Accept Bottom',
|
||||
onClick: (e) => this.acceptBottomClick(e, this.difference),
|
||||
}).build(actionLine);
|
||||
ActionLineDivider.build(actionLine);
|
||||
}).build(actionLine)
|
||||
ActionLineDivider.build(actionLine)
|
||||
new ActionLineButton({
|
||||
text: 'Accept All',
|
||||
onClick: (e) => this.acceptAllClick(e, this.difference),
|
||||
}).build(actionLine);
|
||||
ActionLineDivider.build(actionLine);
|
||||
new ActionLineButton({
|
||||
text: 'Accept None',
|
||||
onClick: (e) => this.acceptNoneClick(e, this.difference),
|
||||
}).build(actionLine);
|
||||
}).build(actionLine)
|
||||
} else if (hasMinusLines) {
|
||||
new ActionLineButton({
|
||||
text: `Accept from ${this.file1.name}`,
|
||||
onClick: (e) => this.insertFile1Difference(e, this.difference),
|
||||
}).build(actionLine);
|
||||
ActionLineDivider.build(actionLine);
|
||||
onClick: (e) => this.acceptTopClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
ActionLineDivider.build(actionLine)
|
||||
new ActionLineButton({
|
||||
text: 'Discard',
|
||||
onClick: (e) => this.discardFile1Difference(e, this.difference),
|
||||
}).build(actionLine);
|
||||
}).build(actionLine)
|
||||
} else if (hasPlusLines) {
|
||||
new ActionLineButton({
|
||||
text: `Accept from ${this.file2.name}`,
|
||||
onClick: (e) => this.insertFile2Difference(e, this.difference),
|
||||
}).build(actionLine);
|
||||
ActionLineDivider.build(actionLine);
|
||||
onClick: (e) => this.acceptBottomClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
ActionLineDivider.build(actionLine)
|
||||
new ActionLineButton({
|
||||
text: 'Discard',
|
||||
onClick: (e) => this.discardFile2Difference(e, this.difference),
|
||||
}).build(actionLine);
|
||||
}).build(actionLine)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,154 +87,99 @@ export class ActionLine {
|
|||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
|
||||
const changedLines = difference.file1Lines.join('\n');
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith('-'))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join('\n')
|
||||
const newContent = replaceLine({
|
||||
fullText: this.file2Content,
|
||||
newLine: changedLines,
|
||||
position: difference.file2Start,
|
||||
linesToReplace: difference.file2Lines.length,
|
||||
});
|
||||
await app.vault.modify(this.file2, newContent);
|
||||
})
|
||||
await app.vault.modify(this.file2, newContent)
|
||||
|
||||
this.triggerRebuild();
|
||||
this.triggerRebuild()
|
||||
}
|
||||
|
||||
private async acceptBottomClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
|
||||
const changedLines = difference.file2Lines.join('\n');
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith('+'))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join('\n')
|
||||
const newContent = replaceLine({
|
||||
fullText: this.file1Content,
|
||||
newLine: changedLines,
|
||||
position: difference.file1Start,
|
||||
linesToReplace: difference.file1Lines.length,
|
||||
});
|
||||
await app.vault.modify(this.file1, newContent);
|
||||
})
|
||||
await app.vault.modify(this.file1, newContent)
|
||||
|
||||
this.triggerRebuild();
|
||||
this.triggerRebuild()
|
||||
}
|
||||
|
||||
private async acceptAllClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
|
||||
const changedLines = [
|
||||
...difference.file1Lines,
|
||||
...difference.file2Lines,
|
||||
].join('\n');
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith('-') || line.startsWith('+'))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join('\n')
|
||||
|
||||
const newFile1Content = replaceLine({
|
||||
fullText: this.file1Content,
|
||||
newLine: changedLines,
|
||||
position: difference.file1Start,
|
||||
linesToReplace: difference.file1Lines.length,
|
||||
});
|
||||
await app.vault.modify(this.file1, newFile1Content);
|
||||
})
|
||||
await app.vault.modify(this.file1, newFile1Content)
|
||||
|
||||
const newFile2Content = replaceLine({
|
||||
fullText: this.file2Content,
|
||||
newLine: changedLines,
|
||||
position: difference.file2Start,
|
||||
linesToReplace: difference.file2Lines.length,
|
||||
});
|
||||
await app.vault.modify(this.file2, newFile2Content);
|
||||
})
|
||||
await app.vault.modify(this.file2, newFile2Content)
|
||||
|
||||
this.triggerRebuild();
|
||||
}
|
||||
|
||||
private async acceptNoneClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
const newFile1Content = deleteLines({
|
||||
fullText: this.file1Content,
|
||||
position: difference.file1Start,
|
||||
count: difference.file1Lines.length,
|
||||
});
|
||||
await app.vault.modify(this.file1, newFile1Content);
|
||||
|
||||
const newFile2Content = deleteLines({
|
||||
fullText: this.file2Content,
|
||||
position: difference.file2Start,
|
||||
count: difference.file2Lines.length,
|
||||
});
|
||||
await app.vault.modify(this.file2, newFile2Content);
|
||||
|
||||
this.triggerRebuild();
|
||||
}
|
||||
|
||||
private async insertFile1Difference(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
const changedLines = difference.file1Lines.join('\n');
|
||||
const newContent = insertLine({
|
||||
fullText: this.file2Content,
|
||||
newLine: changedLines,
|
||||
position: difference.file2Start,
|
||||
});
|
||||
await app.vault.modify(this.file2, newContent);
|
||||
|
||||
this.triggerRebuild();
|
||||
}
|
||||
|
||||
private async insertFile2Difference(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
const changedLines = difference.file2Lines.join('\n');
|
||||
const newContent = insertLine({
|
||||
fullText: this.file1Content,
|
||||
newLine: changedLines,
|
||||
position: difference.file1Start,
|
||||
});
|
||||
await app.vault.modify(this.file1, newContent);
|
||||
|
||||
this.triggerRebuild();
|
||||
this.triggerRebuild()
|
||||
}
|
||||
|
||||
async discardFile1Difference(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
|
||||
const newContent = deleteLines({
|
||||
fullText: this.file1Content,
|
||||
position: difference.file1Start,
|
||||
count: difference.file1Lines.length,
|
||||
});
|
||||
await app.vault.modify(this.file1, newContent);
|
||||
count: difference.lines.length,
|
||||
})
|
||||
await app.vault.modify(this.file1, newContent)
|
||||
|
||||
this.triggerRebuild();
|
||||
this.triggerRebuild()
|
||||
}
|
||||
|
||||
async discardFile2Difference(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
|
||||
const newContent = deleteLines({
|
||||
fullText: this.file2Content,
|
||||
position: difference.file2Start,
|
||||
count: difference.file2Lines.length,
|
||||
});
|
||||
await app.vault.modify(this.file2, newContent);
|
||||
count: difference.lines.length,
|
||||
})
|
||||
await app.vault.modify(this.file2, newContent)
|
||||
|
||||
this.triggerRebuild();
|
||||
this.triggerRebuild()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
export class ActionLineButton {
|
||||
constructor(args: { text: string; onClick: (e: MouseEvent) => void }) {
|
||||
this.text = args.text;
|
||||
this.onClick = args.onClick;
|
||||
this.text = args.text
|
||||
this.onClick = args.onClick
|
||||
}
|
||||
|
||||
public text: string;
|
||||
public text: string
|
||||
|
||||
public onClick: (e: MouseEvent) => void;
|
||||
public onClick: (e: MouseEvent) => void
|
||||
|
||||
build(actionLine: HTMLDivElement): void {
|
||||
actionLine
|
||||
.createEl('a', {
|
||||
text: this.text,
|
||||
cls: 'no-decoration text-xxs file-diff__action-line',
|
||||
cls: 'no-decoration text-xxs text-gray',
|
||||
})
|
||||
.onClickEvent(this.onClick);
|
||||
.onClickEvent(this.onClick)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
export class ActionLineDivider {
|
||||
static build(actionLine: HTMLDivElement): void {
|
||||
actionLine.createEl('span', {
|
||||
text: '|',
|
||||
cls: 'text-xxs file-diff__action-line',
|
||||
});
|
||||
actionLine.createEl('span', { text: '|', cls: 'text-xxs text-gray' })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,290 +1,166 @@
|
|||
import { structuredPatch, diffWords } from 'diff';
|
||||
import { ItemView, TFile, ViewStateResult, WorkspaceLeaf } from 'obsidian';
|
||||
import { Difference } from '../data/difference';
|
||||
import { FileDifferences } from '../data/file_differences';
|
||||
import { preventEmptyString } from '../utils/string_utils';
|
||||
import { ActionLine } from './action_line';
|
||||
import { DeleteFileModal } from './modals/delete_file_modal';
|
||||
import { structuredPatch } from 'diff'
|
||||
import { ItemView, TFile, WorkspaceLeaf } from 'obsidian'
|
||||
import { delay } from 'src/utils/delay'
|
||||
import { Difference } from '../data/difference'
|
||||
import { FileDifferences } from '../data/file_differences'
|
||||
import { preventEmptyString } from '../utils/string_utils'
|
||||
import { ActionLine } from './action_line'
|
||||
import { DeleteFileModal } from './modals/delete_file_modal'
|
||||
|
||||
export const VIEW_TYPE_DIFFERENCES = 'differences-view';
|
||||
|
||||
export interface ViewState {
|
||||
file1: TFile;
|
||||
file2: TFile;
|
||||
showMergeOption: boolean;
|
||||
continueCallback?: (shouldContinue: boolean) => Promise<void>;
|
||||
}
|
||||
export const VIEW_TYPE_PATCH = 'patch-view'
|
||||
|
||||
export class DifferencesView extends ItemView {
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on('modify', async (file) => {
|
||||
if (file !== this.state.file1 && file !== this.state.file2) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.updateState();
|
||||
this.build();
|
||||
})
|
||||
);
|
||||
constructor(args: {
|
||||
leaf: WorkspaceLeaf
|
||||
file1: TFile
|
||||
file2: TFile
|
||||
showMergeOption: boolean
|
||||
}) {
|
||||
super(args.leaf)
|
||||
this.file1 = args.file1
|
||||
this.file2 = args.file2
|
||||
this.showMergeOption = args.showMergeOption
|
||||
}
|
||||
|
||||
private state: ViewState;
|
||||
private file1: TFile
|
||||
|
||||
private file1Content: string;
|
||||
private file2: TFile
|
||||
|
||||
private file2Content: string;
|
||||
private file1Content: string
|
||||
|
||||
private fileDifferences: FileDifferences;
|
||||
private file2Content: string
|
||||
|
||||
private file1Lines: string[];
|
||||
private showMergeOption: boolean
|
||||
|
||||
private file2Lines: string[];
|
||||
private fileDifferences: FileDifferences
|
||||
|
||||
private wasDeleteModalShown = false;
|
||||
private file1Lines: string[]
|
||||
|
||||
override getViewType(): string {
|
||||
return VIEW_TYPE_DIFFERENCES;
|
||||
private lineCount: number
|
||||
|
||||
private wasDeleteModalShown = false
|
||||
|
||||
getViewType(): string {
|
||||
return VIEW_TYPE_PATCH
|
||||
}
|
||||
|
||||
override getDisplayText(): string {
|
||||
if (this.state?.file1 && this.state?.file2) {
|
||||
return (
|
||||
`File Diff: ${this.state.file1.name} ` +
|
||||
`and ${this.state.file2.name}`
|
||||
);
|
||||
}
|
||||
return `File Diff`;
|
||||
getDisplayText(): string {
|
||||
return `Difference between ${this.file1.name} and ${this.file2.name}`
|
||||
}
|
||||
|
||||
override async setState(
|
||||
state: ViewState,
|
||||
result: ViewStateResult
|
||||
): Promise<void> {
|
||||
super.setState(state, result);
|
||||
this.state = state;
|
||||
|
||||
await this.updateState();
|
||||
this.build();
|
||||
}
|
||||
|
||||
async onunload(): Promise<void> {
|
||||
this.state.continueCallback?.(false);
|
||||
async onOpen(): Promise<void> {
|
||||
await this.updateState()
|
||||
this.build()
|
||||
}
|
||||
|
||||
private async updateState(): Promise<void> {
|
||||
if (this.state.file1 == null || this.state.file2 == null) {
|
||||
return;
|
||||
}
|
||||
// TODO(tillf): Find way to refresh state when one of the files changes
|
||||
|
||||
this.file1Content = await this.app.vault.cachedRead(this.state.file1);
|
||||
this.file2Content = await this.app.vault.cachedRead(this.state.file2);
|
||||
this.file1Content = await this.app.vault.read(this.file1)
|
||||
this.file1Lines = this.file1Content.split('\n')
|
||||
|
||||
this.file1Lines = this.file1Content
|
||||
// Add trailing new line as this removes edge cases
|
||||
.concat('\n')
|
||||
.split('\n')
|
||||
// Streamline empty spaces at the end as this remove edge cases
|
||||
.map((line) => line.trimEnd());
|
||||
|
||||
this.file2Lines = this.file2Content
|
||||
// Add trailing new spaces as this removes edge cases
|
||||
.concat('\n')
|
||||
.split('\n')
|
||||
// Streamline empty lines at the end as this remove edge cases
|
||||
.map((line) => line.trimEnd());
|
||||
|
||||
const parsedDiff = structuredPatch(
|
||||
this.state.file1.path,
|
||||
this.state.file2.path,
|
||||
this.file1Lines.join('\n'),
|
||||
this.file2Lines.join('\n')
|
||||
);
|
||||
this.fileDifferences = FileDifferences.fromParsedDiff(parsedDiff);
|
||||
this.file2Content = await this.app.vault.read(this.file2)
|
||||
this.fileDifferences = FileDifferences.fromParsedDiff(
|
||||
structuredPatch(
|
||||
this.file1.path,
|
||||
this.file2.path,
|
||||
// Streamline empty lines at the end as this remove edge cases
|
||||
this.file1Content.trimEnd().concat('\n'),
|
||||
this.file2Content.trimEnd().concat('\n')
|
||||
)
|
||||
)
|
||||
|
||||
// Find the highest line number we need to go through. This can be the
|
||||
// highest number in the differences, because the second file can have
|
||||
// more lines than the first file.
|
||||
this.lineCount = Math.max(
|
||||
this.file1Lines.length,
|
||||
...this.fileDifferences.differences.map((d) => d.file1Start)
|
||||
)
|
||||
}
|
||||
|
||||
private build(): void {
|
||||
this.contentEl.empty();
|
||||
this.contentEl.empty()
|
||||
|
||||
const container = this.contentEl.createDiv({
|
||||
cls: 'file-diff__container',
|
||||
});
|
||||
const container = this.contentEl.createDiv({ cls: 'container' })
|
||||
|
||||
this.buildLines(container);
|
||||
|
||||
this.scrollToFirstDifference();
|
||||
if (
|
||||
this.fileDifferences.differences.length === 0 &&
|
||||
this.state.showMergeOption &&
|
||||
!this.wasDeleteModalShown
|
||||
) {
|
||||
this.wasDeleteModalShown = true;
|
||||
this.showDeleteModal();
|
||||
}
|
||||
}
|
||||
|
||||
private buildLines(container: HTMLDivElement): void {
|
||||
let lineCount1 = 0;
|
||||
let lineCount2 = 0;
|
||||
const maxLineCount = Math.max(this.file1Lines.length, this.file2Lines.length)
|
||||
while (lineCount1 <= maxLineCount || lineCount2 <= maxLineCount) {
|
||||
for (let i = 0; i <= this.lineCount; i += 1) {
|
||||
const line = i in this.file1Lines ? this.file1Lines[i] : null
|
||||
const difference = this.fileDifferences.differences.find(
|
||||
// eslint-disable-next-line no-loop-func
|
||||
(d) =>
|
||||
d.file1Start === lineCount1 && d.file2Start === lineCount2
|
||||
);
|
||||
(d) => d.file1Start === i
|
||||
)
|
||||
|
||||
if (difference != null) {
|
||||
const differenceContainer = container.createDiv({
|
||||
cls: 'difference',
|
||||
});
|
||||
this.buildDifferenceVisualizer(differenceContainer, difference);
|
||||
lineCount1 += difference.file1Lines.length;
|
||||
lineCount2 += difference.file2Lines.length;
|
||||
} else {
|
||||
const line =
|
||||
lineCount1 <= lineCount2
|
||||
? this.file1Lines[lineCount1]
|
||||
: this.file2Lines[lineCount2];
|
||||
this.buildDifferenceVisualizer(container, difference)
|
||||
}
|
||||
if (
|
||||
line != null &&
|
||||
(difference == null || !difference.hasChangesFromFile1())
|
||||
) {
|
||||
container.createDiv({
|
||||
// Necessary to give the line a height when it's empty.
|
||||
text: preventEmptyString(line),
|
||||
cls: 'file-diff__line',
|
||||
});
|
||||
lineCount1 += 1;
|
||||
lineCount2 += 1;
|
||||
cls: 'line',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.fileDifferences.differences.length === 0 &&
|
||||
this.showMergeOption &&
|
||||
!this.wasDeleteModalShown
|
||||
) {
|
||||
this.showDeleteModal()
|
||||
}
|
||||
}
|
||||
|
||||
private buildDifferenceVisualizer(
|
||||
container: HTMLDivElement,
|
||||
difference: Difference
|
||||
): void {
|
||||
if (this.state.showMergeOption) {
|
||||
const triggerRebuild = async (): Promise<void> => {
|
||||
await this.updateState()
|
||||
this.build()
|
||||
}
|
||||
|
||||
if (this.showMergeOption) {
|
||||
new ActionLine({
|
||||
difference,
|
||||
file1: this.state.file1,
|
||||
file2: this.state.file2,
|
||||
file1: this.file1,
|
||||
file2: this.file2,
|
||||
file1Content: this.file1Content,
|
||||
file2Content: this.file2Content,
|
||||
triggerRebuild: async (): Promise<void> => {
|
||||
await this.updateState();
|
||||
this.build();
|
||||
},
|
||||
}).build(container);
|
||||
triggerRebuild,
|
||||
}).build(container)
|
||||
}
|
||||
|
||||
// Draw top diff
|
||||
for (let i = 0; i < difference.file1Lines.length; i += 1) {
|
||||
const line1 = difference.file1Lines[i];
|
||||
const line2 = difference.file2Lines[i];
|
||||
|
||||
const lineDiv = container.createDiv({ cls: 'file-diff__line file-diff__top-line__bg' });
|
||||
const diffSpans = this.buildDiffLine(line1, line2, 'file-diff_top-line__character');
|
||||
|
||||
// Remove border radius if applicable
|
||||
if (i < difference.file1Lines.length - 1 || difference.file2Lines.length !== 0) {
|
||||
lineDiv.classList.add('file-diff__no-bottom-border');
|
||||
difference.lines.forEach((line) => {
|
||||
if (line.startsWith('+')) {
|
||||
container.createDiv({
|
||||
// Necessary to give the line a height when it's empty.
|
||||
text: preventEmptyString(line.slice(1, line.length)),
|
||||
cls: 'line bg-turquoise-light',
|
||||
})
|
||||
} else if (line.startsWith('-')) {
|
||||
container.createDiv({
|
||||
// Necessary to give the line a height when it's empty.
|
||||
text: preventEmptyString(line.slice(1, line.length)),
|
||||
cls: 'line bg-blue-light',
|
||||
})
|
||||
}
|
||||
if (i !== 0) {
|
||||
lineDiv.classList.add('file-diff__no-top-border');
|
||||
}
|
||||
|
||||
lineDiv.appendChild(diffSpans);
|
||||
}
|
||||
|
||||
// Draw bottom diff
|
||||
for (let i = 0; i < difference.file2Lines.length; i += 1) {
|
||||
const line1 = difference.file1Lines[i];
|
||||
const line2 = difference.file2Lines[i];
|
||||
|
||||
const lineDiv = container.createDiv({ cls: 'file-diff__line file-diff__bottom-line__bg' });
|
||||
const diffSpans = this.buildDiffLine(line2, line1, 'file-diff_bottom-line__character');
|
||||
|
||||
// Remove border radius if applicable
|
||||
if ((i == 0 && difference.file1Lines.length > 0) || i > 0) {
|
||||
lineDiv.classList.add('file-diff__no-top-border');
|
||||
}
|
||||
if (i < difference.file2Lines.length - 1) {
|
||||
lineDiv.classList.add('file-diff__no-bottom-border');
|
||||
}
|
||||
|
||||
lineDiv.appendChild(diffSpans);
|
||||
}
|
||||
}
|
||||
|
||||
private buildDiffLine(line1: string, line2: string, charClass: string) {
|
||||
const fragment = document.createElement('div');
|
||||
|
||||
if (line1 != undefined && line1.length === 0) {
|
||||
fragment.textContent = preventEmptyString(line1);
|
||||
} else if (line1 != undefined && line2 != undefined) {
|
||||
const differences = diffWords(line2, line1);
|
||||
|
||||
for (const difference of differences) {
|
||||
if (difference.removed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
// Necessary to give the line a height when it's empty.
|
||||
span.textContent = preventEmptyString(difference.value);
|
||||
if (difference.added) {
|
||||
span.classList.add(charClass);
|
||||
}
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
} else if(line1 != undefined && line2 == undefined) {
|
||||
const span = document.createElement('span');
|
||||
// Necessary to give the line a height when it's empty.
|
||||
span.textContent = preventEmptyString(line1);
|
||||
span.classList.add(charClass);
|
||||
fragment.appendChild(span);
|
||||
} else {
|
||||
fragment.textContent = preventEmptyString(line1);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
private scrollToFirstDifference(): void {
|
||||
if (this.fileDifferences.differences.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = this.contentEl
|
||||
.getElementsByClassName('file-diff__container')[0]
|
||||
.getBoundingClientRect();
|
||||
const elementRect = this.contentEl
|
||||
.getElementsByClassName('difference')[0]
|
||||
.getBoundingClientRect();
|
||||
this.contentEl.scrollTo({
|
||||
top: elementRect.top - containerRect.top - 100,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
async showDeleteModal(): Promise<void> {
|
||||
// Wait a moment to avoid appearing overly aggressive with the modal
|
||||
await sleep(200);
|
||||
await delay(200)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
new DeleteFileModal({
|
||||
file1: this.state.file1,
|
||||
file2: this.state.file2,
|
||||
onDone: (e) => {
|
||||
if (e) {
|
||||
return reject(e);
|
||||
}
|
||||
this.state.continueCallback?.(true);
|
||||
this.leaf.detach();
|
||||
return resolve();
|
||||
},
|
||||
}).open();
|
||||
});
|
||||
file1: this.file1,
|
||||
file2: this.file2,
|
||||
onDone: (e) => (e ? reject(e) : resolve()),
|
||||
}).open()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,58 @@
|
|||
import { Modal, TFile } from 'obsidian';
|
||||
import { Modal, TFile } from 'obsidian'
|
||||
import { DifferencesView } from '../differences_view'
|
||||
|
||||
export class DeleteFileModal extends Modal {
|
||||
constructor(args: {
|
||||
file1: TFile;
|
||||
file2: TFile;
|
||||
onDone: (error: Error | null) => void;
|
||||
file1: TFile
|
||||
file2: TFile
|
||||
onDone: (error: Error | null) => void
|
||||
}) {
|
||||
super(app);
|
||||
this.file1 = args.file1;
|
||||
this.file2 = args.file2;
|
||||
this.onDone = args.onDone;
|
||||
super(app)
|
||||
this.file1 = args.file1
|
||||
this.file2 = args.file2
|
||||
this.onDone = args.onDone
|
||||
}
|
||||
|
||||
private readonly file1: TFile;
|
||||
private readonly file1: TFile
|
||||
|
||||
private readonly file2: TFile;
|
||||
private readonly file2: TFile
|
||||
|
||||
private readonly onDone: (error: Error | null) => void;
|
||||
private readonly onDone: (error: Error | null) => void
|
||||
|
||||
onOpen(): void {
|
||||
// Counteract gravity pull by moving box up for balanced composition
|
||||
this.modalEl.addClass('mb-20');
|
||||
this.modalEl.addClass('mb-16')
|
||||
|
||||
this.contentEl.createEl('h3', {
|
||||
this.contentEl.createEl('h2', {
|
||||
text: `Delete "${this.file2.name}"?`,
|
||||
});
|
||||
})
|
||||
this.contentEl.createEl('p', {
|
||||
text:
|
||||
`The contents of "${this.file1.name}" and ` +
|
||||
`"${this.file2.name}" are identical. Would you like to ` +
|
||||
`delete the duplicate file? Please note that this action is ` +
|
||||
`irreversible.`,
|
||||
});
|
||||
})
|
||||
|
||||
const buttonContainer = this.contentEl.createDiv(
|
||||
'file-diff__button-container'
|
||||
);
|
||||
|
||||
const deleteButton = buttonContainer.createEl('button', {
|
||||
text: 'Delete',
|
||||
cls: 'mod-warning mr-2',
|
||||
});
|
||||
const buttonContainer = this.contentEl.createDiv('button-container')
|
||||
const cancelButton = buttonContainer.createEl('button', {
|
||||
text: 'Cancel',
|
||||
});
|
||||
cls: 'mr-8',
|
||||
})
|
||||
cancelButton.addEventListener('click', () => this.close())
|
||||
const deleteButton = buttonContainer.createEl('button', {
|
||||
text: 'Delete',
|
||||
cls: 'button-danger',
|
||||
})
|
||||
deleteButton.addEventListener('click', () => {
|
||||
this.app.vault.delete(this.file2)
|
||||
|
||||
deleteButton.addEventListener('click', () => this.handleDeleteClick());
|
||||
cancelButton.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
handleDeleteClick(): void {
|
||||
this.app.vault.delete(this.file2);
|
||||
|
||||
this.close();
|
||||
|
||||
this.onDone(null);
|
||||
this.close()
|
||||
// Close currently active file
|
||||
this.app.workspace
|
||||
.getActiveViewOfType(DifferencesView)
|
||||
?.leaf.detach()
|
||||
this.onDone(null)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,38 @@
|
|||
import { Modal } from 'obsidian';
|
||||
import { Modal } from 'obsidian'
|
||||
|
||||
export class RiskyActionModal extends Modal {
|
||||
constructor(args: { onAccept: (error: Error | null) => void }) {
|
||||
super(app);
|
||||
this.onAccept = args.onAccept;
|
||||
super(app)
|
||||
this.onAccept = args.onAccept
|
||||
}
|
||||
|
||||
private readonly onAccept: (error: Error | null) => void;
|
||||
private readonly onAccept: (error: Error | null) => void
|
||||
|
||||
onOpen(): void {
|
||||
// Counteract gravity pull by moving box up for balanced composition
|
||||
this.modalEl.addClass('mb-20');
|
||||
this.modalEl.addClass('mb-16')
|
||||
|
||||
this.contentEl.createEl('h3', { text: `Do you accept the risk?` });
|
||||
this.contentEl.createEl('h2', { text: `Do you accept the risk?` })
|
||||
this.contentEl.createEl('p', {
|
||||
text:
|
||||
`The merging options alter the files irreversibly. ` +
|
||||
`Proceed with caution and only if you are aware and ` +
|
||||
`accepting of the associated risks.`,
|
||||
});
|
||||
})
|
||||
|
||||
const buttonContainer = this.contentEl.createDiv(
|
||||
'file-diff__button-container'
|
||||
);
|
||||
|
||||
const deleteButton = buttonContainer.createEl('button', {
|
||||
text: 'Accept Risk',
|
||||
cls: 'mod-warning mr-2',
|
||||
});
|
||||
const buttonContainer = this.contentEl.createDiv('button-container')
|
||||
const cancelButton = buttonContainer.createEl('button', {
|
||||
text: 'Cancel',
|
||||
});
|
||||
|
||||
cancelButton.addEventListener('click', () => this.close());
|
||||
cls: 'mr-8',
|
||||
})
|
||||
cancelButton.addEventListener('click', () => this.close())
|
||||
const deleteButton = buttonContainer.createEl('button', {
|
||||
text: 'Accept Risk',
|
||||
cls: 'button-danger',
|
||||
})
|
||||
deleteButton.addEventListener('click', () => {
|
||||
this.close();
|
||||
this.onAccept(null);
|
||||
});
|
||||
this.close()
|
||||
this.onAccept(null)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
import { SuggestModal, TFile } from 'obsidian';
|
||||
import { SuggestModal, TFile } from 'obsidian'
|
||||
|
||||
export class SelectFileModal extends SuggestModal<TFile> {
|
||||
constructor(args: {
|
||||
selectableFiles: TFile[];
|
||||
onChoose: (error: Error | null, result?: TFile) => void;
|
||||
selectableFiles: TFile[]
|
||||
onChoose: (error: Error | null, result?: TFile) => void
|
||||
}) {
|
||||
super(app);
|
||||
this.selectableFiles = args.selectableFiles;
|
||||
this.onChoose = args.onChoose;
|
||||
super(app)
|
||||
this.selectableFiles = args.selectableFiles
|
||||
this.onChoose = args.onChoose
|
||||
}
|
||||
|
||||
private readonly selectableFiles: TFile[];
|
||||
private readonly selectableFiles: TFile[]
|
||||
|
||||
private readonly onChoose: (error: Error | null, result?: TFile) => void;
|
||||
private readonly onChoose: (error: Error | null, result?: TFile) => void
|
||||
|
||||
getSuggestions(query: string): TFile[] {
|
||||
return this.selectableFiles
|
||||
.filter((file) => {
|
||||
const searchQuery = query?.toLowerCase();
|
||||
return file.name?.toLowerCase().includes(searchQuery);
|
||||
const searchQuery = query?.toLowerCase()
|
||||
return file.name?.toLowerCase().includes(searchQuery)
|
||||
})
|
||||
.sort((a, b) => (a.stat.mtime < b.stat.mtime ? 1 : -1));
|
||||
.sort((a, b) => (a.stat.mtime < b.stat.mtime ? 1 : -1))
|
||||
}
|
||||
|
||||
renderSuggestion(file: TFile, el: HTMLElement): void {
|
||||
el.createEl('div', { text: file.name });
|
||||
el.createEl('div', { text: file.name })
|
||||
}
|
||||
|
||||
onChooseSuggestion(file: TFile): void {
|
||||
this.onChoose(null, file);
|
||||
this.onChoose(null, file)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
export class Difference {
|
||||
constructor(args: {
|
||||
file1Start: number;
|
||||
file2Start: number;
|
||||
file1Lines: string[];
|
||||
file2Lines: string[];
|
||||
file1Start: number
|
||||
file2Start: number
|
||||
lines: string[]
|
||||
}) {
|
||||
this.file1Start = args.file1Start;
|
||||
this.file2Start = args.file2Start;
|
||||
this.file1Lines = args.file1Lines;
|
||||
this.file2Lines = args.file2Lines;
|
||||
this.file1Start = args.file1Start
|
||||
this.file2Start = args.file2Start
|
||||
this.lines = args.lines
|
||||
}
|
||||
|
||||
public readonly file1Start: number;
|
||||
public readonly file1Start: number
|
||||
|
||||
public readonly file2Start: number;
|
||||
public readonly file2Start: number
|
||||
|
||||
public readonly file1Lines: string[];
|
||||
public readonly lines: string[]
|
||||
|
||||
public readonly file2Lines: string[];
|
||||
hasChangesFromFile1(): boolean {
|
||||
return this.lines.some((l) => l.startsWith('-'))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { ParsedDiff } from 'diff';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FileDifferences } from './file_differences';
|
||||
import { ParsedDiff } from 'diff'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FileDifferences } from './file_differences'
|
||||
|
||||
describe('FileDifferences.fromParsedDiff', () => {
|
||||
it('should work with files containing one line', () => {
|
||||
|
|
@ -10,14 +10,13 @@ describe('FileDifferences.fromParsedDiff', () => {
|
|||
{"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 1,
|
||||
"lines": ["-a","+b"]
|
||||
}]}
|
||||
`) as ParsedDiff;
|
||||
`) as ParsedDiff
|
||||
|
||||
const fileDifferences = FileDifferences.fromParsedDiff(test);
|
||||
const fileDifferences = FileDifferences.fromParsedDiff(test)
|
||||
|
||||
expect(JSON.stringify(fileDifferences)).toBe(
|
||||
'{"file1Name":"1","file2Name":"2",' +
|
||||
'"differences":[{"file1Start":0,"file2Start":0,' +
|
||||
'"file1Lines":["a"],"file2Lines":["b"]}]}'
|
||||
);
|
||||
});
|
||||
});
|
||||
'{"file1Name":"1","file2Name":"2","differences":' +
|
||||
'[{"start":0,"lines":["-a","+b"]}]}'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import { ParsedDiff } from 'diff';
|
||||
import { Difference } from './difference';
|
||||
import { ParsedDiff } from 'diff'
|
||||
import { Difference } from './difference'
|
||||
|
||||
/**
|
||||
* A class that contains the differences between two files.
|
||||
*/
|
||||
export class FileDifferences {
|
||||
private constructor(args: {
|
||||
file1Name?: string;
|
||||
file2Name?: string;
|
||||
differences: Difference[];
|
||||
file1Name?: string
|
||||
file2Name?: string
|
||||
differences: Difference[]
|
||||
}) {
|
||||
this.file1Name = args.file1Name;
|
||||
this.file2Name = args.file2Name;
|
||||
this.differences = args.differences;
|
||||
this.file1Name = args.file1Name
|
||||
this.file2Name = args.file2Name
|
||||
this.differences = args.differences
|
||||
}
|
||||
|
||||
public readonly file1Name?: string;
|
||||
public readonly file1Name?: string
|
||||
|
||||
public readonly file2Name?: string;
|
||||
public readonly file2Name?: string
|
||||
|
||||
public readonly differences: Difference[];
|
||||
public readonly differences: Difference[]
|
||||
|
||||
/**
|
||||
* Returns a FileDifferences object from the given ParsedDiff instance.
|
||||
|
|
@ -39,56 +39,42 @@ export class FileDifferences {
|
|||
* chose the latter option as it seemed simpler.
|
||||
*/
|
||||
static fromParsedDiff(parsedDiff: ParsedDiff): FileDifferences {
|
||||
const differences: Difference[] = [];
|
||||
const differences: Difference[] = []
|
||||
|
||||
parsedDiff.hunks.forEach((hunk) => {
|
||||
let line1Count = 0;
|
||||
let line2Count = 0;
|
||||
for (let i = 0; i < hunk.lines.length; i += 1) {
|
||||
const line = hunk.lines[i];
|
||||
const line = hunk.lines[i]
|
||||
|
||||
if (line.startsWith('+') || line.startsWith('-')) {
|
||||
const start = i;
|
||||
const start = i
|
||||
|
||||
// Find the end of the contiguous lines
|
||||
let end = start;
|
||||
let end = i
|
||||
while (
|
||||
end < hunk.lines.length - 1 &&
|
||||
(hunk.lines[end + 1].startsWith('+') ||
|
||||
hunk.lines[end + 1].startsWith('-'))
|
||||
) {
|
||||
end += 1;
|
||||
end += 1
|
||||
}
|
||||
|
||||
// Add the contiguous lines to the differences
|
||||
const file1Lines = hunk.lines
|
||||
.slice(start, end + 1)
|
||||
.filter((l) => l.startsWith('-'))
|
||||
.map((l) => l.slice(1));
|
||||
const file2Lines = hunk.lines
|
||||
.slice(start, end + 1)
|
||||
.filter((l) => l.startsWith('+'))
|
||||
.map((l) => l.slice(1));
|
||||
differences.push(
|
||||
new Difference({
|
||||
file1Start: hunk.oldStart + start - line2Count - 1,
|
||||
file2Start: hunk.newStart + start - line1Count - 1,
|
||||
file1Lines,
|
||||
file2Lines,
|
||||
file1Start: hunk.oldStart + start - 1,
|
||||
file2Start: hunk.newStart + start - 1,
|
||||
lines: hunk.lines.slice(start, end + 1),
|
||||
})
|
||||
);
|
||||
|
||||
line1Count += file1Lines.length;
|
||||
line2Count += file2Lines.length;
|
||||
i += end - start;
|
||||
)
|
||||
i += end - start
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
return new this({
|
||||
file1Name: parsedDiff.oldFileName,
|
||||
file2Name: parsedDiff.newFileName,
|
||||
differences,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
197
src/main.ts
197
src/main.ts
|
|
@ -1,191 +1,118 @@
|
|||
import { Plugin, TFile } from 'obsidian';
|
||||
import { Plugin, TFile } from 'obsidian'
|
||||
|
||||
import {
|
||||
DifferencesView,
|
||||
VIEW_TYPE_DIFFERENCES,
|
||||
ViewState,
|
||||
} from './components/differences_view';
|
||||
import { RiskyActionModal } from './components/modals/risky_action_modal';
|
||||
import { SelectFileModal } from './components/modals/select_file_modal';
|
||||
import { DifferencesView } from './components/differences_view'
|
||||
import { RiskyActionModal } from './components/modals/risky_action_modal'
|
||||
import { SelectFileModal } from './components/modals/select_file_modal'
|
||||
import { delay } from './utils/delay'
|
||||
|
||||
export default class FileDiffPlugin extends Plugin {
|
||||
fileDiffMergeWarningKey = 'file-diff-merge-warning';
|
||||
|
||||
override onload(): void {
|
||||
this.registerView(
|
||||
VIEW_TYPE_DIFFERENCES,
|
||||
(leaf) => new DifferencesView(leaf)
|
||||
);
|
||||
fileDiffMergeWarningKey = 'file-diff-merge-warning'
|
||||
|
||||
onload(): void {
|
||||
this.addCommand({
|
||||
id: 'compare',
|
||||
id: 'file-diff',
|
||||
name: 'Compare',
|
||||
editorCallback: async () => {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
// Get current active file
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (activeFile == null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const compareFile = await this.getFileToCompare(activeFile);
|
||||
// Get file to compare
|
||||
const compareFile = await this.getFileToCompare(activeFile)
|
||||
if (compareFile == null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
this.openDifferencesView({
|
||||
file1: activeFile,
|
||||
file2: compareFile,
|
||||
showMergeOption: false,
|
||||
});
|
||||
// Open differences view
|
||||
const workspaceLeaf = this.app.workspace.getLeaf()
|
||||
await workspaceLeaf.open(
|
||||
new DifferencesView({
|
||||
leaf: workspaceLeaf,
|
||||
file1: activeFile,
|
||||
file2: compareFile,
|
||||
showMergeOption: false,
|
||||
})
|
||||
)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
this.addCommand({
|
||||
id: 'compare-and-merge',
|
||||
id: 'file-diff-merge',
|
||||
name: 'Compare and merge',
|
||||
editorCallback: async () => {
|
||||
// Show warning when this option is selected for the first time
|
||||
if (!localStorage.getItem(this.fileDiffMergeWarningKey)) {
|
||||
await this.showRiskyActionModal();
|
||||
await this.showRiskyActionModal()
|
||||
if (!localStorage.getItem(this.fileDiffMergeWarningKey)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
// Get current active file
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (activeFile == null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const compareFile = await this.getFileToCompare(activeFile);
|
||||
// Get file to compare
|
||||
const compareFile = await this.getFileToCompare(activeFile)
|
||||
if (compareFile == null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
this.openDifferencesView({
|
||||
file1: activeFile,
|
||||
file2: compareFile,
|
||||
showMergeOption: true,
|
||||
});
|
||||
// Open differences view
|
||||
const workspaceLeaf = this.app.workspace.getLeaf()
|
||||
await workspaceLeaf.open(
|
||||
new DifferencesView({
|
||||
leaf: workspaceLeaf,
|
||||
file1: activeFile,
|
||||
file2: compareFile,
|
||||
showMergeOption: true,
|
||||
})
|
||||
)
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'find-sync-conflicts-and-merge',
|
||||
name: 'Find sync conflicts and merge',
|
||||
callback: async () => {
|
||||
// Show warning when this option is selected for the first time
|
||||
if (!localStorage.getItem(this.fileDiffMergeWarningKey)) {
|
||||
await this.showRiskyActionModal();
|
||||
if (!localStorage.getItem(this.fileDiffMergeWarningKey)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const syncConflicts = this.findSyncConflicts();
|
||||
|
||||
for await (const syncConflict of syncConflicts) {
|
||||
const continuePromise = new Promise<boolean>((resolve) => {
|
||||
this.openDifferencesView({
|
||||
file1: syncConflict.originalFile,
|
||||
file2: syncConflict.syncConflictFile,
|
||||
showMergeOption: true,
|
||||
continueCallback: async (shouldContinue: boolean) =>
|
||||
resolve(shouldContinue),
|
||||
});
|
||||
});
|
||||
|
||||
const shouldContinue = await continuePromise;
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
override async onunload(): Promise<void> {
|
||||
this.app.workspace.detachLeavesOfType(VIEW_TYPE_DIFFERENCES);
|
||||
getFileToCompare(activeFile: TFile): Promise<TFile | undefined> {
|
||||
const selectableFiles = this.app.vault.getFiles()
|
||||
selectableFiles.remove(activeFile)
|
||||
return this.showSelectOtherFileModal({
|
||||
selectableFiles,
|
||||
})
|
||||
}
|
||||
|
||||
private getFileToCompare(activeFile: TFile): Promise<TFile | undefined> {
|
||||
const selectableFiles = this.app.vault.getFiles();
|
||||
selectableFiles.remove(activeFile);
|
||||
return this.showSelectOtherFileModal({ selectableFiles });
|
||||
}
|
||||
|
||||
private showSelectOtherFileModal(args: {
|
||||
selectableFiles: TFile[];
|
||||
showSelectOtherFileModal(args: {
|
||||
selectableFiles: TFile[]
|
||||
}): Promise<TFile | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
new SelectFileModal({
|
||||
selectableFiles: args.selectableFiles,
|
||||
onChoose: (e, f) => (e ? reject(e) : resolve(f)),
|
||||
}).open();
|
||||
});
|
||||
}).open()
|
||||
})
|
||||
}
|
||||
|
||||
private showRiskyActionModal(): Promise<void> {
|
||||
showRiskyActionModal(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
new RiskyActionModal({
|
||||
onAccept: async (e: Error | null) => {
|
||||
if (e) {
|
||||
reject(e);
|
||||
reject(e)
|
||||
} else {
|
||||
localStorage.setItem(
|
||||
this.fileDiffMergeWarningKey,
|
||||
'true'
|
||||
);
|
||||
)
|
||||
// Wait for the set item dispatch event to be processed
|
||||
await sleep(50);
|
||||
await delay(50)
|
||||
|
||||
resolve();
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
}).open();
|
||||
});
|
||||
}
|
||||
|
||||
async openDifferencesView(state: ViewState): Promise<void> {
|
||||
// Closes all leafs (views) of the type VIEW_TYPE_DIFFERENCES
|
||||
this.app.workspace.detachLeavesOfType(VIEW_TYPE_DIFFERENCES);
|
||||
|
||||
// Opens a new leaf (view) of the type VIEW_TYPE_DIFFERENCES
|
||||
const leaf = this.app.workspace.getLeaf(true);
|
||||
leaf.setViewState({
|
||||
type: VIEW_TYPE_DIFFERENCES,
|
||||
active: true,
|
||||
state,
|
||||
});
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
}
|
||||
|
||||
findSyncConflicts(): { originalFile: TFile; syncConflictFile: TFile }[] {
|
||||
const syncConflicts: {
|
||||
originalFile: TFile;
|
||||
syncConflictFile: TFile;
|
||||
}[] = [];
|
||||
|
||||
const files = app.vault.getMarkdownFiles();
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name.includes('sync-conflict')) {
|
||||
const originalFileName = file.name.replace(
|
||||
/\.sync-conflict-\d{8}-\d{6}-[A-Z0-9]+/,
|
||||
''
|
||||
);
|
||||
const originalFile = files.find(
|
||||
(f) => f.name === originalFileName && (file.parent?.path ?? "") === (f.parent?.path ?? "")
|
||||
);
|
||||
|
||||
if (originalFile) {
|
||||
syncConflicts.push({
|
||||
originalFile,
|
||||
syncConflictFile: file,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return syncConflicts;
|
||||
}).open()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
src/utils/delay.ts
Normal file
5
src/utils/delay.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,49 +1,35 @@
|
|||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { replaceLine } from './string_utils';
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { replaceLine } from './string_utils'
|
||||
|
||||
describe('replaceLine', () => {
|
||||
it('should replace fullText with newLine at given position', () => {
|
||||
const fullText = 'line1\nline2\nline3';
|
||||
const fullText = 'line1\nline2\nline3'
|
||||
const newFullText = replaceLine({
|
||||
fullText,
|
||||
newLine: 'newLine2',
|
||||
position: 1,
|
||||
linesToReplace: 1,
|
||||
});
|
||||
expect(newFullText).toBe('line1\nnewLine2\nline3');
|
||||
});
|
||||
})
|
||||
expect(newFullText).toBe('line1\nnewLine2\nline3')
|
||||
})
|
||||
|
||||
it('should replacing with mutliple lines correctly', () => {
|
||||
const fullText = 'line1\nline2\nline3';
|
||||
const fullText = 'line1\nline2\nline3'
|
||||
const newFullText = replaceLine({
|
||||
fullText,
|
||||
newLine: 'newLine2\nnewLine3',
|
||||
position: 1,
|
||||
linesToReplace: 1,
|
||||
});
|
||||
expect(newFullText).toBe('line1\nnewLine2\nnewLine3\nline3');
|
||||
});
|
||||
})
|
||||
expect(newFullText).toBe('line1\nnewLine2\nnewLine3\nline3')
|
||||
})
|
||||
|
||||
it('should remove line when new line is empty', () => {
|
||||
const fullText = 'line1\nline2\nline3';
|
||||
it('should replacing with mutliple lines correctly', () => {
|
||||
const fullText = 'line1\nline2\nline3'
|
||||
const newFullText = replaceLine({
|
||||
fullText,
|
||||
newLine: '',
|
||||
position: 1,
|
||||
linesToReplace: 1,
|
||||
});
|
||||
expect(newFullText).toBe('line1\nline3');
|
||||
});
|
||||
|
||||
it('should replace two lines', () => {
|
||||
const fullText = 'line1\nline2\nline3\nline4';
|
||||
const newFullText = replaceLine({
|
||||
fullText,
|
||||
newLine: 'line5',
|
||||
position: 1,
|
||||
linesToReplace: 2,
|
||||
});
|
||||
expect(newFullText).toBe('line1\nline5\nline4');
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(newFullText).toBe('line1\nline3')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,42 +1,26 @@
|
|||
/** Method to insert a line in a string */
|
||||
export function insertLine(args: {
|
||||
fullText: string;
|
||||
newLine: string;
|
||||
position: number;
|
||||
}): string {
|
||||
const lines = args.fullText.split('\n');
|
||||
lines.splice(args.position, 0, args.newLine);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Method to replace a line in a string */
|
||||
export function replaceLine(args: {
|
||||
fullText: string;
|
||||
newLine: string;
|
||||
position: number;
|
||||
linesToReplace: number;
|
||||
fullText: string
|
||||
newLine: string
|
||||
position: number
|
||||
}): string {
|
||||
const lines = args.fullText.split('\n');
|
||||
if (args.newLine === '') {
|
||||
lines.splice(args.position, args.linesToReplace);
|
||||
} else {
|
||||
lines.splice(args.position, args.linesToReplace, args.newLine);
|
||||
}
|
||||
return lines.join('\n');
|
||||
const lines = args.fullText.split('\n')
|
||||
lines[args.position] = args.newLine
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** Method to delete a line in a string */
|
||||
export function deleteLines(args: {
|
||||
fullText: string;
|
||||
position: number;
|
||||
count: number;
|
||||
fullText: string
|
||||
position: number
|
||||
count: number
|
||||
}): string {
|
||||
const lines = args.fullText.split('\n');
|
||||
lines.splice(args.position, args.count);
|
||||
return lines.join('\n');
|
||||
const lines = args.fullText.split('\n')
|
||||
lines.splice(args.position, args.count)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** Returns an invisible character when the text is empty. */
|
||||
export function preventEmptyString(text: string): string {
|
||||
return text !== '' ? text : '';
|
||||
return text !== '' ? text : ''
|
||||
}
|
||||
|
|
|
|||
99
styles.css
99
styles.css
|
|
@ -1,70 +1,74 @@
|
|||
.file-diff__container {
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: scroll;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.file-diff__line {
|
||||
.line {
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.file-diff__button-container {
|
||||
text-align: right;
|
||||
button:focus-visible {
|
||||
box-shadow: var(--input-shadow);
|
||||
}
|
||||
|
||||
.file-diff__top-line__bg {
|
||||
background-color: color-mix(in srgb, var(--background-primary), #0fc 15%);
|
||||
border-radius: 0.25rem; /* 4px */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.bg-turquoise-light {
|
||||
background-color: #d9f4ef;
|
||||
}
|
||||
|
||||
.bg-blue-light {
|
||||
background-color: #d9edff;
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background-color: #b00020 !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background-color: #9f0020 !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.file-diff_top-line__character {
|
||||
background-color: color-mix(in srgb, var(--background-primary), #0fc 50%);
|
||||
border-radius: 0.25rem; /* 4px */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg-turquoise-light {
|
||||
background-color: #25403b;
|
||||
}
|
||||
|
||||
.file-diff__bottom-line__bg {
|
||||
background-color: color-mix(in srgb, var(--background-primary), #08f 15%);
|
||||
border-radius: 0.25rem; /* 4px */
|
||||
}
|
||||
.bg-blue-light {
|
||||
background-color: #25394b;
|
||||
}
|
||||
|
||||
.file-diff_bottom-line__character {
|
||||
background-color: color-mix(in srgb, var(--background-primary), #08f 50%);
|
||||
border-radius: 0.25rem; /* 4px */
|
||||
}
|
||||
.button-danger {
|
||||
background-color: #cf6679 !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.file-diff__no-bottom-border {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.file-diff__no-top-border {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.file-diff__action-line {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tailwind CSS */
|
||||
.flex {
|
||||
display: flex;
|
||||
.button-danger:hover {
|
||||
background-color: #b85a6d !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: 0.25rem; /* 4px */
|
||||
.gap-2 {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.no-decoration {
|
||||
text-decoration: none;
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
.no-decoration:hover {
|
||||
text-decoration: none;
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
|
|
@ -72,16 +76,19 @@
|
|||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Rename from py-0.5 to py-0-5, as dots aren't allowed in selector names */
|
||||
.py-0-5 {
|
||||
padding-top: 0.125rem; /* 2px */
|
||||
padding-bottom: 0.125rem; /* 2px */
|
||||
.py-2 {
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 0.5rem; /* 8px */
|
||||
.mr-8 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mb-20 {
|
||||
margin-bottom: 5rem; /* 80px */
|
||||
.mb-16 {
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.text-gray {
|
||||
color: #919191;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,4 @@
|
|||
{
|
||||
"1.0.0": "0.15.0",
|
||||
"1.0.1": "0.15.0",
|
||||
"1.0.2": "0.15.0",
|
||||
"1.0.3": "0.15.0",
|
||||
"1.0.4": "0.15.0",
|
||||
"1.0.5": "0.15.0",
|
||||
"1.0.6": "0.15.0",
|
||||
"1.0.7": "0.15.0",
|
||||
"1.0.8": "0.15.0",
|
||||
"1.0.9": "0.15.0",
|
||||
"1.0.10": "0.15.0",
|
||||
"1.0.11": "0.15.0",
|
||||
"1.0.12": "0.15.0",
|
||||
"1.0.13": "0.15.0",
|
||||
"1.1.0": "0.15.0",
|
||||
"1.1.1": "0.15.0",
|
||||
"1.1.2": "0.15.0"
|
||||
"1.0.1": "0.15.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue