Skip to content

Commit b30aa46

Browse files
0xDAEF0Fclaude
andauthored
Fix scroll position bugs and implement jump list navigation (#512)
* Fix bugs scroll position changing buffers * Hide autocomplete panel on cursor navigation and buffer switch * Implement jump list for Go to Definition navigation Add navigation history tracking that records cursor positions when using Go to Definition (Cmd+Click or F12), allowing users to navigate back and forward through their code exploration history. Features: - Back/forward arrows in breadcrumb toolbar (left side) - Keyboard shortcuts: Ctrl+- (back) and Ctrl+Shift+- (forward) - Saves current position before navigating back for proper forward navigation - Deduplicates entries within 5 lines threshold - Reopens closed files when navigating to them - Hides completions when navigating via jump list New files: - jump-list-store.ts: Zustand store for navigation history - jump-navigation.ts: Utility for navigating to jump entries Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent a6b020c commit b30aa46

File tree

11 files changed

+446
-7
lines changed

11 files changed

+446
-7
lines changed

src/features/editor/components/editor.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,13 @@ export function Editor({
743743
inputRef.current.selectionStart = safeOffset;
744744
inputRef.current.selectionEnd = safeOffset;
745745
}
746-
inputRef.current.focus();
746+
// Save scroll before focus (focus can scroll to show cursor)
747+
const scrollTop = inputRef.current.scrollTop;
748+
const scrollLeft = inputRef.current.scrollLeft;
749+
inputRef.current.focus({ preventScroll: true });
750+
// Restore scroll in case focus changed it
751+
inputRef.current.scrollTop = scrollTop;
752+
inputRef.current.scrollLeft = scrollLeft;
747753
// Clear the buffer switch flag after cursor is positioned
748754
isBufferSwitchRef.current = false;
749755
}

src/features/editor/components/layers/input-layer.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Uses uncontrolled input for optimal typing performance
55
*/
66

7-
import { memo, useCallback, useEffect, useRef } from "react";
7+
import { memo, useCallback, useLayoutEffect, useRef } from "react";
88

99
interface InputLayerProps {
1010
content: string;
@@ -53,8 +53,9 @@ const InputLayerComponent = ({
5353
[onInput],
5454
);
5555

56-
// Sync textarea value ONLY when buffer switches
57-
useEffect(() => {
56+
// Sync textarea value when buffer switches
57+
// Uses useLayoutEffect to run before parent's scroll restoration
58+
useLayoutEffect(() => {
5859
if (ref.current && ref.current.value !== content) {
5960
ref.current.value = content;
6061
}

src/features/editor/components/toolbar/breadcrumb.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { ArrowLeft, ChevronRight, Eye, Search, Sparkles } from "lucide-react";
1+
import { ArrowLeft, ArrowRight, ChevronRight, Eye, Search, Sparkles } from "lucide-react";
22
import { type RefObject, useRef, useState } from "react";
33
import { createPortal } from "react-dom";
44
import { useEventListener, useOnClickOutside } from "usehooks-ts";
55
import { EDITOR_CONSTANTS } from "@/features/editor/config/constants";
66
import { useBufferStore } from "@/features/editor/stores/buffer-store";
7+
import { useJumpListStore } from "@/features/editor/stores/jump-list-store";
78
import { useEditorStateStore } from "@/features/editor/stores/state-store";
9+
import { navigateToJumpEntry } from "@/features/editor/utils/jump-navigation";
810
import { logger } from "@/features/editor/utils/logger";
911
import { FileIcon } from "@/features/file-explorer/components/file-icon";
1012
import { readDirectory } from "@/features/file-system/controllers/platform";
@@ -30,6 +32,42 @@ export default function Breadcrumb() {
3032
const { toggle: toggleInlineEditToolbar } = useInlineEditToolbarStore.use.actions();
3133
const selection = useEditorStateStore.use.selection?.();
3234

35+
const jumpListActions = useJumpListStore.use.actions();
36+
const canGoBack = jumpListActions.canGoBack();
37+
const canGoForward = jumpListActions.canGoForward();
38+
39+
const handleJumpBack = async () => {
40+
const bufferStore = useBufferStore.getState();
41+
const editorState = useEditorStateStore.getState();
42+
const activeBufferId = bufferStore.activeBufferId;
43+
const activeBuffer = bufferStore.buffers.find((b) => b.id === activeBufferId);
44+
45+
const currentPosition =
46+
activeBufferId && activeBuffer?.path
47+
? {
48+
bufferId: activeBufferId,
49+
filePath: activeBuffer.path,
50+
line: editorState.cursorPosition.line,
51+
column: editorState.cursorPosition.column,
52+
offset: editorState.cursorPosition.offset,
53+
scrollTop: editorState.scrollTop,
54+
scrollLeft: editorState.scrollLeft,
55+
}
56+
: undefined;
57+
58+
const entry = jumpListActions.goBack(currentPosition);
59+
if (entry) {
60+
await navigateToJumpEntry(entry);
61+
}
62+
};
63+
64+
const handleJumpForward = async () => {
65+
const entry = jumpListActions.goForward();
66+
if (entry) {
67+
await navigateToJumpEntry(entry);
68+
}
69+
};
70+
3371
const handleNavigate = async (path: string) => {
3472
try {
3573
await handleFileSelect(path, false);
@@ -249,6 +287,26 @@ export default function Breadcrumb() {
249287
<>
250288
<div className="flex min-h-7 select-none items-center justify-between border-border border-b bg-terniary-bg px-3 py-1">
251289
<div className="ui-font flex items-center gap-0.5 overflow-hidden text-text-lighter text-xs">
290+
<div className="mr-1 flex items-center gap-0.5">
291+
<button
292+
onClick={handleJumpBack}
293+
disabled={!canGoBack}
294+
className="flex h-5 w-5 items-center justify-center rounded text-text-lighter transition-colors hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-50"
295+
title="Go Back (Ctrl+-)"
296+
aria-label="Go back to previous location"
297+
>
298+
<ArrowLeft size={12} />
299+
</button>
300+
<button
301+
onClick={handleJumpForward}
302+
disabled={!canGoForward}
303+
className="flex h-5 w-5 items-center justify-center rounded text-text-lighter transition-colors hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-50"
304+
title="Go Forward (Ctrl+Shift+-)"
305+
aria-label="Go forward to next location"
306+
>
307+
<ArrowRight size={12} />
308+
</button>
309+
</div>
252310
{segments.map((segment, index) => (
253311
<div key={index} className="flex min-w-0 items-center gap-0.5">
254312
{index > 0 && (
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useCallback } from "react";
2+
import { EDITOR_CONSTANTS } from "@/features/editor/config/constants";
3+
import { editorAPI } from "@/features/editor/extensions/api";
4+
import { useEditorSettingsStore } from "@/features/editor/stores/settings-store";
5+
6+
export const useCenterCursor = () => {
7+
const centerCursorInViewport = useCallback((line: number) => {
8+
const textarea = editorAPI.getTextareaRef();
9+
if (!textarea) return;
10+
11+
const fontSize = useEditorSettingsStore.getState().fontSize;
12+
const lineHeight = Math.ceil(EDITOR_CONSTANTS.LINE_HEIGHT_MULTIPLIER * fontSize);
13+
const viewportHeight = textarea.clientHeight;
14+
15+
const targetLineTop = line * lineHeight;
16+
const centeredScrollTop = targetLineTop - viewportHeight / 2 + lineHeight / 2;
17+
18+
textarea.scrollTop = Math.max(0, centeredScrollTop);
19+
}, []);
20+
21+
return { centerCursorInViewport };
22+
};

src/features/editor/hooks/use-lsp-integration.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ export const useLspIntegration = ({
6969
// Use constant debounce for predictable completion behavior
7070
const completionTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
7171

72+
// Track cursor position where completions were triggered (to hide on cursor movement)
73+
const completionTriggerOffsetRef = useRef<number | null>(null);
74+
75+
// Track which file the last input was for (to avoid triggering completions on buffer switch)
76+
const lastInputFilePathRef = useRef<string | null>(null);
77+
7278
// Track document versions per file path for LSP sync
7379
const documentVersionsRef = useRef<Map<string, number>>(new Map());
7480

@@ -198,12 +204,22 @@ export const useLspIntegration = ({
198204
return;
199205
}
200206

207+
// Skip if this is a buffer switch (filePath changed but typing happened in a different file)
208+
// This prevents completions from appearing when switching to a buffer where user didn't just type
209+
if (lastInputFilePathRef.current !== null && lastInputFilePathRef.current !== filePath) {
210+
return;
211+
}
212+
201213
// Debounce completion trigger with fixed delay for predictable behavior
202214
completionTimerRef.current = setTimeout(() => {
203215
// Get latest value at trigger time (not from effect deps)
204216
const buffer = useBufferStore.getState().buffers.find((b) => b.path === filePath);
205217
if (!buffer) return;
206218

219+
// Store the cursor offset and file path where completion was triggered
220+
completionTriggerOffsetRef.current = cursorPosition.offset;
221+
lastInputFilePathRef.current = filePath;
222+
207223
lspActions.requestCompletion({
208224
filePath,
209225
cursorPos: cursorPosition.offset,
@@ -220,6 +236,32 @@ export const useLspIntegration = ({
220236
// eslint-disable-next-line react-hooks/exhaustive-deps -- cursorPosition and isApplyingCompletion are read at render time, not as triggers
221237
}, [lastInputTimestamp, filePath, lspActions, isLspSupported, editorRef]);
222238

239+
// Hide completions when cursor moves via navigation (not typing)
240+
// Navigation = cursor moves but lastInputTimestamp doesn't change
241+
const prevInputTimestampRef = useRef<number>(0);
242+
243+
useEffect(() => {
244+
const { isLspCompletionVisible } = useEditorUIStore.getState();
245+
246+
// Only check if completions are visible
247+
if (!isLspCompletionVisible) {
248+
prevInputTimestampRef.current = lastInputTimestamp;
249+
return;
250+
}
251+
252+
// If lastInputTimestamp changed, this cursor movement was caused by typing
253+
// Don't hide completions in this case
254+
if (lastInputTimestamp !== prevInputTimestampRef.current) {
255+
prevInputTimestampRef.current = lastInputTimestamp;
256+
return;
257+
}
258+
259+
// lastInputTimestamp didn't change, so this is navigation (arrow keys, click, etc.)
260+
// Hide completions
261+
useEditorUIStore.getState().actions.setIsLspCompletionVisible(false);
262+
completionTriggerOffsetRef.current = null;
263+
}, [cursorPosition.offset, lastInputTimestamp]);
264+
223265
return {
224266
lspClient,
225267
isLspSupported,

src/features/editor/lsp/use-go-to-definition.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useCallback } from "react";
22
import { EDITOR_CONSTANTS } from "@/features/editor/config/constants";
33
import { editorAPI } from "@/features/editor/extensions/api";
4+
import { useCenterCursor } from "@/features/editor/hooks/use-center-cursor";
45
import { useBufferStore } from "@/features/editor/stores/buffer-store";
6+
import { useJumpListStore } from "@/features/editor/stores/jump-list-store";
7+
import { useEditorStateStore } from "@/features/editor/stores/state-store";
58
import { readFileContent } from "@/features/file-system/controllers/file-operations";
69
import { logger } from "../utils/logger";
710

@@ -32,6 +35,8 @@ export const useGoToDefinition = ({
3235
fontSize,
3336
charWidth,
3437
}: UseGoToDefinitionProps) => {
38+
const { centerCursorInViewport } = useCenterCursor();
39+
3540
const handleClick = useCallback(
3641
async (e: React.MouseEvent<HTMLDivElement>) => {
3742
// Only handle Cmd+Click (Mac) or Ctrl+Click (Windows/Linux)
@@ -76,6 +81,21 @@ export const useGoToDefinition = ({
7681
const targetFilePath = target.uri.replace("file://", "");
7782

7883
const bufferStore = useBufferStore.getState();
84+
85+
// Push current position to jump list before navigating
86+
const activeBufferId = bufferStore.activeBufferId;
87+
if (activeBufferId && filePath) {
88+
const editorState = useEditorStateStore.getState();
89+
useJumpListStore.getState().actions.pushEntry({
90+
bufferId: activeBufferId,
91+
filePath,
92+
line: editorState.cursorPosition.line,
93+
column: editorState.cursorPosition.column,
94+
offset: editorState.cursorPosition.offset,
95+
scrollTop: editorState.scrollTop,
96+
scrollLeft: editorState.scrollLeft,
97+
});
98+
}
7999
const existingBuffer = bufferStore.buffers.find((b) => b.path === targetFilePath);
80100

81101
if (existingBuffer) {
@@ -102,6 +122,10 @@ export const useGoToDefinition = ({
102122
offset,
103123
});
104124

125+
requestAnimationFrame(() => {
126+
centerCursorInViewport(target.range.start.line);
127+
});
128+
105129
logger.info(
106130
"Editor",
107131
`Jumped to ${targetFilePath}:${target.range.start.line}:${target.range.start.character}`,
@@ -115,7 +139,7 @@ export const useGoToDefinition = ({
115139
}
116140
}
117141
},
118-
[getDefinition, isLanguageSupported, filePath, fontSize, charWidth],
142+
[getDefinition, isLanguageSupported, filePath, fontSize, charWidth, centerCursorInViewport],
119143
);
120144

121145
return {

0 commit comments

Comments
 (0)