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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user