Skip to content
2 changes: 1 addition & 1 deletion src/features/editor/components/code-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ const CodeEditor = ({
clearTimeout(searchTimerRef.current);
}

if (!enableInteractiveServices || !isFindVisible) {
if (!enableInteractiveServices) {
setSearchMatches([]);
setCurrentMatchIndex(-1);
return;
Expand Down
171 changes: 171 additions & 0 deletions src/features/editor/history/tests/history-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { enableMapSet } from "immer";

enableMapSet();

vi.mock("@tauri-apps/api/webviewWindow", () => ({
getCurrentWebviewWindow: () => ({
listen: vi.fn(),
onDragDropEvent: vi.fn(),
}),
}));

vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({
listen: vi.fn(),
}),
}));

import { useHistoryStore } from "@/features/editor/stores/history-store";

describe("history-store", () => {
const BUFFER_ID = "test-buffer";

beforeEach(() => {
useHistoryStore.getState().actions.clearHistory(BUFFER_ID);
});

const makeEntry = (content: string, overrides?: Record<string, unknown>) => ({
content,
timestamp: Date.now(),
...overrides,
});

it("starts with empty history", () => {
const { canUndo, canRedo } = useHistoryStore.getState().actions;
expect(canUndo(BUFFER_ID)).toBe(false);
expect(canRedo(BUFFER_ID)).toBe(false);
});

it("pushHistory adds an entry and enables undo", () => {
const { pushHistory, canUndo, canRedo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("content-v1"));
expect(canUndo(BUFFER_ID)).toBe(true);
expect(canRedo(BUFFER_ID)).toBe(false);
});

it("undo returns the last pushed entry", () => {
const { pushHistory, undo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("content-v1"));
const entry = undo(BUFFER_ID);
expect(entry?.content).toBe("content-v1");
});

it("after undo, canRedo is true", () => {
const { pushHistory, undo, canRedo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("content-v1"));
undo(BUFFER_ID);
expect(canRedo(BUFFER_ID)).toBe(true);
});

it("redo returns the undone entry", () => {
const { pushHistory, undo, redo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("content-v1"));
undo(BUFFER_ID);
const entry = redo(BUFFER_ID);
expect(entry?.content).toBe("content-v1");
});

it("after redo, canRedo is false again", () => {
const { pushHistory, undo, redo, canRedo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("content-v1"));
undo(BUFFER_ID);
redo(BUFFER_ID);
expect(canRedo(BUFFER_ID)).toBe(false);
});

it("multiple undo/redo cycles work correctly", () => {
const { pushHistory, undo, redo, canUndo, canRedo } = useHistoryStore.getState().actions;

pushHistory(BUFFER_ID, makeEntry("v1"));
pushHistory(BUFFER_ID, makeEntry("v2"));
pushHistory(BUFFER_ID, makeEntry("v3"));

expect(canUndo(BUFFER_ID)).toBe(true);

expect(undo(BUFFER_ID)?.content).toBe("v3");
expect(undo(BUFFER_ID)?.content).toBe("v2");
expect(undo(BUFFER_ID)?.content).toBe("v1");
expect(canUndo(BUFFER_ID)).toBe(false);
expect(canRedo(BUFFER_ID)).toBe(true);

expect(redo(BUFFER_ID)?.content).toBe("v1");
expect(redo(BUFFER_ID)?.content).toBe("v2");
expect(redo(BUFFER_ID)?.content).toBe("v3");
expect(canUndo(BUFFER_ID)).toBe(true);
expect(canRedo(BUFFER_ID)).toBe(false);
});

it("pushHistory after undo clears the future stack", () => {
const { pushHistory, undo, canRedo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("v1"));
pushHistory(BUFFER_ID, makeEntry("v2"));
undo(BUFFER_ID); // back to v1
pushHistory(BUFFER_ID, makeEntry("v3"));
// Future should be cleared
expect(canRedo(BUFFER_ID)).toBe(false);
});

it("pushHistory deduplicates identical consecutive content", () => {
const { pushHistory, undo, canUndo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("same-content"));
pushHistory(BUFFER_ID, makeEntry("same-content"));
// Second push should be skipped because content matches top of past
undo(BUFFER_ID);
expect(canUndo(BUFFER_ID)).toBe(false);
});

it("pushHistory preserves cursor position in entry", () => {
const { pushHistory, undo } = useHistoryStore.getState().actions;
pushHistory(
BUFFER_ID,
makeEntry("content", {
cursorPosition: { line: 5, column: 10, offset: 42 },
}),
);
const entry = undo(BUFFER_ID);
expect(entry?.cursorPosition).toEqual({ line: 5, column: 10, offset: 42 });
});

it("pushHistory enforces max history size", () => {
const { pushHistory, undo } = useHistoryStore.getState().actions;
// Default max is 100; push 101 entries
for (let i = 1; i <= 101; i++) {
pushHistory(BUFFER_ID, makeEntry(`v${i}`));
}
// The oldest entry (v1) should have been evicted
let oldest = null;
for (let i = 0; i < 101; i++) {
oldest = undo(BUFFER_ID);
}
// After 100 undos, canUndo should be false (v1 was evicted)
const { canUndo } = useHistoryStore.getState().actions;
expect(canUndo(BUFFER_ID)).toBe(false);
});

it("clearHistory resets history for a buffer", () => {
const { pushHistory, clearHistory, canUndo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("v1"));
clearHistory(BUFFER_ID);
expect(canUndo(BUFFER_ID)).toBe(false);
});

it("clearAllHistories resets all buffer histories", () => {
const { pushHistory, clearAllHistories, canUndo } = useHistoryStore.getState().actions;
pushHistory(BUFFER_ID, makeEntry("v1"));
pushHistory("another-buffer", makeEntry("v2"));
clearAllHistories();
expect(canUndo(BUFFER_ID)).toBe(false);
expect(canUndo("another-buffer")).toBe(false);
});

it("undo when already at start returns null", () => {
const { undo } = useHistoryStore.getState().actions;
expect(undo(BUFFER_ID)).toBeNull();
});

it("redo when already at end returns null", () => {
const { redo } = useHistoryStore.getState().actions;
expect(redo(BUFFER_ID)).toBeNull();
});
});
7 changes: 7 additions & 0 deletions src/features/editor/stores/history-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { current } from "immer";
import isEqual from "fast-deep-equal";
import { immer } from "zustand/middleware/immer";
import { createWithEqualityFn } from "zustand/traditional";
Expand Down Expand Up @@ -60,6 +61,12 @@ export const useHistoryStore = createSelectors(
return;
}

// Skip if content is identical to the top of the past stack (dedup)
const topEntry = history.past[history.past.length - 1];
if (topEntry && topEntry.content === entry.content) {
return;
}

// Add to past
history.past.push(entry);

Expand Down
43 changes: 8 additions & 35 deletions src/features/vim/core/actions/paste-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* Paste actions (p, P)
*/

import { calculateOffsetFromPosition } from "@/features/editor/utils/position";
import {
calculateCursorPosition,
calculateOffsetFromPosition,
} from "@/features/editor/utils/position";
import { useVimStore } from "@/features/vim/stores/vim-store";
import type { Action, EditorContext } from "../core/types";

Expand Down Expand Up @@ -57,23 +60,8 @@ export const pasteAction: Action = {

const newOffset = pasteOffset + clipboard.content.length - 1;
const newLines = newContent.split("\n");
let line = 0;
let offset = 0;

for (let i = 0; i < newLines.length; i++) {
if (offset + newLines[i].length >= newOffset) {
line = i;
break;
}
offset += newLines[i].length + 1;
}

const column = newOffset - offset;
setCursorPosition({
line,
column: Math.max(0, column),
offset: Math.max(0, newOffset),
});
const newCursorPosition = calculateCursorPosition(Math.max(0, newOffset), newLines);
setCursorPosition(newCursorPosition);
}
},
};
Expand Down Expand Up @@ -116,23 +104,8 @@ export const pasteBeforeAction: Action = {

const newOffset = cursor.offset + clipboard.content.length - 1;
const newLines = newContent.split("\n");
let line = 0;
let offset = 0;

for (let i = 0; i < newLines.length; i++) {
if (offset + newLines[i].length >= newOffset) {
line = i;
break;
}
offset += newLines[i].length + 1;
}

const column = newOffset - offset;
setCursorPosition({
line,
column: Math.max(0, column),
offset: Math.max(0, newOffset),
});
const newCursorPosition = calculateCursorPosition(Math.max(0, newOffset), newLines);
setCursorPosition(newCursorPosition);
}
},
};
15 changes: 4 additions & 11 deletions src/features/vim/core/actions/replace-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import { calculateCursorPosition } from "@/features/editor/utils/position";
import type { Action, EditorContext } from "../core/types";
import { setVimClipboard } from "../operators/yank-operator";

/**
* Replace action factory - creates a replace action for a specific character
Expand All @@ -14,7 +13,7 @@ export const createReplaceAction = (char: string, count = 1): Action => ({
repeatable: true,

execute: (context: EditorContext): void => {
const { content, updateContent, setCursorPosition, cursor } = context;
const { content, updateContent, setCursorPosition, cursor, facade } = context;

if (cursor.offset >= content.length) {
return;
Expand All @@ -28,8 +27,8 @@ export const createReplaceAction = (char: string, count = 1): Action => ({
return;
}

// Store replaced characters in clipboard for undo/redo parity
setVimClipboard({ content: replacedSegment, linewise: false });
// Note: vim's 'r' does NOT affect any register, so we intentionally
// do NOT call setVimClipboard here.

const replacementText = char.repeat(replacedSegment.length);
const newContent =
Expand All @@ -45,13 +44,7 @@ export const createReplaceAction = (char: string, count = 1): Action => ({
const newCursorPosition = calculateCursorPosition(newCursorOffset, newLines);

setCursorPosition(newCursorPosition);

// Update textarea cursor
const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement;
if (textarea) {
textarea.selectionStart = textarea.selectionEnd = newCursorPosition.offset;
textarea.dispatchEvent(new Event("select"));
}
facade.collapseSelection(newCursorPosition.offset);
},
});

Expand Down
Loading
Loading