mirror of
https://github.com/friebetill/obsidian-file-diff.git
synced 2026-07-22 07:40:25 +00:00
Follow guide https://bit.ly/3JIZGTE to improve Prettier + ESLint
This commit is contained in:
parent
8d864b7d8f
commit
a01e69a66f
20 changed files with 2065 additions and 444 deletions
|
|
@ -1,3 +1,6 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
|
||||
*.css
|
||||
*.svg
|
||||
84
.eslintrc
84
.eslintrc
|
|
@ -1,23 +1,63 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:react/recommended",
|
||||
"airbnb",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react", "@typescript-eslint", "react-hooks"],
|
||||
"rules": {
|
||||
"no-use-before-define": "off",
|
||||
"@typescript-eslint/no-use-before-define": ["error"],
|
||||
"react/jsx-filename-extension": [
|
||||
"warn",
|
||||
{
|
||||
"extensions": [".tsx"]
|
||||
}
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
"ts": "never",
|
||||
"tsx": "never"
|
||||
}
|
||||
],
|
||||
"no-shadow": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"error",
|
||||
{
|
||||
"allowExpressions": true
|
||||
}
|
||||
],
|
||||
"max-len": [
|
||||
"warn",
|
||||
{
|
||||
"code": 80
|
||||
}
|
||||
],
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"import/prefer-default-export": "off",
|
||||
"react/prop-types": "off"
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
14
package.json
14
package.json
|
|
@ -10,7 +10,6 @@
|
|||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"coverage": "vitest run --coverage",
|
||||
"release": "./scripts/release.sh"
|
||||
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian"
|
||||
|
|
@ -19,7 +18,8 @@
|
|||
"authorUrl": "https://friebetill.github.io/",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"diff": "^5.1.0"
|
||||
"diff": "^5.1.0",
|
||||
"obsidian": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^5.0.2",
|
||||
|
|
@ -28,7 +28,15 @@
|
|||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.3",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint": "^8.33.0",
|
||||
"prettier": "^2.8.3",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4",
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
import { TFile } from "obsidian";
|
||||
import { Difference } from "./data/difference";
|
||||
import { replaceLine } from "./utils/string_utils";
|
||||
|
||||
type VoidCallback = () => void;
|
||||
|
||||
export class ActionLine {
|
||||
constructor(args: {
|
||||
difference: Difference;
|
||||
file1: TFile;
|
||||
file2: TFile;
|
||||
triggerRebuild: VoidCallback;
|
||||
}) {
|
||||
this.difference = args.difference;
|
||||
this.file1 = args.file1;
|
||||
this.file2 = args.file2;
|
||||
this.triggerRebuild = args.triggerRebuild;
|
||||
}
|
||||
|
||||
private difference: Difference;
|
||||
private file1: TFile;
|
||||
private file2: TFile;
|
||||
private triggerRebuild: VoidCallback;
|
||||
|
||||
build(container: HTMLDivElement) {
|
||||
const actionLine = container.createDiv({ cls: "flex-row gap-2 py-2" });
|
||||
|
||||
const hasPlusLines = this.difference.lines.some((l) =>
|
||||
l.startsWith("+")
|
||||
);
|
||||
const hasMinusLines = this.difference.lines.some((l) =>
|
||||
l.startsWith("-")
|
||||
);
|
||||
|
||||
if (hasPlusLines && hasMinusLines) {
|
||||
this.buildActionLineButton(actionLine, "Accept Top", (e) =>
|
||||
this.handleAcceptTopClick(e, this.difference)
|
||||
);
|
||||
this.buildActionLineDivider(actionLine);
|
||||
this.buildActionLineButton(actionLine, "Accept Bottom", (e) =>
|
||||
this.handleAcceptBottomClick(e, this.difference)
|
||||
);
|
||||
this.buildActionLineDivider(actionLine);
|
||||
this.buildActionLineButton(actionLine, "Accept All", (e) =>
|
||||
this.handleAcceptAllClick(e, this.difference)
|
||||
);
|
||||
} else if (hasMinusLines) {
|
||||
this.buildActionLineButton(
|
||||
actionLine,
|
||||
`Accept from ${this.file1.name}`,
|
||||
(e) => this.handleAcceptTopClick(e, this.difference)
|
||||
);
|
||||
this.buildActionLineDivider(actionLine);
|
||||
this.buildActionLineButton(actionLine, `Discard`, (e) =>
|
||||
this.handleDiscardClick(e, this.difference)
|
||||
);
|
||||
} else if (hasPlusLines) {
|
||||
this.buildActionLineButton(
|
||||
actionLine,
|
||||
`Accept from ${this.file2.name}`,
|
||||
(e) => this.handleAcceptTopClick(e, this.difference)
|
||||
);
|
||||
this.buildActionLineDivider(actionLine);
|
||||
this.buildActionLineButton(actionLine, `Discard`, (e) =>
|
||||
this.handleDiscardClick(e, this.difference)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildActionLineButton(
|
||||
actionLine: HTMLDivElement,
|
||||
text: string,
|
||||
onClick: (event: MouseEvent) => void
|
||||
) {
|
||||
actionLine
|
||||
.createEl("a", { text, cls: "no-decoration text-xxs text-gray" })
|
||||
.onClickEvent(onClick);
|
||||
}
|
||||
|
||||
private buildActionLineDivider(actionLine: HTMLDivElement) {
|
||||
actionLine.createEl("span", { text: "|", cls: "text-xxs text-gray" });
|
||||
}
|
||||
|
||||
private async handleAcceptTopClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
const file1Content = await app.vault.read(this.file1);
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith("-"))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join("\n");
|
||||
const minusPlusLinesCount = difference.lines.findIndex(
|
||||
(line) => line.startsWith("-") || line.startsWith("+")
|
||||
);
|
||||
const newContent = replaceLine(
|
||||
file1Content,
|
||||
difference.start - 1 + minusPlusLinesCount,
|
||||
changedLines
|
||||
);
|
||||
await app.vault.modify(this.file1, newContent);
|
||||
|
||||
this.triggerRebuild();
|
||||
}
|
||||
|
||||
private async handleAcceptBottomClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
const file1Content = await app.vault.read(this.file1);
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith("+"))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join("\n");
|
||||
const minusPlusLinesCount = difference.lines.findIndex(
|
||||
(line) => line.startsWith("-") || line.startsWith("+")
|
||||
);
|
||||
const newContent = replaceLine(
|
||||
file1Content,
|
||||
difference.start - 1 + minusPlusLinesCount,
|
||||
changedLines
|
||||
);
|
||||
await app.vault.modify(this.file1, newContent);
|
||||
|
||||
this.triggerRebuild();
|
||||
}
|
||||
|
||||
private async handleAcceptAllClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
const file1Content = await app.vault.read(this.file1);
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith("-") || line.startsWith("+"))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join("\n");
|
||||
|
||||
const minusPlusLinesCount = difference.lines.findIndex(
|
||||
(line) => line.startsWith("-") || line.startsWith("+")
|
||||
);
|
||||
const newContent = replaceLine(
|
||||
file1Content,
|
||||
difference.start - 1 + minusPlusLinesCount,
|
||||
changedLines
|
||||
);
|
||||
await app.vault.modify(this.file1, newContent);
|
||||
|
||||
this.triggerRebuild();
|
||||
}
|
||||
|
||||
async handleDiscardClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
const file1Content = await app.vault.read(this.file1);
|
||||
|
||||
const minusPlusLinesCount = difference.lines.findIndex(
|
||||
(line) => line.startsWith("-") || line.startsWith("+")
|
||||
);
|
||||
const newContent = replaceLine(
|
||||
file1Content,
|
||||
difference.start - 1 + minusPlusLinesCount,
|
||||
""
|
||||
);
|
||||
await app.vault.modify(this.file1, newContent);
|
||||
|
||||
this.triggerRebuild();
|
||||
}
|
||||
}
|
||||
172
src/components/action_line.ts
Normal file
172
src/components/action_line.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { TFile } from 'obsidian'
|
||||
import { Difference } from '../data/difference'
|
||||
import { replaceLine } from '../utils/string_utils'
|
||||
import { ActionLineButton } from './action_line_button'
|
||||
import { ActionLineDivider } from './action_line_divider'
|
||||
|
||||
type VoidCallback = () => void
|
||||
|
||||
export class ActionLine {
|
||||
constructor(args: {
|
||||
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
|
||||
}
|
||||
|
||||
private difference: Difference
|
||||
|
||||
private file1: TFile
|
||||
|
||||
private file2: TFile
|
||||
|
||||
private file1Content: string
|
||||
|
||||
private file2Content: string
|
||||
|
||||
private triggerRebuild: VoidCallback
|
||||
|
||||
build(container: HTMLDivElement): void {
|
||||
const actionLine = container.createDiv({ cls: 'flex-row gap-2 py-2' })
|
||||
|
||||
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.handleAcceptTopClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
ActionLineDivider.build(actionLine)
|
||||
new ActionLineButton({
|
||||
text: 'Accept Bottom',
|
||||
onClick: (e) =>
|
||||
this.handleAcceptBottomClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
ActionLineDivider.build(actionLine)
|
||||
new ActionLineButton({
|
||||
text: 'Accept All',
|
||||
onClick: (e) => this.handleAcceptAllClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
} else if (hasMinusLines) {
|
||||
new ActionLineButton({
|
||||
text: `Accept from ${this.file1.name}`,
|
||||
onClick: (e) => this.handleAcceptTopClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
ActionLineDivider.build(actionLine)
|
||||
new ActionLineButton({
|
||||
text: 'Discard',
|
||||
onClick: (e) => this.handleDiscardClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
} else if (hasPlusLines) {
|
||||
new ActionLineButton({
|
||||
text: `Accept from ${this.file2.name}`,
|
||||
onClick: (e) => this.handleAcceptTopClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
ActionLineDivider.build(actionLine)
|
||||
new ActionLineButton({
|
||||
text: 'Discard',
|
||||
onClick: (e) => this.handleDiscardClick(e, this.difference),
|
||||
}).build(actionLine)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAcceptTopClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith('-'))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join('\n')
|
||||
const minusPlusLinesCount = difference.lines.findIndex(
|
||||
(line) => line.startsWith('-') || line.startsWith('+')
|
||||
)
|
||||
const newContent = replaceLine({
|
||||
fullText: this.file1Content,
|
||||
position: difference.start - 1 + minusPlusLinesCount,
|
||||
newLine: changedLines,
|
||||
})
|
||||
await app.vault.modify(this.file1, newContent)
|
||||
|
||||
this.triggerRebuild()
|
||||
}
|
||||
|
||||
private async handleAcceptBottomClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith('+'))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join('\n')
|
||||
const newContent = replaceLine({
|
||||
fullText: this.file1Content,
|
||||
position: difference.start - 1,
|
||||
newLine: changedLines,
|
||||
})
|
||||
await app.vault.modify(this.file1, newContent)
|
||||
|
||||
this.triggerRebuild()
|
||||
}
|
||||
|
||||
private async handleAcceptAllClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const changedLines = difference.lines
|
||||
.filter((line) => line.startsWith('-') || line.startsWith('+'))
|
||||
.map((line) => line.slice(1, line.length))
|
||||
.join('\n')
|
||||
|
||||
const minusPlusLinesCount = difference.lines.findIndex(
|
||||
(line) => line.startsWith('-') || line.startsWith('+')
|
||||
)
|
||||
const newContent = replaceLine({
|
||||
fullText: this.file1Content,
|
||||
position: difference.start - 1 + minusPlusLinesCount,
|
||||
newLine: changedLines,
|
||||
})
|
||||
await app.vault.modify(this.file1, newContent)
|
||||
|
||||
this.triggerRebuild()
|
||||
}
|
||||
|
||||
async handleDiscardClick(
|
||||
event: MouseEvent,
|
||||
difference: Difference
|
||||
): Promise<void> {
|
||||
event.preventDefault()
|
||||
|
||||
const minusPlusLinesCount = difference.lines.findIndex(
|
||||
(line) => line.startsWith('-') || line.startsWith('+')
|
||||
)
|
||||
const newContent = replaceLine({
|
||||
fullText: this.file1Content,
|
||||
position: difference.start - 1 + minusPlusLinesCount,
|
||||
newLine: '',
|
||||
})
|
||||
await app.vault.modify(this.file1, newContent)
|
||||
|
||||
this.triggerRebuild()
|
||||
}
|
||||
}
|
||||
19
src/components/action_line_button.ts
Normal file
19
src/components/action_line_button.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export class ActionLineButton {
|
||||
constructor(args: { text: string; onClick: (e: MouseEvent) => void }) {
|
||||
this.text = args.text
|
||||
this.onClick = args.onClick
|
||||
}
|
||||
|
||||
public text: string
|
||||
|
||||
public onClick: (e: MouseEvent) => void
|
||||
|
||||
build(actionLine: HTMLDivElement): void {
|
||||
actionLine
|
||||
.createEl('a', {
|
||||
text: this.text,
|
||||
cls: 'no-decoration text-xxs text-gray',
|
||||
})
|
||||
.onClickEvent(this.onClick)
|
||||
}
|
||||
}
|
||||
5
src/components/action_line_divider.ts
Normal file
5
src/components/action_line_divider.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class ActionLineDivider {
|
||||
static build(actionLine: HTMLDivElement): void {
|
||||
actionLine.createEl('span', { text: '|', cls: 'text-xxs text-gray' })
|
||||
}
|
||||
}
|
||||
140
src/components/differences_view.ts
Normal file
140
src/components/differences_view.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { structuredPatch } from 'diff'
|
||||
import { ItemView, TFile, 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'
|
||||
|
||||
export const VIEW_TYPE_PATCH = 'patch-view'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private file1: TFile
|
||||
|
||||
private file2: TFile
|
||||
|
||||
private file1Content: string
|
||||
|
||||
private file2Content: string
|
||||
|
||||
private showMergeOption: boolean
|
||||
|
||||
private fileDifferences: FileDifferences
|
||||
|
||||
private file1Lines: string[]
|
||||
|
||||
private lineCount: number
|
||||
|
||||
getViewType(): string {
|
||||
return VIEW_TYPE_PATCH
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return `Difference between ${this.file1.name} and ${this.file2.name}`
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
await this.updateState()
|
||||
this.build()
|
||||
}
|
||||
|
||||
private async updateState(): Promise<void> {
|
||||
// TODO(tillf): Find way to refresh state when one of the files changes
|
||||
|
||||
this.file1Content = await this.app.vault.read(this.file1)
|
||||
this.file1Lines = this.file1Content.split('\n')
|
||||
|
||||
this.file2Content = await this.app.vault.read(this.file2)
|
||||
this.fileDifferences = FileDifferences.fromParsedDiff(
|
||||
structuredPatch(
|
||||
this.file1.path,
|
||||
this.file2.path,
|
||||
this.file1Content,
|
||||
this.file2Content
|
||||
)
|
||||
)
|
||||
|
||||
// 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.start)
|
||||
)
|
||||
}
|
||||
|
||||
private build(): void {
|
||||
this.contentEl.empty()
|
||||
|
||||
const container = this.contentEl.createDiv({ cls: 'container' })
|
||||
|
||||
for (let i = 0; i <= this.lineCount; i++) {
|
||||
const line = i in this.file1Lines ? this.file1Lines[i] : null
|
||||
const difference = this.fileDifferences.differences.find(
|
||||
(d) => d.start === i
|
||||
)
|
||||
|
||||
if (difference != null) {
|
||||
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: 'line',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(tillf): Show delete second file modal if showMergeOption is
|
||||
// enabled and there are no more differences
|
||||
}
|
||||
|
||||
private buildDifferenceVisualizer(
|
||||
container: HTMLDivElement,
|
||||
difference: Difference
|
||||
): void {
|
||||
const triggerRebuild = (): Promise<void> => this.onOpen()
|
||||
|
||||
if (this.showMergeOption) {
|
||||
new ActionLine({
|
||||
difference,
|
||||
file1: this.file1,
|
||||
file2: this.file2,
|
||||
file1Content: this.file1Content,
|
||||
file2Content: this.file2Content,
|
||||
triggerRebuild,
|
||||
}).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',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
26
src/components/select_file_modal.ts
Normal file
26
src/components/select_file_modal.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { App, SuggestModal, TFile } from 'obsidian'
|
||||
|
||||
export class SelectFileModal extends SuggestModal<TFile> {
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly selectableFiles: TFile[],
|
||||
private onChoose: (error: Error | null, result?: TFile) => void
|
||||
) {
|
||||
super(app)
|
||||
}
|
||||
|
||||
getSuggestions(query: string): TFile[] {
|
||||
return this.selectableFiles.filter((file) => {
|
||||
const searchQuery = query?.toLowerCase()
|
||||
return file.name?.toLowerCase().includes(searchQuery)
|
||||
})
|
||||
}
|
||||
|
||||
renderSuggestion(file: TFile, el: HTMLElement): void {
|
||||
el.createEl('div', { text: file.name })
|
||||
}
|
||||
|
||||
onChooseSuggestion(file: TFile): void {
|
||||
this.onChoose(null, file)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
export class Difference {
|
||||
constructor(args: { start: number; lines: string[] }) {
|
||||
this.start = args.start;
|
||||
this.lines = args.lines;
|
||||
this.start = args.start
|
||||
this.lines = args.lines
|
||||
}
|
||||
|
||||
public readonly start: number;
|
||||
public readonly lines: string[];
|
||||
public readonly start: number
|
||||
|
||||
public readonly lines: string[]
|
||||
|
||||
hasChangesFromFile1(): boolean {
|
||||
return this.lines.some((l) => l.startsWith("-"));
|
||||
return this.lines.some((l) => l.startsWith('-'))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
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", () => {
|
||||
describe('FileDifferences.fromParsedDiff', () => {
|
||||
it('should work with files containing one line', () => {
|
||||
const test = JSON.parse(
|
||||
'{"oldFileName":"Hallo.md","newFileName":"Test.md","hunks":[{"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":"Hallo.md","file2Name":"Test.md","differences":[{"start":0,"lines":["-a","+b"]}]}'
|
||||
);
|
||||
});
|
||||
});
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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.
|
||||
|
|
@ -29,23 +29,23 @@ 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) => {
|
||||
for (let i = 0; i < hunk.lines.length; i++) {
|
||||
const line = hunk.lines[i];
|
||||
const line = hunk.lines[i]
|
||||
|
||||
if (line.startsWith("+") || line.startsWith("-")) {
|
||||
let start = i;
|
||||
if (line.startsWith('+') || line.startsWith('-')) {
|
||||
const start = i
|
||||
|
||||
// Find the end of the contiguous lines
|
||||
let end = i;
|
||||
let end = i
|
||||
while (
|
||||
end < hunk.lines.length - 1 &&
|
||||
(hunk.lines[end + 1].startsWith("+") ||
|
||||
hunk.lines[end + 1].startsWith("-"))
|
||||
(hunk.lines[end + 1].startsWith('+') ||
|
||||
hunk.lines[end + 1].startsWith('-'))
|
||||
) {
|
||||
end++;
|
||||
end++
|
||||
}
|
||||
|
||||
// Add the contiguous lines to the differences
|
||||
|
|
@ -54,16 +54,16 @@ export class FileDifferences {
|
|||
start: hunk.oldStart + start - 1,
|
||||
lines: hunk.lines.slice(start, end + 1),
|
||||
})
|
||||
);
|
||||
i += end - start;
|
||||
)
|
||||
i += end - start
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
return new this(
|
||||
parsedDiff.oldFileName!,
|
||||
parsedDiff.newFileName!,
|
||||
differences
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
import { structuredPatch } from "diff";
|
||||
import { ItemView, TFile, WorkspaceLeaf } from "obsidian";
|
||||
import { ActionLine } from "./action_line";
|
||||
import { Difference } from "./data/difference";
|
||||
import { FileDifferences } from "./data/file_differences";
|
||||
import { preventEmptyString } from "./utils/string_utils";
|
||||
|
||||
export const VIEW_TYPE_PATCH = "patch-view";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private file1: TFile;
|
||||
private file2: TFile;
|
||||
private showMergeOption: boolean;
|
||||
|
||||
private fileDifferences: FileDifferences;
|
||||
private file1Lines: string[];
|
||||
private lineCount: number;
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_PATCH;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return `Difference between ${this.file1.name} and ${this.file2.name}`;
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
await this.updateState();
|
||||
this.build();
|
||||
}
|
||||
|
||||
private async updateState() {
|
||||
const file1Content = await this.app.vault.read(this.file1);
|
||||
this.file1Lines = file1Content.split("\n");
|
||||
|
||||
const file2Content = await this.app.vault.read(this.file2);
|
||||
this.fileDifferences = FileDifferences.fromParsedDiff(
|
||||
structuredPatch(
|
||||
this.file1.path,
|
||||
this.file2.path,
|
||||
file1Content,
|
||||
file2Content
|
||||
)
|
||||
);
|
||||
|
||||
// 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.start)
|
||||
);
|
||||
}
|
||||
|
||||
private build() {
|
||||
this.contentEl.empty();
|
||||
|
||||
const container = this.contentEl.createDiv({ cls: "container" });
|
||||
|
||||
for (let i = 0; i <= this.lineCount; i++) {
|
||||
let line = i in this.file1Lines ? this.file1Lines[i] : null;
|
||||
const difference = this.fileDifferences.differences.find(
|
||||
(d) => d.start === i
|
||||
);
|
||||
|
||||
if (difference != null) {
|
||||
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: "line",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildDifferenceVisualizer(
|
||||
container: HTMLDivElement,
|
||||
difference: Difference
|
||||
) {
|
||||
const triggerRebuild = () => this.onOpen();
|
||||
|
||||
if (this.showMergeOption) {
|
||||
new ActionLine({
|
||||
difference: difference,
|
||||
file1: this.file1,
|
||||
file2: this.file2,
|
||||
triggerRebuild: triggerRebuild,
|
||||
}).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",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
70
src/main.ts
70
src/main.ts
|
|
@ -1,25 +1,28 @@
|
|||
import { Editor, Plugin, TFile } from "obsidian";
|
||||
import { Plugin, TFile } from 'obsidian'
|
||||
|
||||
import { DifferencesView } from "./differences_view";
|
||||
import { SelectFileModal } from "./select_file_modal";
|
||||
import { DifferencesView } from './components/differences_view'
|
||||
import { SelectFileModal } from './components/select_file_modal'
|
||||
|
||||
export default class FileDiffPlugin extends Plugin {
|
||||
async onload() {
|
||||
onload(): void {
|
||||
this.addCommand({
|
||||
id: "file-diff",
|
||||
name: "Show differences to another file",
|
||||
editorCallback: async (_: Editor) => {
|
||||
id: 'file-diff',
|
||||
name: 'Show differences to another file',
|
||||
editorCallback: async () => {
|
||||
// Get current active file
|
||||
const activeFile = this.app.workspace.getActiveFile()!;
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (activeFile == null) {
|
||||
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();
|
||||
const workspaceLeaf = this.app.workspace.getLeaf()
|
||||
await workspaceLeaf.open(
|
||||
new DifferencesView({
|
||||
leaf: workspaceLeaf,
|
||||
|
|
@ -27,28 +30,31 @@ export default class FileDiffPlugin extends Plugin {
|
|||
file2: compareFile,
|
||||
showMergeOption: false,
|
||||
})
|
||||
);
|
||||
)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
this.addCommand({
|
||||
id: "file-diff-merge",
|
||||
name: "Show differences and merge options to another file",
|
||||
editorCallback: async (_: Editor) => {
|
||||
id: 'file-diff-merge',
|
||||
name: 'Show differences and merge options to another file',
|
||||
editorCallback: async () => {
|
||||
// TODO(tillf): Show warning when the user selects this option
|
||||
// for the first time
|
||||
|
||||
// Get current active file
|
||||
const activeFile = this.app.workspace.getActiveFile()!;
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (activeFile == null) {
|
||||
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();
|
||||
const workspaceLeaf = this.app.workspace.getLeaf()
|
||||
await workspaceLeaf.open(
|
||||
new DifferencesView({
|
||||
leaf: workspaceLeaf,
|
||||
|
|
@ -56,29 +62,29 @@ export default class FileDiffPlugin extends Plugin {
|
|||
file2: compareFile,
|
||||
showMergeOption: true,
|
||||
})
|
||||
);
|
||||
)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
async getFileToCompare(activeFile: TFile): Promise<TFile | undefined> {
|
||||
const selectableFiles = this.app.vault.getFiles();
|
||||
selectableFiles.remove(activeFile);
|
||||
getFileToCompare(activeFile: TFile): Promise<TFile | undefined> {
|
||||
const selectableFiles = this.app.vault.getFiles()
|
||||
selectableFiles.remove(activeFile)
|
||||
return this.showSelectOtherFileModal({
|
||||
selectableFiles: selectableFiles,
|
||||
});
|
||||
selectableFiles,
|
||||
})
|
||||
}
|
||||
|
||||
async showSelectOtherFileModal(args: {
|
||||
selectableFiles: TFile[];
|
||||
showSelectOtherFileModal(args: {
|
||||
selectableFiles: TFile[]
|
||||
}): Promise<TFile | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new SelectFileModal(
|
||||
new SelectFileModal(
|
||||
this.app,
|
||||
args.selectableFiles,
|
||||
(error, selectedFile) =>
|
||||
error ? reject(error) : resolve(selectedFile)
|
||||
).open();
|
||||
});
|
||||
).open()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { App, SuggestModal, TFile } from "obsidian";
|
||||
|
||||
export class SelectFileModal extends SuggestModal<TFile> {
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly selectableFiles: TFile[],
|
||||
private onChoose: (error: Error | null, result?: TFile) => void
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
getSuggestions(query: string): TFile[] {
|
||||
return this.selectableFiles.filter((file) => {
|
||||
const searchQuery = query?.toLowerCase();
|
||||
return file.name?.toLowerCase().includes(searchQuery);
|
||||
});
|
||||
}
|
||||
|
||||
renderSuggestion(file: TFile, el: HTMLElement) {
|
||||
el.createEl("div", { text: file.name });
|
||||
}
|
||||
|
||||
onChooseSuggestion(file: TFile) {
|
||||
this.onChoose(null, file);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,24 @@
|
|||
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 the line at the given line number with the new content", () => {
|
||||
const content = "line1\nline2\nline3";
|
||||
const newContent = replaceLine(content, 1, "newLine2");
|
||||
expect(newContent).toBe("line1\nnewLine2\nline3");
|
||||
});
|
||||
describe('replaceLine', () => {
|
||||
it('should replace the line at the given line number with the new content', () => {
|
||||
const content = 'line1\nline2\nline3'
|
||||
const newContent = replaceLine({
|
||||
fullText: content,
|
||||
position: 1,
|
||||
newLine: 'newLine2',
|
||||
})
|
||||
expect(newContent).toBe('line1\nnewLine2\nline3')
|
||||
})
|
||||
|
||||
it("should replace the line at the given line number with multiple lines", () => {
|
||||
const content = "line1\nline2\nline3";
|
||||
const newContent = replaceLine(content, 1, "newLine2\nnewLine3");
|
||||
expect(newContent).toBe("line1\nnewLine2\nnewLine3\nline3");
|
||||
});
|
||||
});
|
||||
it('should replace the line at the given line number with multiple lines', () => {
|
||||
const content = 'line1\nline2\nline3'
|
||||
const newContent = replaceLine({
|
||||
fullText: content,
|
||||
position: 1,
|
||||
newLine: 'newLine2\nnewLine3',
|
||||
})
|
||||
expect(newContent).toBe('line1\nnewLine2\nnewLine3\nline3')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
/** Method to replace line in a string */
|
||||
export function replaceLine(
|
||||
text: string,
|
||||
lineNumber: number,
|
||||
contentToInsert: string
|
||||
): string {
|
||||
const lines = text.split("\n");
|
||||
lines[lineNumber] = contentToInsert;
|
||||
return lines.join("\n");
|
||||
export function replaceLine(args: {
|
||||
fullText: string
|
||||
position: number
|
||||
newLine: string
|
||||
}): string {
|
||||
const lines = this.fullText.split('\n')
|
||||
lines[this.position] = this.newLine
|
||||
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 : ''
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue