feat: add file editor vim mode (#1437)
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d9b9b56599
commit
f45fe05f33
@@ -133,6 +133,7 @@ const VisualSectionContent: React.FC = () => {
|
||||
...(!isVSCode ? ['weekStart' as const] : []),
|
||||
'fontSize',
|
||||
'terminalFontSize',
|
||||
'fileEditorKeymap',
|
||||
'spacing',
|
||||
'inputBarOffset',
|
||||
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
|
||||
|
||||
@@ -247,7 +247,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
@@ -297,6 +297,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setDiffViewMode = useUIStore(state => state.setDiffViewMode);
|
||||
const showTerminalQuickKeysOnDesktop = useUIStore(state => state.showTerminalQuickKeysOnDesktop);
|
||||
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
|
||||
const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap);
|
||||
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
|
||||
const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
|
||||
const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
|
||||
const persistChatDraft = useUIStore(state => state.persistChatDraft);
|
||||
@@ -516,7 +518,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
? hasLocalizationSettings
|
||||
: (shouldShow('theme') || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
|
||||
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset');
|
||||
const hasNavigationSettings = shouldShow('terminalQuickKeys') && !isMobile;
|
||||
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || shouldShow('fileEditorKeymap');
|
||||
const hasBehaviorSettings = shouldShow('mermaidRendering')
|
||||
|| shouldShow('userMessageRendering')
|
||||
|| shouldShow('chatRenderMode')
|
||||
@@ -1211,6 +1213,48 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<div className="space-y-3">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.section.navigation')}</h4>
|
||||
{shouldShow('fileEditorKeymap') && (
|
||||
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-start sm:gap-8">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">
|
||||
{t('settings.openchamber.visual.field.fileEditorKeymap')}
|
||||
</span>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.openchamber.visual.field.fileEditorKeymap')}
|
||||
className="space-y-0"
|
||||
>
|
||||
{(['default', 'vim'] as const).map((keymap) => {
|
||||
const selected = fileEditorKeymap === keymap;
|
||||
const labelText = t(`settings.openchamber.visual.option.fileEditorKeymap.${keymap}`);
|
||||
return (
|
||||
<button
|
||||
key={keymap}
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-2 py-0.5 text-left"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setFileEditorKeymap(keymap)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative flex h-[14px] w-[14px] min-h-[14px] min-w-[14px] shrink-0 self-center items-center justify-center rounded-full transition-[background-color,box-shadow] duration-200 ease-out',
|
||||
selected
|
||||
? 'bg-[color-mix(in_srgb,var(--primary-base)_80%,transparent)] shadow-none'
|
||||
: 'bg-[var(--surface-muted)] shadow-[inset_0_0_0_1px_var(--interactive-border)]'
|
||||
)}
|
||||
>
|
||||
<span className={cn('block h-[5px] w-[5px] rounded-full bg-white', !selected && 'opacity-0')} />
|
||||
</span>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{labelText}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shouldShow('terminalQuickKeys') && !isMobile && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { forceParsing, indentUnit } from '@codemirror/language';
|
||||
import { search, searchKeymap, openSearchPanel, closeSearchPanel, searchPanelOpen, getSearchQuery } from '@codemirror/search';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { createVimModeExtensions } from '@/lib/codemirror/vimModeExtension';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Patches `title` attributes onto CodeMirror search-panel controls for icon-only tooltips. */
|
||||
@@ -141,6 +142,7 @@ type CodeMirrorEditorProps = {
|
||||
enableSearch?: boolean;
|
||||
searchOpen?: boolean;
|
||||
onSearchOpenChange?: (open: boolean) => void;
|
||||
vimMode?: boolean;
|
||||
};
|
||||
|
||||
const lineNumbersCompartment = new Compartment();
|
||||
@@ -149,6 +151,7 @@ const externalExtensionsCompartment = new Compartment();
|
||||
const highlightLinesCompartment = new Compartment();
|
||||
const blockWidgetsCompartment = new Compartment();
|
||||
const searchCompartment = new Compartment();
|
||||
const vimCompartment = new Compartment();
|
||||
|
||||
const toViewKeyBindings = (bindings: readonly unknown[]): readonly KeyBinding[] => {
|
||||
return bindings as readonly KeyBinding[];
|
||||
@@ -265,6 +268,7 @@ export function CodeMirrorEditor({
|
||||
enableSearch,
|
||||
searchOpen,
|
||||
onSearchOpenChange,
|
||||
vimMode,
|
||||
}: CodeMirrorEditorProps) {
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = React.useRef<EditorView | null>(null);
|
||||
@@ -274,6 +278,7 @@ export function CodeMirrorEditor({
|
||||
const onViewDestroyRef = React.useRef(onViewDestroy);
|
||||
const onSearchOpenChangeRef = React.useRef(onSearchOpenChange);
|
||||
const blockWidgetsRef = React.useRef(blockWidgets);
|
||||
const vimModeRef = React.useRef(vimMode);
|
||||
|
||||
// Scoped map for widget containers to avoid global collisions and memory leaks
|
||||
const widgetContainersRef = React.useRef(new Map<string, HTMLElement>());
|
||||
@@ -367,6 +372,7 @@ export function CodeMirrorEditor({
|
||||
lineNumbersCompartment.of(lineNumbers(lineNumbersConfig)),
|
||||
history(),
|
||||
indentUnit.of(' '),
|
||||
vimCompartment.of(createVimModeExtensions(vimMode)),
|
||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
syncEditorCssVars(update.view);
|
||||
@@ -449,6 +455,23 @@ export function CodeMirrorEditor({
|
||||
});
|
||||
}, [extensions, highlightLines, lineNumbersConfig, readOnly, blockWidgets, enableSearch, syncEditorCssVars, syncPortalWidgets]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
vimModeRef.current = vimMode;
|
||||
return;
|
||||
}
|
||||
|
||||
if (vimModeRef.current === vimMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
vimModeRef.current = vimMode;
|
||||
view.dispatch({
|
||||
effects: vimCompartment.reconfigure(createVimModeExtensions(vimMode)),
|
||||
});
|
||||
}, [vimMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || enableSearch === false) {
|
||||
|
||||
@@ -906,10 +906,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
|
||||
const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation);
|
||||
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
|
||||
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
|
||||
const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
|
||||
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
|
||||
const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap);
|
||||
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
|
||||
|
||||
// Global mouseup to end drag selection
|
||||
React.useEffect(() => {
|
||||
@@ -3555,11 +3556,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
>
|
||||
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
|
||||
<CodeMirrorEditor
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
readOnly={!canEdit}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
readOnly={!canEdit}
|
||||
vimMode={fileEditorKeymap === 'vim'}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
blockWidgets={blockWidgets}
|
||||
onViewReady={(view) => {
|
||||
editorViewRef.current = view;
|
||||
@@ -3852,11 +3854,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
<div className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}>
|
||||
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
|
||||
<CodeMirrorEditor
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
readOnly={!canEdit}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
readOnly={!canEdit}
|
||||
vimMode={fileEditorKeymap === 'vim'}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
onViewReady={(view) => {
|
||||
editorViewRef.current = view;
|
||||
window.requestAnimationFrame(() => {
|
||||
|
||||
Reference in New Issue
Block a user