From ef4c6ee1635bbd138c150a003f3a1fa6bcc6db7f Mon Sep 17 00:00:00 2001 From: coldbrow <78590713+coldbrow@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:15:36 +0900 Subject: [PATCH] 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 --- .../ui/src/components/views/FilesView.tsx | 64 +++++- .../src/components/views/GoToLineDialog.tsx | 209 ++++++++++++++++++ 2 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/components/views/GoToLineDialog.tsx diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 6aeadce9..c3ff075c 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -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 = ({ mode = 'full' }) => { const [contextMenuPath, setContextMenuPath] = React.useState(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 = ({ 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 = ({ mode = 'full' }) => { {textViewMode === 'edit' && ( - + <> + + + )} )} @@ -2756,6 +2791,11 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { data-keyboard-avoid="none" style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined} > +
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(null); + const committedRef = React.useRef(false); + const panelRef = React.useRef(null); + const inputRef = React.useRef(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 ( +
+
+ 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" + /> +
+ {helperText} +
+
+
+ ); +}