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,
|
RiSearchLine,
|
||||||
RiSave3Line,
|
RiSave3Line,
|
||||||
RiTextWrap,
|
RiTextWrap,
|
||||||
|
RiCommandLine,
|
||||||
RiMore2Fill,
|
RiMore2Fill,
|
||||||
RiFileAddLine,
|
RiFileAddLine,
|
||||||
RiFolderAddLine,
|
RiFolderAddLine,
|
||||||
@@ -42,6 +43,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||||
|
import { GoToLineDialog } from './GoToLineDialog';
|
||||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
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 [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
|
||||||
const [copiedContent, setCopiedContent] = React.useState(false);
|
const [copiedContent, setCopiedContent] = React.useState(false);
|
||||||
const [copiedPath, setCopiedPath] = React.useState(false);
|
const [copiedPath, setCopiedPath] = React.useState(false);
|
||||||
|
const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false);
|
||||||
|
|
||||||
const canCreateFile = Boolean(files.writeFile);
|
const canCreateFile = Boolean(files.writeFile);
|
||||||
const canCreateFolder = Boolean(files.createDirectory);
|
const canCreateFolder = Boolean(files.createDirectory);
|
||||||
@@ -2063,6 +2066,27 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
};
|
};
|
||||||
}, [isMobile, nudgeEditorSelectionAboveKeyboard]);
|
}, [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(() => {
|
const editorExtensions = React.useMemo(() => {
|
||||||
if (!selectedFile?.path) {
|
if (!selectedFile?.path) {
|
||||||
return [createFlexokiCodeMirrorTheme(currentTheme)];
|
return [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||||
@@ -2333,18 +2357,29 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
<RiTextWrap className="size-4" />
|
<RiTextWrap className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{textViewMode === 'edit' && (
|
{textViewMode === 'edit' && (
|
||||||
<Button
|
<>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
size="sm"
|
||||||
className={cn(
|
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||||
'h-6 w-6 p-0 transition-opacity',
|
className={cn(
|
||||||
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
|
'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"
|
)}
|
||||||
>
|
title="Find in file"
|
||||||
<RiSearchLine className="size-4" />
|
>
|
||||||
</Button>
|
<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"
|
data-keyboard-avoid="none"
|
||||||
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
|
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')}>
|
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
|
||||||
<CodeMirrorEditor
|
<CodeMirrorEditor
|
||||||
value={draftContent}
|
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