logancyang_obsidian-copilot/src/components/composer/ApplyView.tsx
2025-05-31 22:30:54 -07:00

391 lines
13 KiB
TypeScript

import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import { Change } from "diff";
import { Check, X as XIcon } from "lucide-react";
import { App, ItemView, Notice, TFile, WorkspaceLeaf } from "obsidian";
import React, { useRef } from "react";
import { createRoot } from "react-dom/client";
import { Button } from "../ui/button";
import { useState } from "react";
import { getChangeBlocks } from "@/composerUtils";
export const APPLY_VIEW_TYPE = "obsidian-copilot-apply-view";
export interface ApplyViewState {
changes: Change[];
path: string;
}
// Extended Change interface to track user decisions
interface ExtendedChange extends Change {
accepted: boolean | null;
}
export class ApplyView extends ItemView {
private root: ReturnType<typeof createRoot> | null = null;
private state: ApplyViewState | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return APPLY_VIEW_TYPE;
}
getDisplayText(): string {
return "Preview Changes";
}
async setState(state: ApplyViewState) {
this.state = state;
this.render();
}
async onOpen() {
this.render();
}
async onClose() {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
private render() {
if (!this.state) return;
// The second child is the actual content of the view, and the first child is the title of the view
// NOTE: While no official documentation is found, this seems like a standard pattern across community plugins.
const contentEl = this.containerEl.children[1];
contentEl.empty();
const rootEl = contentEl.createDiv();
if (!this.root) {
this.root = createRoot(rootEl);
}
this.root.render(
<ApplyViewRoot app={this.app} state={this.state} close={() => this.leaf.detach()} />
);
}
}
interface ApplyViewRootProps {
app: App;
state: ApplyViewState;
close: () => void;
}
const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
const [diff, setDiff] = useState<ExtendedChange[]>(() => {
return state.changes.map((change) => ({
...change,
accepted: null, // Start with null (undecided)
}));
});
// Group changes into blocks for better UI presentation
const changeBlocks = getChangeBlocks(diff);
// Add refs to track change blocks
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
// Add defensive check for state after hooks
if (!state || !state.changes) {
logError("Invalid state:", state);
return (
<div className="tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-center">
<div className="tw-text-error">Error: Invalid state - missing changes</div>
<Button onClick={close} className="tw-mt-4">
Close
</Button>
</div>
);
}
// Apply all changes regardless of whether they have been marked as accepted
const handleAccept = async () => {
try {
// Mark all undecided changes as accepted
const updatedDiff = diff.map((change) =>
change.accepted === null ? { ...change, accepted: true } : change
);
await applyDecidedChangesToFile(updatedDiff);
} catch (error) {
logError("Error applying changes:", error);
new Notice(`Error applying changes: ${error.message}`);
}
};
// Handle rejecting all changes
const handleReject = async () => {
try {
// Mark all undecided changes as rejected
const updatedDiff = diff.map((change) =>
change.accepted === null ? { ...change, accepted: false } : change
);
await applyDecidedChangesToFile(updatedDiff);
} catch (error) {
logError("Error applying changes:", error);
new Notice(`Error applying changes: ${error.message}`);
}
};
// Shared function to apply changes to file
const applyDecidedChangesToFile = async (updatedDiff: ExtendedChange[]) => {
// Apply changes based on their accepted status
const newContent = updatedDiff
.filter((change) => {
if (change.added) return change.accepted === true; // Include if accepted
if (change.removed) return change.accepted === false; // Include if rejected
return true; // Keep unchanged lines
})
.map((change) => change.value)
.join("");
const file = app.vault.getAbstractFileByPath(state.path);
if (!file || !(file instanceof TFile)) {
new Notice("File not found:" + state.path);
close();
return;
}
await app.vault.modify(file, newContent);
new Notice("Changes applied successfully");
close();
};
// Function to focus on the next change block or scroll to top if it's the last block
const focusNextChangeBlock = (currentBlockIndex: number) => {
if (!changeBlocks) return;
// Find the next block with changes that is undecided
let nextBlockIndex = -1;
for (let i = currentBlockIndex + 1; i < changeBlocks.length; i++) {
const block = changeBlocks[i];
const hasChanges = block.some((change) => change.added || change.removed);
const isUndecided = block.some(
(change) => (change.added || change.removed) && (change as ExtendedChange).accepted === null
);
if (hasChanges && isUndecided) {
nextBlockIndex = i;
break;
}
}
// If there's a next block, scroll to it
if (nextBlockIndex !== -1 && blockRefs.current[nextBlockIndex]) {
blockRefs.current[nextBlockIndex]?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
// Accept a block of changes
const acceptBlock = (blockIndex: number) => {
setDiff((prevDiff) => {
const newDiff = [...prevDiff];
const block = changeBlocks?.[blockIndex];
if (!block) return newDiff;
// Find the indices of the changes in this block
block.forEach((blockChange) => {
const index = newDiff.findIndex((change) => change === blockChange);
if (index !== -1) {
newDiff[index] = {
...newDiff[index],
accepted: true,
};
}
});
return newDiff;
});
// Focus on the next change block after state update
setTimeout(() => focusNextChangeBlock(blockIndex), 0);
};
// Reject a block of changes
const rejectBlock = (blockIndex: number) => {
setDiff((prevDiff) => {
const newDiff = [...prevDiff];
const block = changeBlocks?.[blockIndex];
if (!block) return newDiff;
// Find the indices of the changes in this block
block.forEach((blockChange) => {
const index = newDiff.findIndex((change) => change === blockChange);
if (index !== -1) {
newDiff[index] = {
...newDiff[index],
accepted: false,
};
}
});
return newDiff;
});
// Focus on the next change block after state update
setTimeout(() => focusNextChangeBlock(blockIndex), 0);
};
return (
<div className="tw-relative tw-flex tw-h-full tw-flex-col">
<div className="tw-fixed tw-bottom-2 tw-left-1/2 tw-z-[1] tw-flex tw-gap-2 tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-secondary tw-p-2">
<Button variant="destructive" size="sm" onClick={handleReject}>
<XIcon className="tw-size-4" />
Reject
</Button>
<Button variant="success" size="sm" onClick={handleAccept}>
<Check className="tw-size-4" />
Accept
</Button>
</div>
<div className="tw-flex tw-items-center tw-border-b tw-border-solid tw-border-border tw-p-2 tw-text-sm tw-font-medium">
{state.path}
</div>
<div className="tw-flex-1 tw-overflow-auto tw-p-2">
{changeBlocks?.map((block, blockIndex) => {
// Check if this block contains any changes (added or removed)
const hasChanges = block.some((change) => change.added || change.removed);
// Get the decision status for this block
const blockStatus = hasChanges
? block.every(
(change) =>
(!change.added && !change.removed) || (change as ExtendedChange).accepted === true
)
? "accepted"
: block.every(
(change) =>
(!change.added && !change.removed) ||
(change as ExtendedChange).accepted === false
)
? "rejected"
: "undecided"
: "unchanged";
return (
<div
key={blockIndex}
ref={(el) => (blockRefs.current[blockIndex] = el)}
className={cn(
"tw-mb-4 tw-overflow-hidden tw-rounded-md tw-border tw-border-solid tw-border-border",
{
"tw-border-solid": blockStatus !== "unchanged",
}
)}
>
{blockStatus === "accepted" ? (
// Show only the accepted version
<div className="tw-flex-1 tw-whitespace-pre-wrap tw-px-2 tw-py-1 tw-font-mono tw-text-sm tw-text-normal">
{block
.filter((change) => !change.removed)
.map((change, idx) => (
<div key={idx}>{change.value}</div>
))}
</div>
) : blockStatus === "rejected" ? (
// Show only the original version
<div className="tw-flex-1 tw-whitespace-pre-wrap tw-px-2 tw-py-1 tw-font-mono tw-text-sm tw-text-normal">
{block
.filter((change) => !change.added)
.map((change, idx) => (
<div key={idx}>{change.value}</div>
))}
</div>
) : (
// Show the diff view for undecided blocks
block.map((change, changeIndex) => (
<div
key={`${blockIndex}-${changeIndex}`}
className={cn("tw-relative", {
"tw-bg-success tw-border-l-green": change.added,
"tw-bg-error tw-border-l-red": change.removed,
})}
>
<div className="tw-flex-1 tw-whitespace-pre-wrap tw-px-2 tw-py-1 tw-font-mono tw-text-sm tw-text-normal">
{change.value}
</div>
</div>
))
)}
{/* Only show accept/reject buttons for blocks with changes that are undecided */}
{hasChanges && blockStatus === "undecided" && (
<div className="tw-flex tw-items-center tw-justify-end tw-border-[0px] tw-border-t tw-border-solid tw-border-border tw-p-2">
<div className="tw-flex tw-items-center tw-gap-2">
<Button variant="destructive" size="sm" onClick={() => rejectBlock(blockIndex)}>
<XIcon className="tw-size-4" />
Reject
</Button>
<Button variant="success" size="sm" onClick={() => acceptBlock(blockIndex)}>
<Check className="tw-size-4" />
Accept
</Button>
</div>
</div>
)}
{/* Show status for decided blocks with revert option */}
{hasChanges && (blockStatus === "accepted" || blockStatus === "rejected") && (
<div className="tw-flex tw-items-center tw-justify-end tw-border-[0px] tw-border-t tw-border-solid tw-border-border tw-p-2">
<div className="tw-flex tw-items-center tw-gap-2">
<div className="tw-mr-2 tw-text-sm tw-font-medium">
{blockStatus === "accepted" ? (
<div className="tw-flex tw-items-center tw-gap-1 tw-text-success">
<Check className="tw-size-4" />
<div>Accepted</div>
</div>
) : (
<div className="tw-flex tw-items-center tw-gap-1 tw-text-error">
<XIcon className="tw-size-4" />
<div>Rejected</div>
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
// Reset the block to undecided state
setDiff((prevDiff) => {
const newDiff = [...prevDiff];
const block = changeBlocks?.[blockIndex];
if (!block) return newDiff;
block.forEach((blockChange) => {
const index = newDiff.findIndex((change) => change === blockChange);
if (index !== -1) {
newDiff[index] = {
...newDiff[index],
accepted: null,
};
}
});
return newDiff;
});
}}
>
Revert
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
};