Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions src/features/editor/components/editor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "../styles/overlay-editor.css";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { useGitGutter } from "@/features/version-control/git/controllers/use-gutter";
import { useZoomStore } from "@/stores/zoom-store";
Expand Down Expand Up @@ -542,6 +542,9 @@ export function Editor({
lastScrollRef.current = { top: scrollTop, left: scrollLeft };
isScrollingRef.current = true;

// Capture buffer ID NOW, before RAF executes (buffer might change by then)
const currentBufferId = bufferId;

// Clear existing scroll timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
Expand All @@ -565,8 +568,8 @@ export function Editor({
multiCursorRef.current.style.transform = `translate(-${left}px, -${top}px)`;
}

// Update state store for Vim motions and cursor visibility
useEditorStateStore.getState().actions.setScroll(top, left);
// Update state store with captured buffer ID to avoid race condition
useEditorStateStore.getState().actions.setScrollForBuffer(currentBufferId, top, left);

// Update viewport tracking in the same RAF
handleViewportScroll(top, lines.length);
Expand All @@ -580,7 +583,7 @@ export function Editor({
isScrollingRef.current = false;
}, 150);
},
[handleViewportScroll, lines.length],
[bufferId, handleViewportScroll, lines.length],
);

useEffect(() => {
Expand Down Expand Up @@ -687,21 +690,15 @@ export function Editor({
viewportRange,
]);

// Restore cursor position when switching buffers (deferred to ensure content sync)
useEffect(() => {
// Restore cursor and scroll position when switching buffers
// Using useLayoutEffect to apply scroll before paint, avoiding visual flash
useLayoutEffect(() => {
if (!bufferId) return;

// Only restore when bufferId changes (not on initial mount)
if (prevBufferIdRef.current !== null && prevBufferIdRef.current !== bufferId) {
isBufferSwitchRef.current = true;
requestAnimationFrame(() => {
// Only restore if we're still on the same buffer (user might have switched again)
const currentBufferId = useBufferStore.getState().activeBufferId;
if (currentBufferId === bufferId) {
useEditorStateStore.getState().actions.restorePositionForFile(bufferId);
}
// Flag will be cleared by the cursor positioning effect after applying position
});
useEditorStateStore.getState().actions.restorePositionForFile(bufferId);
}
prevBufferIdRef.current = bufferId;
}, [bufferId]);
Expand Down
125 changes: 101 additions & 24 deletions src/features/editor/stores/state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,63 @@ import { createSelectors } from "@/utils/zustand-selectors";
import { useBufferStore } from "./buffer-store";
import { useEditorSettingsStore } from "./settings-store";

// Position Cache Manager
class PositionCacheManager {
private cache = new Map<string, Position>();
// Types for editor state caching
interface EditorViewState {
cursor: Position;
scrollTop: number;
scrollLeft: number;
}

// Editor View State Cache Manager - caches cursor position and scroll offset per buffer
class EditorViewStateCacheManager {
private cache = new Map<string, EditorViewState>();
private readonly MAX_CACHE_SIZE = EDITOR_CONSTANTS.MAX_POSITION_CACHE_SIZE;

set(bufferId: string, position: Position): void {
const cachedPosition = this.cache.get(bufferId);
if (cachedPosition && this.positionsEqual(cachedPosition, position)) {
setCursor(bufferId: string, position: Position): void {
const cached = this.cache.get(bufferId);
if (cached && this.positionsEqual(cached.cursor, position)) {
return;
}

if (this.cache.size >= this.MAX_CACHE_SIZE && !this.cache.has(bufferId)) {
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.cache.delete(firstKey);
}
this.ensureCacheSize(bufferId);

const existing = this.cache.get(bufferId);
this.cache.set(bufferId, {
cursor: { ...position },
scrollTop: existing?.scrollTop ?? 0,
scrollLeft: existing?.scrollLeft ?? 0,
});
}

setScroll(bufferId: string, scrollTop: number, scrollLeft: number): void {
const existing = this.cache.get(bufferId);
if (existing && existing.scrollTop === scrollTop && existing.scrollLeft === scrollLeft) {
return;
}

this.cache.set(bufferId, { ...position });
this.ensureCacheSize(bufferId);

this.cache.set(bufferId, {
cursor: existing?.cursor ?? { line: 0, column: 0, offset: 0 },
scrollTop,
scrollLeft,
});
}

get(bufferId: string): EditorViewState | null {
const cached = this.cache.get(bufferId);
if (!cached) return null;
return {
cursor: { ...cached.cursor },
scrollTop: cached.scrollTop,
scrollLeft: cached.scrollLeft,
};
}

get(bufferId: string): Position | null {
const cachedPosition = this.cache.get(bufferId);
if (!cachedPosition) return null;
return { ...cachedPosition };
getCursor(bufferId: string): Position | null {
const cached = this.cache.get(bufferId);
if (!cached) return null;
return { ...cached.cursor };
}

clear(bufferId?: string): void {
Expand All @@ -43,12 +75,21 @@ class PositionCacheManager {
}
}

private ensureCacheSize(bufferId: string): void {
if (this.cache.size >= this.MAX_CACHE_SIZE && !this.cache.has(bufferId)) {
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.cache.delete(firstKey);
}
}
}

private positionsEqual(pos1: Position, pos2: Position): boolean {
return pos1.line === pos2.line && pos1.column === pos2.column && pos1.offset === pos2.offset;
}
}

const positionCache = new PositionCacheManager();
const viewStateCache = new EditorViewStateCacheManager();

const ensureCursorVisible = (position: Position) => {
if (typeof window === "undefined") return;
Expand Down Expand Up @@ -123,6 +164,7 @@ interface EditorStateActions {

// Layout actions
setScroll: (scrollTop: number, scrollLeft: number) => void;
setScrollForBuffer: (bufferId: string | null, scrollTop: number, scrollLeft: number) => void;
setViewportHeight: (height: number) => void;

// Instance actions
Expand Down Expand Up @@ -164,20 +206,38 @@ export const useEditorStateStore = createSelectors(
setCursorPosition: (position) => {
const activeBufferId = useBufferStore.getState().activeBufferId;
if (activeBufferId) {
positionCache.set(activeBufferId, position);
viewStateCache.setCursor(activeBufferId, position);
}
set({ cursorPosition: position });
ensureCursorVisible(position);
},
setSelection: (selection) => set({ selection }),
setDesiredColumn: (column) => set({ desiredColumn: column }),
setCursorVisibility: (visible) => set({ cursorVisible: visible }),
getCachedPosition: (bufferId) => positionCache.get(bufferId),
clearPositionCache: (bufferId) => positionCache.clear(bufferId),
getCachedPosition: (bufferId) => viewStateCache.getCursor(bufferId),
clearPositionCache: (bufferId) => viewStateCache.clear(bufferId),
restorePositionForFile: (bufferId) => {
const cachedPosition = positionCache.get(bufferId);
if (cachedPosition) {
set({ cursorPosition: cachedPosition });
const cachedState = viewStateCache.get(bufferId);
if (cachedState) {
// Restore cursor position and scroll offset together
set({
cursorPosition: cachedState.cursor,
scrollTop: cachedState.scrollTop,
scrollLeft: cachedState.scrollLeft,
});
// Apply scroll position to DOM elements synchronously to avoid flash
const viewport = document.querySelector(".editor-viewport") as HTMLDivElement | null;
const textarea = document.querySelector(
".editor-textarea",
) as HTMLTextAreaElement | null;
if (viewport) {
viewport.scrollTop = cachedState.scrollTop;
viewport.scrollLeft = cachedState.scrollLeft;
}
if (textarea) {
textarea.scrollTop = cachedState.scrollTop;
textarea.scrollLeft = cachedState.scrollLeft;
}
return true;
}
// Reset to beginning for files with no cached position
Expand Down Expand Up @@ -298,7 +358,24 @@ export const useEditorStateStore = createSelectors(
}),

// Layout actions
setScroll: (scrollTop, scrollLeft) => set({ scrollTop, scrollLeft }),
setScroll: (scrollTop, scrollLeft) => {
const activeBufferId = useBufferStore.getState().activeBufferId;
if (activeBufferId) {
viewStateCache.setScroll(activeBufferId, scrollTop, scrollLeft);
}
set({ scrollTop, scrollLeft });
},
setScrollForBuffer: (bufferId, scrollTop, scrollLeft) => {
// Cache scroll for the specified buffer (avoids race condition when buffer switches)
if (bufferId) {
viewStateCache.setScroll(bufferId, scrollTop, scrollLeft);
}
// Only update global state if this is still the active buffer
const activeBufferId = useBufferStore.getState().activeBufferId;
if (bufferId === activeBufferId) {
set({ scrollTop, scrollLeft });
}
},
setViewportHeight: (height) => set({ viewportHeight: height }),

// Instance actions
Expand Down