* feat: add i18n foundation * feat: localize sessions sidebar * Localize multirun/scheduled tasks and fix dialog dropdown interactions * localize git sidebar surface and add zh-CN keys * feat(ui): localize context panel, diff/plan views, and context sidebar content * fix(config): resolve user config home via fs/home before embedded home * localize header/chat UI and complete model/worktree panel strings * localize worktree + github issue/pr dialog flows * localize settings sections and split settings i18n dictionaries * localize additional settings sections and sidebars * localize more settings pages and dialogs * fix settings select trigger localization * localize tunnel settings ui surface * localize additional settings sections * localize keyboard shortcuts labels in settings * localize terminal and utility dialogs surfaces * feat(i18n): localize remaining UI strings * Add Ukrainian locale * Add Spanish locale * Add Brazilian Portuguese locale * Polish locale translations
257 lines
7.3 KiB
TypeScript
257 lines
7.3 KiB
TypeScript
import React from 'react';
|
|
|
|
import { EditorSelection } from '@codemirror/state';
|
|
import { EditorView } from '@codemirror/view';
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
type GoToLineDialogProps = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
view: EditorView | null;
|
|
variant?: 'overlay' | 'inline';
|
|
};
|
|
|
|
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, variant = 'overlay' }: GoToLineDialogProps) {
|
|
const { t } = useI18n();
|
|
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, variant]);
|
|
|
|
const helperText = React.useMemo(() => {
|
|
if (!view) {
|
|
return t('goToLineDialog.helper.editorUnavailable');
|
|
}
|
|
|
|
if (lineNumber === null) {
|
|
const snapshot = initialCursorRef.current ?? getCursorSnapshot(view, view.state.selection);
|
|
return t('goToLineDialog.helper.currentLineRange', {
|
|
current: snapshot.lineNumber,
|
|
max: view.state.doc.lines,
|
|
});
|
|
}
|
|
|
|
return t('goToLineDialog.helper.goToLine', { line: lineNumber });
|
|
}, [lineNumber, t, view]);
|
|
|
|
if (variant === 'inline') {
|
|
if (!open) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={panelRef}
|
|
className="ml-1 flex h-6 items-center gap-1"
|
|
>
|
|
<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={t('goToLineDialog.field.linePlaceholderShort')}
|
|
className="h-6 w-20 rounded-md border-border/70 bg-transparent px-2 typography-meta"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={handleSubmit}
|
|
disabled={!view || lineNumber === null}
|
|
className="h-6 px-2"
|
|
>
|
|
{t('goToLineDialog.actions.go')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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={t('goToLineDialog.field.linePlaceholder')}
|
|
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>
|
|
);
|
|
}
|