feat(files): add Go to Line workflow (#933)

Add a Go to Line dialog to the file editor that accepts a line number
and scrolls the CodeMirror editor to that position. Triggered via:
- Keyboard shortcut: Alt+G (macOS/Windows/Linux)
- Toolbar button next to the existing Find in file button
This commit is contained in:
coldbrow
2026-04-17 11:15:36 +03:00
committed by GitHub
parent bee9d19f3a
commit ef4c6ee163
2 changed files with 261 additions and 12 deletions
+52 -12
View File
@@ -17,6 +17,7 @@ import {
RiSearchLine,
RiSave3Line,
RiTextWrap,
RiCommandLine,
RiMore2Fill,
RiFileAddLine,
RiFolderAddLine,
@@ -42,6 +43,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { GoToLineDialog } from './GoToLineDialog';
import { PreviewToggleButton } from './PreviewToggleButton';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
@@ -609,6 +611,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
const [copiedContent, setCopiedContent] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false);
const canCreateFile = Boolean(files.writeFile);
const canCreateFolder = Boolean(files.createDirectory);
@@ -2063,6 +2066,27 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
};
}, [isMobile, nudgeEditorSelectionAboveKeyboard]);
React.useEffect(() => {
if (!canEdit || textViewMode !== 'edit' || isMobile) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as Element | null;
if (target?.closest('[role="dialog"]')) {
return;
}
if (event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey && event.code === 'KeyG') {
event.preventDefault();
setIsGoToLineOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canEdit, isMobile, textViewMode]);
const editorExtensions = React.useMemo(() => {
if (!selectedFile?.path) {
return [createFlexokiCodeMirrorTheme(currentTheme)];
@@ -2333,18 +2357,29 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<RiTextWrap className="size-4" />
</Button>
{textViewMode === 'edit' && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchOpen(!isSearchOpen)}
className={cn(
'h-6 w-6 p-0 transition-opacity',
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
)}
title="Find in file"
>
<RiSearchLine className="size-4" />
</Button>
<>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchOpen(!isSearchOpen)}
className={cn(
'h-6 w-6 p-0 transition-opacity',
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
)}
title="Find in file"
>
<RiSearchLine className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsGoToLineOpen(true)}
className="h-6 w-6 p-0 text-muted-foreground opacity-65 hover:opacity-100"
title="Go to line"
>
<RiCommandLine className="size-4" />
</Button>
</>
)}
</>
)}
@@ -2756,6 +2791,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
data-keyboard-avoid="none"
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
>
<GoToLineDialog
open={isGoToLineOpen}
onOpenChange={setIsGoToLineOpen}
view={editorViewRef.current}
/>
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
<CodeMirrorEditor
value={draftContent}
@@ -0,0 +1,209 @@
import React from 'react';
import { EditorSelection } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
type GoToLineDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
view: EditorView | null;
};
type CursorSnapshot = {
character: number;
lineNumber: number;
selection: EditorSelection;
};
const resolveLineNumber = (rawValue: string, view: EditorView | null): number | null => {
if (!view) {
return null;
}
const parsed = Number.parseInt(rawValue.trim(), 10);
if (!Number.isFinite(parsed) || parsed < 1) {
return null;
}
return Math.min(parsed, view.state.doc.lines);
};
const getCursorSnapshot = (view: EditorView, selection: EditorSelection): CursorSnapshot => {
const line = view.state.doc.lineAt(selection.main.head);
return {
lineNumber: line.number,
character: selection.main.head - line.from + 1,
selection,
};
};
const moveSelectionToLine = (view: EditorView, lineNumber: number, preferredCharacter: number) => {
const line = view.state.doc.line(lineNumber);
const nextCharacter = Math.max(1, Math.min(preferredCharacter, line.length + 1));
const position = line.from + nextCharacter - 1;
view.dispatch({
selection: EditorSelection.cursor(position),
effects: EditorView.scrollIntoView(position, { y: 'center' }),
});
};
export function GoToLineDialog({ open, onOpenChange, view }: GoToLineDialogProps) {
const [inputValue, setInputValue] = React.useState('');
const initialCursorRef = React.useRef<CursorSnapshot | null>(null);
const committedRef = React.useRef(false);
const panelRef = React.useRef<HTMLDivElement | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const lineNumber = React.useMemo(
() => resolveLineNumber(inputValue, view),
[inputValue, view],
);
React.useEffect(() => {
if (!open || !view) {
return;
}
committedRef.current = false;
initialCursorRef.current = getCursorSnapshot(view, view.state.selection);
setInputValue('');
}, [open, view]);
React.useEffect(() => {
if (!open) {
return;
}
window.requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}, [open]);
React.useEffect(() => {
if (!open || !view || !initialCursorRef.current) {
return;
}
if (lineNumber === null) {
const { selection } = initialCursorRef.current;
view.dispatch({
selection,
effects: EditorView.scrollIntoView(selection.main.from, { y: 'center' }),
});
return;
}
moveSelectionToLine(view, lineNumber, initialCursorRef.current.character);
}, [lineNumber, open, view]);
const restoreInitialSelection = React.useCallback(() => {
if (!view || committedRef.current || !initialCursorRef.current) {
return;
}
const { selection } = initialCursorRef.current;
view.dispatch({
selection,
effects: EditorView.scrollIntoView(selection.main.from, { y: 'center' }),
});
}, [view]);
const handleOpenChange = React.useCallback((nextOpen: boolean) => {
if (!nextOpen) {
restoreInitialSelection();
}
onOpenChange(nextOpen);
}, [onOpenChange, restoreInitialSelection]);
const handleSubmit = React.useCallback(() => {
if (!view || lineNumber === null || !initialCursorRef.current) {
return;
}
committedRef.current = true;
moveSelectionToLine(view, lineNumber, initialCursorRef.current.character);
onOpenChange(false);
view.focus();
}, [lineNumber, onOpenChange, view]);
React.useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const panel = panelRef.current;
const target = event.target;
if (!panel || !(target instanceof Node) || panel.contains(target)) {
return;
}
handleOpenChange(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
event.preventDefault();
handleOpenChange(false);
};
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [handleOpenChange, open]);
const helperText = React.useMemo(() => {
if (!view) {
return 'Editor unavailable.';
}
if (lineNumber === null) {
const snapshot = initialCursorRef.current ?? getCursorSnapshot(view, view.state.selection);
return `Current Line: ${snapshot.lineNumber}. Type a line number between 1 and ${view.state.doc.lines} to navigate to.`;
}
return `Go to line ${lineNumber}`;
}, [lineNumber, view]);
return (
<div
ref={panelRef}
className={cn(
'absolute left-3 top-3 z-40 w-[min(32rem,calc(100%-1.5rem))] rounded-xl border border-[var(--interactive-border)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_94%,transparent)] p-2.5 shadow-lg backdrop-blur-sm transition-all',
open ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0',
)}
>
<div className="min-w-0">
<Input
ref={inputRef}
type="number"
min={1}
step={1}
inputMode="numeric"
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSubmit();
}
}}
placeholder="Line number"
className="h-8 w-full rounded-md border-border/70 bg-background/60 typography-ui-label"
/>
<div className="mt-2 rounded-md bg-primary/15 px-3 py-1.5 typography-ui-label text-foreground/95">
{helperText}
</div>
</div>
</div>
);
}