Compare commits

..

55 commits

Author SHA1 Message Date
Till Friebe
d566ded402 Release version 1.1.2 2025-08-24 02:52:13 +02:00
Till Friebe
7bd7dd41c0
Merge pull request #20 from TheJusticeMan/master
fixed the colors
2025-08-24 01:41:54 +02:00
Justice Vellacott
185f95f30a I fixed the colors using the css mix function it'll now work on any theme. 2025-08-03 22:23:57 -04:00
friebetill
08e488d425 Release version 1.1.1 2024-02-15 13:39:30 +07:00
Till Friebe
ac4ea85fe0
Merge pull request #13 from blainelewis1/patch-1
Fix bug where files have same name but differing paths
2024-02-15 13:38:53 +07:00
Blaine Lewis
00567dea68
fix bug where files have same name but differing paths 2024-02-08 10:25:22 -05:00
Till Friebe
ad651d827e
Add SyncThing link 2023-11-08 12:52:26 +01:00
friebetill
dd31d3d371 Release version 1.1.0 2023-11-08 02:39:49 +01:00
friebetill
5426feb1e1 Update locked packages 2023-11-08 02:39:03 +01:00
friebetill
fa81f5dd62 Show word differences 2023-11-08 02:37:20 +01:00
friebetill
6cb64e9743 ISSUE-10: Fix diff for some files 2023-11-06 22:16:25 +01:00
friebetill
e51806b299 11: Don't open result page after deletion 2023-11-06 21:16:52 +01:00
Till Friebe
5e3c691266
Remove link to official plugin merge request 2023-05-03 16:37:27 +02:00
friebetill
87fc2b1bd8 Release version 1.0.13 2023-04-30 11:35:27 +02:00
friebetill
1f0ee0187b Apply feedback from liamcain 2023-04-30 11:34:49 +02:00
friebetill
6ee9e381d4 Release version 1.0.12 2023-03-27 00:06:02 +08:00
friebetill
b1883dc436 Add 'Find sync conflicts and merge' command 2023-03-27 00:05:36 +08:00
friebetill
46bb29a745 Release version 1.0.11 2023-03-26 18:55:08 +08:00
friebetill
6dbb36a16a Register view with registerView
If we don't register the view, as before, then the workspace won't
be able to properly serialize it.
2023-03-26 18:54:18 +08:00
friebetill
9df590f6b2 Release version 1.0.10 2023-02-22 12:22:46 +08:00
friebetill
5f7503598a Update test 2023-02-22 12:16:26 +08:00
friebetill
7dfff85dd1 Add semicolons to prevent difficult to debug bugs 2023-02-22 12:14:02 +08:00
friebetill
dee8138564 Remove file-diff prefix of commands
Obsidian will automatically prefix the command id with the plugin id,
we don't need to do it ourself.
2023-02-22 11:58:13 +08:00
friebetill
705176ef6b Don't overwrite Obsidian CSS in a plugin 2023-02-22 11:56:48 +08:00
friebetill
cd0be499d5 Use existing sleep function 2023-02-22 11:49:20 +08:00
friebetill
e48981ab19 Release version 1.0.9 2023-02-15 21:47:55 +08:00
friebetill
e56eac7508 Improve UX by opening in active leaf 2023-02-15 21:45:30 +08:00
friebetill
81c8172a11 Improve UX by opening file 1 after deleting file 2 2023-02-15 21:44:43 +08:00
friebetill
fbd14edf23 Add motivation for plugin 2023-02-15 21:20:36 +08:00
friebetill
b2dccfe804 Remove untested compatibility 2023-02-15 21:19:59 +08:00
friebetill
8306c36722 Improve variable name 2023-02-15 21:16:48 +08:00
friebetill
9e677a94b2 Use unused variable 2023-02-15 21:16:19 +08:00
friebetill
b5ee9349e7 Release version 1.0.8 2023-02-15 20:25:07 +08:00
friebetill
a240ccfee7 Fix bug where lines where skipped 2023-02-15 20:24:22 +08:00
friebetill
8454d68b54 Fix old name of view type 2023-02-15 20:23:46 +08:00
friebetill
941c5f0c80 Remove edge cases by add trailing new line
This solves some problems for my test cases, but not sure if this
is bulletproof.
2023-02-15 19:58:17 +08:00
friebetill
e9c4a7aba6 Release version 1.0.7 2023-02-14 12:31:54 +08:00
friebetill
cc1ffac60b Reactivate ignoring line breaks at the end of the file 2023-02-14 12:31:15 +08:00
friebetill
dd5a1fc2b8 Release version 1.0.6 2023-02-14 12:14:58 +08:00
friebetill
428d9b4682 Show all lines in diff
This fixes a bug where the line count was not caculated correctly.
2023-02-14 12:13:55 +08:00
friebetill
add2cb75b4 Ignore whitespaces at the end of each line 2023-02-14 12:13:38 +08:00
friebetill
5c4f98d48b Release version 1.0.5 2023-02-11 12:54:15 +08:00
friebetill
fad48c595c Fix bug on deleting modal 2023-02-11 12:53:42 +08:00
friebetill
d8000bd0fc Use correct name 2023-02-11 10:51:56 +08:00
friebetill
5065a53482 Streamline style of modals 2023-02-11 10:47:24 +08:00
Till Friebe
bfd7500d5b
Add link to how to adjust the colors 2023-02-10 12:33:11 +08:00
friebetill
fe165918ac Release version 1.0.4 2023-02-10 12:26:10 +08:00
friebetill
c3b005e909 Fix bug when file 2 has multiple lines 2023-02-10 12:25:32 +08:00
friebetill
56b8e2d7b0 Refresh state when one of the files changes 2023-02-10 11:49:02 +08:00
friebetill
66b7f06154 Fix bug when replacing multiple lines 2023-02-10 11:37:52 +08:00
friebetill
de98f88a83 Make colors easily adjustable
Add prefix to avoid mixing styles.
2023-02-10 11:21:53 +08:00
friebetill
68b0e9ec09 Release version 1.0.3 2023-02-07 16:13:29 +08:00
friebetill
a7e1afcb52 Add "Accept None" button 2023-02-07 16:09:41 +08:00
friebetill
f0b2c3a20b Fix accept from one file 2023-02-07 16:02:23 +08:00
friebetill
37319e5c5d Fix showing duplicate lines 2023-02-07 16:01:58 +08:00
22 changed files with 1406 additions and 905 deletions

View file

@ -22,7 +22,9 @@
"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",

View file

@ -1,6 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": false,
"semi": true,
"singleQuote": true
}

View file

@ -1,6 +1,6 @@
# Obsidian File Diff
<!-- [![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?color=7e6ad6&labelColor=34208c&label=Obsidian%20Downloads&query=$['file-diff'].downloads&url=https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugin-stats.json&)](obsidian://show-plugin?id=file-diff) -->
[![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?color=7e6ad6&labelColor=34208c&label=Obsidian%20Downloads&query=$['file-diff'].downloads&url=https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugin-stats.json&)](obsidian://show-plugin?id=file-diff)
![GitHub stars](https://img.shields.io/github/stars/friebetill/obsidian-file-diff?style=flat)
## 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" />
## Compatibility
## Motivation
Compatible with all platforms (Windows, Linux, macOS, Android and iOS). TODO:
Test on Android and iOS. Tested with Obsidian v0.15.0 or higher.
I created this plugin because I use [SyncThing](https://syncthing.net/) and it bothered me to clean up merge conflicts by hand.
## 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.

View file

@ -1,7 +1,7 @@
{
"id": "file-diff",
"name": "File Diff",
"version": "1.0.2",
"version": "1.1.2",
"minAppVersion": "0.15.0",
"description": "Shows the differences between two files..",
"author": "Till Friebe",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-file-diff",
"version": "1.0.2",
"version": "1.1.2",
"description": "Shows the differences between two files.",
"main": "main.js",
"scripts": {

View file

@ -1,85 +1,88 @@
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'
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';
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-row gap-2 py-2' })
const actionLine = container.createDiv({
cls: 'flex flex-row gap-1 py-0-5',
});
const hasPlusLines = this.difference.lines.some((l) =>
l.startsWith('+')
)
const hasMinusLines = this.difference.lines.some((l) =>
l.startsWith('-')
)
const hasMinusLines = this.difference.file1Lines.length > 0;
const hasPlusLines = this.difference.file2Lines.length > 0;
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)
}).build(actionLine);
ActionLineDivider.build(actionLine);
new ActionLineButton({
text: 'Accept None',
onClick: (e) => this.acceptNoneClick(e, this.difference),
}).build(actionLine);
} else if (hasMinusLines) {
new ActionLineButton({
text: `Accept from ${this.file1.name}`,
onClick: (e) => this.acceptTopClick(e, this.difference),
}).build(actionLine)
ActionLineDivider.build(actionLine)
onClick: (e) => this.insertFile1Difference(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.acceptBottomClick(e, this.difference),
}).build(actionLine)
ActionLineDivider.build(actionLine)
onClick: (e) => this.insertFile2Difference(e, this.difference),
}).build(actionLine);
ActionLineDivider.build(actionLine);
new ActionLineButton({
text: 'Discard',
onClick: (e) => this.discardFile2Difference(e, this.difference),
}).build(actionLine)
}).build(actionLine);
}
}
@ -87,99 +90,154 @@ export class ActionLine {
event: MouseEvent,
difference: Difference
): Promise<void> {
event.preventDefault()
event.preventDefault();
const changedLines = difference.lines
.filter((line) => line.startsWith('-'))
.map((line) => line.slice(1, line.length))
.join('\n')
const changedLines = difference.file1Lines.join('\n');
const newContent = replaceLine({
fullText: this.file2Content,
newLine: changedLines,
position: difference.file2Start,
})
await app.vault.modify(this.file2, newContent)
linesToReplace: difference.file2Lines.length,
});
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.lines
.filter((line) => line.startsWith('+'))
.map((line) => line.slice(1, line.length))
.join('\n')
const changedLines = difference.file2Lines.join('\n');
const newContent = replaceLine({
fullText: this.file1Content,
newLine: changedLines,
position: difference.file1Start,
})
await app.vault.modify(this.file1, newContent)
linesToReplace: difference.file1Lines.length,
});
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.lines
.filter((line) => line.startsWith('-') || line.startsWith('+'))
.map((line) => line.slice(1, line.length))
.join('\n')
const changedLines = [
...difference.file1Lines,
...difference.file2Lines,
].join('\n');
const newFile1Content = replaceLine({
fullText: this.file1Content,
newLine: changedLines,
position: difference.file1Start,
})
await app.vault.modify(this.file1, newFile1Content)
linesToReplace: difference.file1Lines.length,
});
await app.vault.modify(this.file1, newFile1Content);
const newFile2Content = replaceLine({
fullText: this.file2Content,
newLine: changedLines,
position: difference.file2Start,
})
await app.vault.modify(this.file2, newFile2Content)
linesToReplace: difference.file2Lines.length,
});
await app.vault.modify(this.file2, newFile2Content);
this.triggerRebuild()
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();
}
async discardFile1Difference(
event: MouseEvent,
difference: Difference
): Promise<void> {
event.preventDefault()
event.preventDefault();
const newContent = deleteLines({
fullText: this.file1Content,
position: difference.file1Start,
count: difference.lines.length,
})
await app.vault.modify(this.file1, newContent)
count: difference.file1Lines.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.lines.length,
})
await app.vault.modify(this.file2, newContent)
count: difference.file2Lines.length,
});
await app.vault.modify(this.file2, newContent);
this.triggerRebuild()
this.triggerRebuild();
}
}

View file

@ -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 text-gray',
cls: 'no-decoration text-xxs file-diff__action-line',
})
.onClickEvent(this.onClick)
.onClickEvent(this.onClick);
}
}

View file

@ -1,5 +1,8 @@
export class ActionLineDivider {
static build(actionLine: HTMLDivElement): void {
actionLine.createEl('span', { text: '|', cls: 'text-xxs text-gray' })
actionLine.createEl('span', {
text: '|',
cls: 'text-xxs file-diff__action-line',
});
}
}

View file

@ -1,182 +1,290 @@
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'
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';
export const VIEW_TYPE_PATCH = 'patch-view'
export const VIEW_TYPE_DIFFERENCES = 'differences-view';
export interface ViewState {
file1: TFile;
file2: TFile;
showMergeOption: boolean;
continueCallback?: (shouldContinue: boolean) => Promise<void>;
}
export class DifferencesView extends ItemView {
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
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();
})
);
}
private file1: TFile
private state: ViewState;
private file2: TFile
private file1Content: string;
private file1Content: string
private file2Content: string;
private file2Content: string
private fileDifferences: FileDifferences;
private showMergeOption: boolean
private file1Lines: string[];
private fileDifferences: FileDifferences
private file2Lines: string[];
private file1Lines: string[]
private wasDeleteModalShown = false;
private lineCount: number
private wasDeleteModalShown = false
getViewType(): string {
return VIEW_TYPE_PATCH
override getViewType(): string {
return VIEW_TYPE_DIFFERENCES;
}
getDisplayText(): string {
return `Difference between ${this.file1.name} and ${this.file2.name}`
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`;
}
async onOpen(): Promise<void> {
await this.updateState()
this.build()
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);
}
private async updateState(): Promise<void> {
// TODO(tillf): Find way to refresh state when one of the files changes
if (this.state.file1 == null || this.state.file2 == null) {
return;
}
this.file1Content = await this.app.vault.read(this.file1)
this.file1Lines = this.file1Content.split('\n')
this.file1Content = await this.app.vault.cachedRead(this.state.file1);
this.file2Content = await this.app.vault.cachedRead(this.state.file2);
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')
)
)
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);
// 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: 'container' })
const container = this.contentEl.createDiv({
cls: 'file-diff__container',
});
for (let i = 0; i <= this.lineCount; i += 1) {
const line = i in this.file1Lines ? this.file1Lines[i] : null
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) {
const difference = this.fileDifferences.differences.find(
(d) => d.file1Start === i
)
// eslint-disable-next-line no-loop-func
(d) =>
d.file1Start === lineCount1 && d.file2Start === lineCount2
);
if (difference != null) {
const differenceContainer = container.createDiv({
cls: 'difference',
})
this.buildDifferenceVisualizer(differenceContainer, difference)
}
if (
line != null &&
(difference == null || !difference.hasChangesFromFile1())
) {
});
this.buildDifferenceVisualizer(differenceContainer, difference);
lineCount1 += difference.file1Lines.length;
lineCount2 += difference.file2Lines.length;
} else {
const line =
lineCount1 <= lineCount2
? this.file1Lines[lineCount1]
: this.file2Lines[lineCount2];
container.createDiv({
// Necessary to give the line a height when it's empty.
text: preventEmptyString(line),
cls: 'line',
})
cls: 'file-diff__line',
});
lineCount1 += 1;
lineCount2 += 1;
}
}
if (this.fileDifferences.differences.length > 0) {
const containerRect = this.contentEl
.getElementsByClassName('container')[0]
.getBoundingClientRect()
const elementRect = this.contentEl
.getElementsByClassName('difference')[0]
.getBoundingClientRect()
this.contentEl.scrollTo({
top: elementRect.top - containerRect.top - 100,
behavior: 'smooth',
})
}
if (
this.fileDifferences.differences.length === 0 &&
this.showMergeOption &&
!this.wasDeleteModalShown
) {
this.showDeleteModal()
}
}
private buildDifferenceVisualizer(
container: HTMLDivElement,
difference: Difference
): void {
const triggerRebuild = async (): Promise<void> => {
await this.updateState()
this.build()
}
if (this.showMergeOption) {
if (this.state.showMergeOption) {
new ActionLine({
difference,
file1: this.file1,
file2: this.file2,
file1: this.state.file1,
file2: this.state.file2,
file1Content: this.file1Content,
file2Content: this.file2Content,
triggerRebuild,
}).build(container)
triggerRebuild: async (): Promise<void> => {
await this.updateState();
this.build();
},
}).build(container);
}
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',
})
// 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');
}
})
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 delay(200)
await sleep(200);
return new Promise((resolve, reject) => {
new DeleteFileModal({
file1: this.file1,
file2: this.file2,
onDone: (e) => (e ? reject(e) : resolve()),
}).open()
})
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();
});
}
}

View file

@ -1,58 +1,59 @@
import { Modal, TFile } from 'obsidian'
import { DifferencesView } from '../differences_view'
import { Modal, TFile } from 'obsidian';
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-16')
this.modalEl.addClass('mb-20');
this.contentEl.createEl('h2', {
this.contentEl.createEl('h3', {
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 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)
cls: 'mod-warning mr-2',
});
const cancelButton = buttonContainer.createEl('button', {
text: 'Cancel',
});
this.close()
// Close currently active file
this.app.workspace
.getActiveViewOfType(DifferencesView)
?.leaf.detach()
this.onDone(null)
})
deleteButton.addEventListener('click', () => this.handleDeleteClick());
cancelButton.addEventListener('click', () => this.close());
}
handleDeleteClick(): void {
this.app.vault.delete(this.file2);
this.close();
this.onDone(null);
}
}

View file

@ -1,38 +1,41 @@
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-16')
this.modalEl.addClass('mb-20');
this.contentEl.createEl('h2', { text: `Do you accept the risk?` })
this.contentEl.createEl('h3', { 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 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: 'Accept Risk',
cls: 'button-danger',
})
cls: 'mod-warning mr-2',
});
const cancelButton = buttonContainer.createEl('button', {
text: 'Cancel',
});
cancelButton.addEventListener('click', () => this.close());
deleteButton.addEventListener('click', () => {
this.close()
this.onAccept(null)
})
this.close();
this.onAccept(null);
});
}
}

View file

@ -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);
}
}

View file

@ -1,21 +1,21 @@
export class Difference {
constructor(args: {
file1Start: number
file2Start: number
lines: string[]
file1Start: number;
file2Start: number;
file1Lines: string[];
file2Lines: string[];
}) {
this.file1Start = args.file1Start
this.file2Start = args.file2Start
this.lines = args.lines
this.file1Start = args.file1Start;
this.file2Start = args.file2Start;
this.file1Lines = args.file1Lines;
this.file2Lines = args.file2Lines;
}
public readonly file1Start: number
public readonly file1Start: number;
public readonly file2Start: number
public readonly file2Start: number;
public readonly lines: string[]
public readonly file1Lines: string[];
hasChangesFromFile1(): boolean {
return this.lines.some((l) => l.startsWith('-'))
}
public readonly file2Lines: string[];
}

View file

@ -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,13 +10,14 @@ 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":' +
'[{"start":0,"lines":["-a","+b"]}]}'
)
})
})
'{"file1Name":"1","file2Name":"2",' +
'"differences":[{"file1Start":0,"file2Start":0,' +
'"file1Lines":["a"],"file2Lines":["b"]}]}'
);
});
});

View file

@ -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,42 +39,56 @@ 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 = i
let end = start;
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 - 1,
file2Start: hunk.newStart + start - 1,
lines: hunk.lines.slice(start, end + 1),
file1Start: hunk.oldStart + start - line2Count - 1,
file2Start: hunk.newStart + start - line1Count - 1,
file1Lines,
file2Lines,
})
)
i += end - start
);
line1Count += file1Lines.length;
line2Count += file2Lines.length;
i += end - start;
}
}
})
});
return new this({
file1Name: parsedDiff.oldFileName,
file2Name: parsedDiff.newFileName,
differences,
})
});
}
}

View file

@ -1,118 +1,191 @@
import { Plugin, TFile } from 'obsidian'
import { Plugin, TFile } from 'obsidian';
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'
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';
export default class FileDiffPlugin extends Plugin {
fileDiffMergeWarningKey = 'file-diff-merge-warning'
fileDiffMergeWarningKey = 'file-diff-merge-warning';
override onload(): void {
this.registerView(
VIEW_TYPE_DIFFERENCES,
(leaf) => new DifferencesView(leaf)
);
onload(): void {
this.addCommand({
id: 'file-diff',
id: 'compare',
name: 'Compare',
editorCallback: async () => {
// Get current active file
const activeFile = this.app.workspace.getActiveFile()
const activeFile = this.app.workspace.getActiveFile();
if (activeFile == null) {
return
return;
}
// Get file to compare
const compareFile = await this.getFileToCompare(activeFile)
const compareFile = await this.getFileToCompare(activeFile);
if (compareFile == null) {
return
return;
}
// Open differences view
const workspaceLeaf = this.app.workspace.getLeaf()
await workspaceLeaf.open(
new DifferencesView({
leaf: workspaceLeaf,
file1: activeFile,
file2: compareFile,
showMergeOption: false,
})
)
this.openDifferencesView({
file1: activeFile,
file2: compareFile,
showMergeOption: false,
});
},
})
});
this.addCommand({
id: 'file-diff-merge',
id: 'compare-and-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;
}
}
// Get current active file
const activeFile = this.app.workspace.getActiveFile()
const activeFile = this.app.workspace.getActiveFile();
if (activeFile == null) {
return
return;
}
// Get file to compare
const compareFile = await this.getFileToCompare(activeFile)
const compareFile = await this.getFileToCompare(activeFile);
if (compareFile == null) {
return
return;
}
// Open differences view
const workspaceLeaf = this.app.workspace.getLeaf()
await workspaceLeaf.open(
new DifferencesView({
leaf: workspaceLeaf,
file1: activeFile,
file2: compareFile,
showMergeOption: true,
})
)
this.openDifferencesView({
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;
}
}
},
});
}
getFileToCompare(activeFile: TFile): Promise<TFile | undefined> {
const selectableFiles = this.app.vault.getFiles()
selectableFiles.remove(activeFile)
return this.showSelectOtherFileModal({
selectableFiles,
})
override async onunload(): Promise<void> {
this.app.workspace.detachLeavesOfType(VIEW_TYPE_DIFFERENCES);
}
showSelectOtherFileModal(args: {
selectableFiles: TFile[]
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[];
}): Promise<TFile | undefined> {
return new Promise((resolve, reject) => {
new SelectFileModal({
selectableFiles: args.selectableFiles,
onChoose: (e, f) => (e ? reject(e) : resolve(f)),
}).open()
})
}).open();
});
}
showRiskyActionModal(): Promise<void> {
private 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 delay(50)
await sleep(50);
resolve()
resolve();
}
},
}).open()
})
}).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;
}
}

View file

@ -1,5 +0,0 @@
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}

View file

@ -1,35 +1,49 @@
/* 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,
})
expect(newFullText).toBe('line1\nnewLine2\nline3')
})
linesToReplace: 1,
});
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,
})
expect(newFullText).toBe('line1\nnewLine2\nnewLine3\nline3')
})
linesToReplace: 1,
});
expect(newFullText).toBe('line1\nnewLine2\nnewLine3\nline3');
});
it('should replacing with mutliple lines correctly', () => {
const fullText = 'line1\nline2\nline3'
it('should remove line when new line is empty', () => {
const fullText = 'line1\nline2\nline3';
const newFullText = replaceLine({
fullText,
newLine: '',
position: 1,
})
expect(newFullText).toBe('line1\nline3')
})
})
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');
});
});

View file

@ -1,26 +1,42 @@
/** 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
fullText: string;
newLine: string;
position: number;
linesToReplace: number;
}): string {
const lines = args.fullText.split('\n')
lines[args.position] = args.newLine
return lines.join('\n')
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');
}
/** 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 : '';
}

View file

@ -1,74 +1,70 @@
.container {
.file-diff__container {
width: 100%;
max-width: 100%;
overflow-x: scroll;
padding: 1rem;
}
.line {
.file-diff__line {
padding: 0.1rem 0;
}
button:focus-visible {
box-shadow: var(--input-shadow);
.file-diff__button-container {
text-align: right;
}
@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__bg {
background-color: color-mix(in srgb, var(--background-primary), #0fc 15%);
border-radius: 0.25rem; /* 4px */
}
@media (prefers-color-scheme: dark) {
.bg-turquoise-light {
background-color: #25403b;
}
.file-diff_top-line__character {
background-color: color-mix(in srgb, var(--background-primary), #0fc 50%);
border-radius: 0.25rem; /* 4px */
}
.bg-blue-light {
background-color: #25394b;
}
.file-diff__bottom-line__bg {
background-color: color-mix(in srgb, var(--background-primary), #08f 15%);
border-radius: 0.25rem; /* 4px */
}
.button-danger {
background-color: #cf6679 !important;
color: #ffffff;
}
.file-diff_bottom-line__character {
background-color: color-mix(in srgb, var(--background-primary), #08f 50%);
border-radius: 0.25rem; /* 4px */
}
.button-danger:hover {
background-color: #b85a6d !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;
}
.flex-row {
display: flex;
flex-direction: row;
}
.gap-2 {
gap: 4px;
.gap-1 {
gap: 0.25rem; /* 4px */
}
.no-decoration {
text-decoration: none;
text-decoration-line: none;
}
.no-decoration:hover {
text-decoration: none;
text-decoration-line: none;
}
@ -76,19 +72,16 @@ button:focus-visible {
font-size: 0.65rem;
}
.py-2 {
padding-top: 2px;
padding-bottom: 2px;
/* 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 */
}
.mr-8 {
margin-right: 8px;
.mr-2 {
margin-right: 0.5rem; /* 8px */
}
.mb-16 {
margin-bottom: 80px;
}
.text-gray {
color: #919191;
.mb-20 {
margin-bottom: 5rem; /* 80px */
}

View file

@ -1,5 +1,19 @@
{
"1.0.0": "0.15.0",
"1.0.1": "0.15.0",
"1.0.2": "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"
}

1050
yarn.lock

File diff suppressed because it is too large Load diff