feat: implement file editing capabilities with CodeMirror integration

- Added CodeMirror editor for file content editing in FilesView.
- Introduced draft saving functionality with unsaved changes confirmation dialog.
- Implemented file writing API to persist changes to the filesystem.
- Enhanced language support for syntax highlighting based on file extensions.
- Updated UI components to support new editing features, including save and discard options.
- Refactored line selection logic for improved user experience on both desktop and mobile.
- Added new utility functions for language detection by file extension.
- Introduced a new theme for CodeMirror to align with the application's design.
This commit is contained in:
Bohdan Triapitsyn
2026-01-18 17:29:31 +02:00
parent 934df2b7e6
commit 24969ba856
11 changed files with 1151 additions and 189 deletions
@@ -0,0 +1,152 @@
import React from 'react';
import type { Extension } from '@codemirror/state';
import { Compartment, EditorState, RangeSetBuilder } from '@codemirror/state';
import { Decoration, EditorView, ViewPlugin, gutters, keymap, lineNumbers } from '@codemirror/view';
import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { cn } from '@/lib/utils';
type CodeMirrorEditorProps = {
value: string;
onChange: (value: string) => void;
extensions?: Extension[];
className?: string;
readOnly?: boolean;
lineNumbersConfig?: Parameters<typeof lineNumbers>[0];
highlightLines?: { start: number; end: number };
};
const lineNumbersCompartment = new Compartment();
const editableCompartment = new Compartment();
const externalExtensionsCompartment = new Compartment();
const highlightLinesCompartment = new Compartment();
const createHighlightLinesExtension = (range?: { start: number; end: number }): Extension => {
if (!range) {
return [];
}
const start = Math.max(1, range.start);
const end = Math.max(start, range.end);
return ViewPlugin.fromClass(class {
decorations;
constructor(view: EditorView) {
this.decorations = this.build(view);
}
update(update: import('@codemirror/view').ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.build(update.view);
}
}
build(view: EditorView) {
const builder = new RangeSetBuilder<Decoration>();
for (let lineNo = start; lineNo <= end && lineNo <= view.state.doc.lines; lineNo += 1) {
const line = view.state.doc.line(lineNo);
builder.add(line.from, line.from, Decoration.line({ class: 'oc-cm-selected-line' }));
}
return builder.finish();
}
}, { decorations: (v) => v.decorations });
};
export function CodeMirrorEditor({ value, onChange, extensions, className, readOnly, lineNumbersConfig, highlightLines }: CodeMirrorEditorProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
const valueRef = React.useRef(value);
const onChangeRef = React.useRef(onChange);
React.useEffect(() => {
valueRef.current = value;
}, [value]);
React.useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
React.useEffect(() => {
if (!hostRef.current) {
return;
}
const state = EditorState.create({
doc: valueRef.current,
extensions: [
gutters({ fixed: true }),
lineNumbersCompartment.of(lineNumbers(lineNumbersConfig)),
history(),
indentUnit.of(' '),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.updateListener.of((update) => {
if (!update.docChanged) {
return;
}
const next = update.state.doc.toString();
valueRef.current = next;
onChangeRef.current(next);
}),
editableCompartment.of(EditorView.editable.of(!readOnly)),
externalExtensionsCompartment.of(extensions ?? []),
highlightLinesCompartment.of(createHighlightLinesExtension(highlightLines)),
],
});
viewRef.current = new EditorView({
state,
parent: hostRef.current,
});
return () => {
viewRef.current?.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: [
lineNumbersCompartment.reconfigure(lineNumbers(lineNumbersConfig)),
editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
externalExtensionsCompartment.reconfigure(extensions ?? []),
highlightLinesCompartment.reconfigure(createHighlightLinesExtension(highlightLines)),
],
});
}, [extensions, highlightLines, lineNumbersConfig, readOnly]);
React.useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const current = view.state.doc.toString();
if (current !== value) {
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
}
}, [value]);
return (
<div
ref={hostRef}
className={cn(
'h-full w-full',
'[&_.cm-editor]:h-full [&_.cm-editor]:w-full',
'[&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[var(--text-code)] [&_.cm-scroller]:leading-6',
className,
)}
/>
);
}
+357 -182
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import createElement from 'react-syntax-highlighter/create-element';
import {
RiArrowLeftSLine,
RiClipboardLine,
@@ -8,11 +7,13 @@ import {
RiCodeLine,
RiFileImageLine,
RiFileTextLine,
RiFileCopy2Line,
RiFolder3Fill,
RiFolderOpenFill,
RiLoader4Line,
RiRefreshLine,
RiSearchLine,
RiSave3Line,
RiSendPlane2Line,
RiTextWrap,
} from '@remixicon/react';
@@ -22,14 +23,25 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel } from '@/lib/utils';
import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
@@ -81,7 +93,6 @@ const useEffectiveDirectory = () => {
return worktreeMetadata?.path ?? sessionDirectory ?? fallbackDirectory ?? '';
};
const MAX_HIGHLIGHT_CHARS = 200_000;
const MAX_VIEW_CHARS = 200_000;
const CODE_EXTENSIONS = new Set([
@@ -221,8 +232,7 @@ const getFileIcon = (extension?: string): React.ReactNode => {
export const FilesView: React.FC = () => {
const { files, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const { isMobile } = useDeviceInfo();
const { isMobile, screenWidth } = useDeviceInfo();
const currentDirectory = useEffectiveDirectory();
const root = normalizePath(currentDirectory);
@@ -249,6 +259,14 @@ export const FilesView: React.FC = () => {
const [fileError, setFileError] = React.useState<string | null>(null);
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
const [draftContent, setDraftContent] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false);
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
const pendingTabRef = React.useRef<import('@/stores/useUIStore').MainTab | null>(null);
const skipDirtyOnceRef = React.useRef(false);
// Line selection state for commenting
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = React.useState('');
@@ -263,62 +281,15 @@ export const FilesView: React.FC = () => {
const getAgentModelForSession = useContextStore((state) => state.getAgentModelForSession);
const getAgentModelVariantForSession = useContextStore((state) => state.getAgentModelVariantForSession);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
const { inputBarOffset, isKeyboardOpen } = useUIStore();
// Line selection handlers
const handleLineClick = React.useCallback((lineNumber: number, shiftKey: boolean) => {
if (shiftKey && lineSelection) {
// Extend selection with shift+click
const newStart = Math.min(lineSelection.start, lineNumber);
const newEnd = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start: newStart, end: newEnd });
} else {
// Start new selection
setLineSelection({ start: lineNumber, end: lineNumber });
}
}, [lineSelection]);
const handleLineMouseDown = React.useCallback((lineNumber: number, e: React.MouseEvent) => {
e.preventDefault(); // Prevent text selection while selecting lines
if (e.shiftKey && lineSelection) {
// Shift+click extends selection
handleLineClick(lineNumber, true);
return;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
setLineSelection({ start: lineNumber, end: lineNumber });
}, [handleLineClick, lineSelection]);
const handleLineMouseEnter = React.useCallback((lineNumber: number) => {
if (!isSelectingRef.current || selectionStartRef.current === null) return;
const start = Math.min(selectionStartRef.current, lineNumber);
const end = Math.max(selectionStartRef.current, lineNumber);
setLineSelection({ start, end });
}, []);
const handleLineMouseUp = React.useCallback(() => {
isSelectingRef.current = false;
}, []);
// Mobile: tap to extend selection
const handleLineTap = React.useCallback((lineNumber: number) => {
if (lineSelection) {
// Extend selection to tapped line
const newStart = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const newEnd = Math.max(lineSelection.start, lineSelection.end, lineNumber);
if (lineNumber < lineSelection.start || lineNumber > lineSelection.end) {
setLineSelection({ start: newStart, end: newEnd });
return;
}
}
setLineSelection({ start: lineNumber, end: lineNumber });
}, [lineSelection]);
// Global mouseup to end drag selection
React.useEffect(() => {
const handleGlobalMouseUp = () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
@@ -328,7 +299,10 @@ export const FilesView: React.FC = () => {
React.useEffect(() => {
setLineSelection(null);
setCommentText('');
}, [selectedFile?.path]);
setMainTabGuard(null);
setDraftContent('');
setIsSaving(false);
}, [selectedFile?.path, setMainTabGuard]);
// Click outside to dismiss selection
React.useEffect(() => {
@@ -341,8 +315,8 @@ export const FilesView: React.FC = () => {
const commentUI = document.querySelector('[data-comment-ui]');
if (commentUI?.contains(target)) return;
// Check if click is on a line number (only line numbers should not dismiss)
if (target.closest('[data-line-number]')) return;
// Check if click is on CM gutter (only gutter should not dismiss)
if (target.closest('.cm-gutterElement')) return;
// Check if click is inside toast (sonner)
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
@@ -652,7 +626,94 @@ export const FilesView: React.FC = () => {
return response.text();
}, [files]);
const displayedContent = React.useMemo(() => {
return fileContent.length > MAX_VIEW_CHARS
? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: fileContent;
}, [fileContent]);
const isDirty = React.useMemo(() => draftContent !== displayedContent, [draftContent, displayedContent]);
const saveDraft = React.useCallback(async () => {
if (!selectedFile || !files.writeFile) {
toast.error('Saving not supported');
return;
}
if (!isDirty) {
return;
}
setIsSaving(true);
try {
const result = await files.writeFile(selectedFile.path, draftContent);
if (!result?.success) {
throw new Error('Failed to write file');
}
setFileContent(draftContent);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Save failed');
} finally {
setIsSaving(false);
}
}, [draftContent, files, isDirty, selectedFile]);
React.useEffect(() => {
if (!isDirty) {
setMainTabGuard(null);
return;
}
const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => {
if (skipDirtyOnceRef.current) {
skipDirtyOnceRef.current = false;
return true;
}
setConfirmDiscardOpen(true);
pendingTabRef.current = _nextTab;
return false;
};
setMainTabGuard(guard);
return () => {
const currentGuard = useUIStore.getState().mainTabGuard;
if (currentGuard === guard) {
setMainTabGuard(null);
}
};
}, [isDirty, setMainTabGuard]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!hasModifier(e)) {
return;
}
if (e.key.toLowerCase() !== 's') {
return;
}
e.preventDefault();
if (!isSaving) {
void saveDraft();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSaving, saveDraft]);
const handleSelectFile = React.useCallback(async (node: FileNode) => {
if (skipDirtyOnceRef.current) {
skipDirtyOnceRef.current = false;
} else if (isDirty) {
setConfirmDiscardOpen(true);
pendingSelectFileRef.current = node;
return;
}
setSelectedFile(node);
setFileError(null);
setDesktopImageSrc('');
@@ -684,13 +745,68 @@ export const FilesView: React.FC = () => {
try {
const content = await readFile(node.path);
setFileContent(content);
setDraftContent(content.length > MAX_VIEW_CHARS
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: content);
} catch (error) {
setFileContent('');
setDraftContent('');
setFileError(error instanceof Error ? error.message : 'Failed to read file');
} finally {
setFileLoading(false);
}
}, [isMobile, readFile, runtime.isDesktop]);
}, [isDirty, isMobile, readFile, runtime.isDesktop]);
const discardAndContinue = React.useCallback(() => {
const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current;
pendingSelectFileRef.current = null;
pendingTabRef.current = null;
// Allow one guarded navigation (tab/file) without re-opening dialog.
skipDirtyOnceRef.current = true;
setConfirmDiscardOpen(false);
// Discard draft by reverting back to last loaded content
setDraftContent(displayedContent);
if (nextFile) {
void handleSelectFile(nextFile);
return;
}
if (nextTab) {
setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab);
}
}, [displayedContent, handleSelectFile, setMainTabGuard]);
const saveAndContinue = React.useCallback(async () => {
const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current;
pendingSelectFileRef.current = null;
pendingTabRef.current = null;
// We'll proceed after saving; suppress guard reopening.
skipDirtyOnceRef.current = true;
setConfirmDiscardOpen(false);
await saveDraft();
if (nextFile) {
await handleSelectFile(nextFile);
return;
}
if (nextTab) {
setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab);
}
}, [handleSelectFile, saveDraft, setMainTabGuard]);
const toggleDirectory = React.useCallback(async (dirPath: string) => {
const normalized = normalizePath(dirPath);
@@ -763,11 +879,6 @@ export const FilesView: React.FC = () => {
});
}, [childrenByDir, expandedDirs, handleSelectFile, selectedFile?.path, toggleDirectory]);
const viewerLanguage = selectedFile?.path ? getLanguageFromExtension(selectedFile.path) || 'text' : 'text';
const contentForViewer = fileContent.length > MAX_VIEW_CHARS
? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: fileContent;
const shouldHighlight = contentForViewer.length <= MAX_HIGHLIGHT_CHARS;
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
const displaySelectedPath = React.useMemo(() => {
@@ -783,6 +894,24 @@ export const FilesView: React.FC = () => {
const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0);
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
const editorExtensions = React.useMemo(() => {
if (!selectedFile?.path) {
return [createFlexokiCodeMirrorTheme(currentTheme)];
}
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
const language = languageByExtension(selectedFile.path);
if (language) {
extensions.push(language);
}
if (wrapLines) {
extensions.push(EditorView.lineWrapping);
}
return extensions;
}, [currentTheme, selectedFile?.path, wrapLines]);
const imageSrc = selectedFile?.path && isSelectedImage
? (runtime.isDesktop
@@ -794,83 +923,7 @@ export const FilesView: React.FC = () => {
: `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}`))
: '';
const codeRenderer = React.useCallback(({
rows,
stylesheet,
useInlineStyles,
}: {
rows: unknown[];
stylesheet: unknown;
useInlineStyles: boolean;
}) => {
const gutterWidthCh = Math.max(3, String(rows.length).length + 1);
return (
<div data-code-viewer>
{rows.map((row, index) => {
const lineNumber = index + 1;
const isSelected = lineSelection !== null && lineNumber >= lineSelection.start && lineNumber <= lineSelection.end;
return (
<div
key={index}
data-line-row={lineNumber}
data-selected={isSelected ? 'true' : undefined}
onMouseEnter={isMobile ? undefined : () => handleLineMouseEnter(lineNumber)}
style={{
display: 'flex',
alignItems: 'flex-start',
lineHeight: '1.5rem',
position: 'relative',
backgroundColor: isSelected ? 'color-mix(in srgb, var(--accent) 70%, transparent)' : undefined,
}}
>
<span
data-line-number={lineNumber}
onMouseDown={isMobile ? undefined : (e) => handleLineMouseDown(lineNumber, e)}
onMouseUp={isMobile ? undefined : handleLineMouseUp}
onClick={isMobile ? () => handleLineTap(lineNumber) : undefined}
style={{
width: `calc(${gutterWidthCh}ch + 0.75rem + 0.75rem)`,
flexShrink: 0,
paddingLeft: '0.75rem',
paddingRight: '1.75ch',
textAlign: 'right',
color: 'hsl(var(--muted-foreground))',
opacity: 0.35,
fontSize: '0.8em',
lineHeight: '1.5rem',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none' as const,
cursor: 'pointer',
touchAction: 'manipulation',
}}
>
{lineNumber}
</span>
<code
style={{
flex: 1,
minWidth: 0,
display: 'block',
whiteSpace: wrapLines ? 'pre-wrap' : 'pre',
overflowWrap: wrapLines ? 'break-word' : 'normal',
tabSize: 2,
paddingRight: '0.75rem',
userSelect: lineSelection ? 'none' : undefined,
WebkitUserSelect: lineSelection ? 'none' : undefined,
}}
>
{createElement({ node: row, stylesheet, useInlineStyles, key: index })}
</code>
</div>
);
})}
</div>
);
}, [wrapLines, lineSelection, isMobile, handleLineMouseDown, handleLineMouseEnter, handleLineMouseUp, handleLineTap]);
React.useEffect(() => {
@@ -999,9 +1052,35 @@ export const FilesView: React.FC = () => {
const fileViewer = (
<div
className="relative flex h-full min-h-0 flex-col"
className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden"
>
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
<Dialog open={confirmDiscardOpen} onOpenChange={(open) => {
// Intentionally no "cancel" action. Keep dialog modal.
if (!open) {
setConfirmDiscardOpen(true);
}
}}>
<DialogContent showCloseButton={false} className="max-w-md">
<DialogHeader>
<DialogTitle>Unsaved changes</DialogTitle>
<DialogDescription>
Save your edits before continuing?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => void saveAndContinue()}
disabled={isSaving}
className="border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)] hover:bg-[rgb(var(--status-success)/0.2)]"
>
Save changes
</Button>
<Button variant="destructive" onClick={discardAndContinue}>Discard</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
{isMobile && showMobilePageContent && (
<button
type="button"
@@ -1024,7 +1103,29 @@ export const FilesView: React.FC = () => {
)}
</div>
<div className="flex items-center gap-0">
<div className="flex items-center gap-1">
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
disabled={!isDirty || isSaving}
className="h-5 w-5 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
title={`Save (${getModifierLabel()}+S)`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
{isSaving ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiSave3Line className="h-4 w-4" />
)}
</Button>
)}
{canEdit && selectedFile && !isSelectedImage && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{selectedFile && !isSelectedImage && (
<Button
variant="ghost"
@@ -1040,6 +1141,10 @@ export const FilesView: React.FC = () => {
</Button>
)}
{(canCopy || canCopyPath) && (canEdit || (selectedFile && !isSelectedImage)) && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{canCopy && (
<Button
variant="ghost"
@@ -1052,17 +1157,38 @@ export const FilesView: React.FC = () => {
toast.error('Copy failed');
}
}}
className="gap-1"
className="h-5 w-5 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
<RiClipboardLine className="h-4 w-4" />
Copy
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(displaySelectedPath);
toast.success('Copied');
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
<RiFileCopy2Line className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex-1 min-h-0 relative">
<ScrollableOverlay outerClassName="h-full" className="h-full">
<div className="flex-1 min-h-0 min-w-0 relative">
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">Pick a file from the tree.</div>
) : fileLoading ? (
@@ -1081,32 +1207,79 @@ export const FilesView: React.FC = () => {
/>
</div>
) : (
<div className="py-3">
<SyntaxHighlighter
key={lineSelection ? `${lineSelection.start}-${lineSelection.end}` : 'none'}
language={shouldHighlight ? viewerLanguage : 'text'}
style={syntaxTheme}
PreTag="div"
renderer={codeRenderer}
customStyle={{
margin: 0,
padding: 0,
background: 'transparent',
fontSize: 'var(--text-code)',
lineHeight: '1.5rem',
overflowX: 'auto',
overflowY: 'visible',
whiteSpace: 'normal',
}}
codeTagProps={{
style: {
fontStyle: 'normal',
lineHeight: '1.5rem',
<div className="h-full">
<CodeMirrorEditor
value={draftContent}
onChange={setDraftContent}
extensions={editorExtensions}
className="h-full"
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view: EditorView, line: { from: number; to: number }, event: Event) => {
if (!(event instanceof MouseEvent)) {
return false;
}
if (event.button !== 0) {
return false;
}
event.preventDefault();
const lineNumber = view.state.doc.lineAt(line.from).number;
// Mobile: tap-to-extend selection
if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
setLineSelection({ start, end });
isSelectingRef.current = false;
selectionStartRef.current = null;
return true;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
if (lineSelection && event.shiftKey) {
const start = Math.min(lineSelection.start, lineNumber);
const end = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start, end });
} else {
setLineSelection({ start: lineNumber, end: lineNumber });
}
return true;
},
mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => {
if (!(event instanceof MouseEvent)) {
return false;
}
if (event.buttons !== 1) {
return false;
}
if (!isSelectingRef.current || selectionStartRef.current === null) {
return false;
}
const lineNumber = view.state.doc.lineAt(line.from).number;
const start = Math.min(selectionStartRef.current, lineNumber);
const end = Math.max(selectionStartRef.current, lineNumber);
setLineSelection({ start, end });
return false;
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
return false;
},
},
}}
>
{contentForViewer}
</SyntaxHighlighter>
/>
</div>
)}
</ScrollableOverlay>
@@ -1225,16 +1398,18 @@ export const FilesView: React.FC = () => {
) : (
treePanel
)
) : (
<div className="flex flex-1 min-h-0 min-w-0 gap-3 px-3 pb-3 pt-2">
<div className="w-72 flex-shrink-0 min-h-0 overflow-hidden">
{treePanel}
</div>
<div className="flex-1 min-h-0 min-w-0 overflow-hidden rounded-xl border border-border/60 bg-background">
{fileViewer}
</div>
</div>
)}
) : (
<div className="flex flex-1 min-h-0 min-w-0 gap-3 px-3 pb-3 pt-2">
{screenWidth >= 1024 && (
<div className="w-72 flex-shrink-0 min-h-0 overflow-hidden">
{treePanel}
</div>
)}
<div className="flex-1 min-h-0 min-w-0 overflow-hidden rounded-xl border border-border/60 bg-background">
{fileViewer}
</div>
</div>
)}
</div>
);
};
+4
View File
@@ -46,6 +46,10 @@ textarea[data-chat-input="true"]:hover {
box-shadow: none;
}
.oc-cm-selected-line {
background: color-mix(in srgb, var(--accent) 70%, transparent);
}
:root.vscode-runtime textarea[data-chat-input="true"]::placeholder {
color: color-mix(in srgb, var(--vscode-input-placeholderForeground, var(--muted-foreground)) 65%, transparent);
}
@@ -0,0 +1,193 @@
import type { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import type { Theme } from '@/types/theme';
export function createFlexokiCodeMirrorTheme(theme: Theme): Extension {
const isDark = theme.metadata.variant === 'dark';
const monoFont = theme.config?.fonts?.mono || 'monospace';
const highlights = theme.colors.syntax.highlights || {};
const tokens = theme.colors.syntax.tokens || {};
const ui = EditorView.theme({
'&': {
backgroundColor: 'var(--background)',
color: theme.colors.syntax.base.foreground,
fontSize: 'var(--text-code)',
lineHeight: '1.5rem',
},
'.cm-scroller': {
fontFamily: monoFont,
backgroundColor: 'var(--background)',
},
/* StreamLanguage/legacy-modes tokens (class-based) */
'.cm-comment': {
color: theme.colors.syntax.base.comment,
},
'.cm-keyword': {
color: theme.colors.syntax.base.keyword,
},
'.cm-string': {
color: theme.colors.syntax.base.string,
},
'.cm-string-2': {
color: tokens.stringEscape || theme.colors.syntax.base.string,
},
'.cm-number': {
color: theme.colors.syntax.base.number,
},
'.cm-atom': {
color: tokens.boolean || theme.colors.syntax.base.number,
},
'.cm-builtin': {
color: tokens.functionCall || theme.colors.syntax.base.function,
},
'.cm-def': {
color: tokens.variableGlobal || theme.colors.syntax.base.variable,
},
// Legacy shell flags (--foo, -bar)
'.cm-attribute': {
color: tokens.variableOther || tokens.variableProperty || theme.colors.syntax.base.operator,
},
'.cm-meta': {
color: theme.colors.syntax.base.comment,
},
'.cm-property': {
color: tokens.variableProperty || theme.colors.syntax.base.keyword,
},
'.cm-variable': {
color: theme.colors.syntax.base.variable,
},
'.cm-variable-2': {
color: tokens.variableOther || theme.colors.syntax.base.function,
},
'.cm-variable-3': {
color: tokens.variableGlobal || theme.colors.syntax.base.type,
},
'.cm-tag': {
color: tokens.tag || theme.colors.syntax.base.keyword,
},
'.cm-link': {
color: tokens.url || theme.colors.syntax.base.keyword,
textDecoration: 'underline',
},
'.cm-content': {
caretColor: theme.colors.interactive.cursor,
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: theme.colors.interactive.cursor,
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
backgroundColor: theme.colors.interactive.selection,
},
'.cm-gutters': {
backgroundColor: 'var(--background)',
color: highlights.lineNumber || theme.colors.syntax.base.comment,
borderRight: `1px solid ${theme.colors.interactive.border}`,
position: 'sticky',
paddingRight: '8px',
left: 0,
zIndex: 2,
boxShadow: `0 0 0 var(--background)`,
},
'.cm-gutter': {
backgroundColor: 'var(--background)',
},
'.cm-gutterElement': {
backgroundColor: 'var(--background)',
},
'.cm-lineNumbers': {
backgroundColor: 'var(--background)',
},
'.cm-lineNumbers .cm-gutterElement': {
paddingLeft: '8px',
paddingRight: '8px',
minWidth: '42px',
},
'.cm-activeLineGutter': {
color: highlights.lineNumberActive || theme.colors.syntax.base.foreground,
},
'.cm-activeLine': {
backgroundColor: theme.colors.surface.overlay,
},
'&.cm-focused': {
outline: 'none',
},
}, { dark: isDark });
const syntax = HighlightStyle.define([
{ tag: t.comment, color: theme.colors.syntax.base.comment },
{ tag: t.docComment, color: tokens.commentDoc || theme.colors.syntax.base.comment },
{ tag: t.keyword, color: theme.colors.syntax.base.keyword },
{ tag: t.controlKeyword, color: theme.colors.syntax.base.keyword },
{ tag: t.operatorKeyword, color: theme.colors.syntax.base.operator },
{ tag: t.moduleKeyword, color: tokens.keywordImport || theme.colors.syntax.base.keyword },
{ tag: [t.definitionKeyword, t.modifier], color: tokens.storageModifier || theme.colors.syntax.base.keyword },
{ tag: t.atom, color: tokens.boolean || theme.colors.syntax.base.number },
{ tag: [t.null, t.self], color: theme.colors.syntax.base.number },
{ tag: [t.meta, t.documentMeta], color: theme.colors.syntax.base.comment },
{ tag: t.string, color: theme.colors.syntax.base.string },
{ tag: t.escape, color: tokens.stringEscape || theme.colors.syntax.base.foreground },
{ tag: t.regexp, color: tokens.regex || theme.colors.syntax.base.string },
{ tag: t.number, color: theme.colors.syntax.base.number },
{ tag: t.bool, color: tokens.boolean || theme.colors.syntax.base.number },
// Operators + punctuation
{ tag: t.operator, color: theme.colors.syntax.base.operator },
{ tag: [t.derefOperator, t.updateOperator, t.definitionOperator, t.typeOperator, t.controlOperator], color: theme.colors.syntax.base.operator },
{ tag: [t.logicOperator, t.bitwiseOperator, t.arithmeticOperator], color: theme.colors.syntax.base.operator },
{ tag: [t.compareOperator], color: tokens.diffModified || theme.colors.syntax.base.operator },
{ tag: [t.punctuation, t.separator, t.bracket, t.paren, t.brace, t.squareBracket, t.angleBracket], color: tokens.punctuation || theme.colors.syntax.base.comment },
// Calls vs definitions
{ tag: t.function(t.variableName), color: tokens.functionCall || theme.colors.syntax.base.function },
{ tag: t.function(t.definition(t.variableName)), color: theme.colors.syntax.base.function },
{ tag: t.function(t.propertyName), color: tokens.method || tokens.functionCall || theme.colors.syntax.base.function },
// Names
{ tag: t.namespace, color: tokens.namespace || theme.colors.syntax.base.type },
{ tag: t.moduleKeyword, color: tokens.module || theme.colors.syntax.base.keyword },
{ tag: t.macroName, color: tokens.macro || theme.colors.syntax.base.keyword },
{ tag: t.labelName, color: tokens.label || theme.colors.syntax.base.keyword },
{ tag: t.annotation, color: tokens.decorator || theme.colors.syntax.base.keyword },
// Variables/properties
{ tag: t.propertyName, color: tokens.variableProperty || theme.colors.syntax.base.keyword },
{ tag: t.attributeName, color: tokens.tagAttribute || theme.colors.syntax.base.keyword },
// StreamLanguage/legacy token tags resolve to these
{ tag: t.standard(t.variableName), color: tokens.method || theme.colors.syntax.base.function },
{ tag: t.definition(t.variableName), color: theme.colors.syntax.base.variable },
{ tag: t.local(t.variableName), color: theme.colors.syntax.base.variable },
{ tag: t.special(t.variableName), color: tokens.variableOther || theme.colors.syntax.base.function },
{ tag: t.variableName, color: theme.colors.syntax.base.variable },
{ tag: t.special(t.string), color: theme.colors.syntax.base.string },
// Types/constants
{ tag: t.className, color: tokens.className || theme.colors.syntax.base.type },
{ tag: t.typeName, color: theme.colors.syntax.base.type },
{ tag: t.constant(t.variableName), color: tokens.constant || theme.colors.syntax.base.variable },
{ tag: t.literal, color: tokens.constant || theme.colors.syntax.base.variable },
// Markup
{ tag: t.tagName, color: tokens.tag || theme.colors.syntax.base.keyword },
{ tag: t.attributeValue, color: tokens.tagAttributeValue || theme.colors.syntax.base.string },
// Markdown-ish
{ tag: [t.heading, t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], color: theme.colors.syntax.base.keyword, fontWeight: '600' },
{ tag: t.monospace, color: theme.colors.syntax.base.string },
{ tag: t.link, color: tokens.url || theme.colors.syntax.base.keyword, textDecoration: 'underline' },
]);
return [ui, syntaxHighlighting(syntax)];
}
@@ -0,0 +1,191 @@
import type { Extension } from '@codemirror/state';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { css } from '@codemirror/lang-css';
import { html } from '@codemirror/lang-html';
import { markdown } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data';
import { python } from '@codemirror/lang-python';
import { sql } from '@codemirror/lang-sql';
import { xml } from '@codemirror/lang-xml';
import { yaml as yamlLanguage } from '@codemirror/lang-yaml';
import { rust } from '@codemirror/lang-rust';
import { Language, LanguageDescription, StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { diff } from '@codemirror/legacy-modes/mode/diff';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { properties } from '@codemirror/legacy-modes/mode/properties';
const shellLanguage = StreamLanguage.define(shell);
const tomlLanguage = StreamLanguage.define(toml);
const diffLanguage = StreamLanguage.define(diff);
const dockerfileLanguage = StreamLanguage.define(dockerFile);
const rubyLanguage = StreamLanguage.define(ruby);
const propertiesLanguage = StreamLanguage.define(properties);
function codeBlockLanguageResolver(info: string): Language | LanguageDescription | null {
const normalized = info.trim().toLowerCase();
switch (normalized) {
case 'bash':
case 'sh':
case 'zsh':
case 'shell':
case 'shellsession':
case 'console':
return shellLanguage;
case 'toml':
return tomlLanguage;
case 'diff':
case 'patch':
return diffLanguage;
case 'json':
case 'jsonc':
case 'json5':
return json().language;
case 'js':
case 'javascript':
return javascript().language;
case 'jsx':
return javascript({ jsx: true }).language;
case 'ts':
case 'typescript':
return javascript({ typescript: true }).language;
case 'tsx':
return javascript({ typescript: true, jsx: true }).language;
case 'yaml':
case 'yml':
return yamlLanguage().language;
case 'html':
return html().language;
case 'css':
return css().language;
case 'xml':
case 'svg':
return xml().language;
case 'py':
case 'python':
return python().language;
case 'sql':
return sql().language;
case 'rs':
case 'rust':
return rust().language;
default:
return LanguageDescription.matchLanguageName(languages, normalized, true);
}
}
const normalizeFileName = (filePath: string) => filePath.split('/').pop()?.toLowerCase() ?? '';
export function languageByExtension(filePath: string): Extension | null {
const normalized = filePath.toLowerCase();
const filename = normalizeFileName(normalized);
// Special filenames
switch (filename) {
case 'dockerfile':
return dockerfileLanguage;
case 'makefile':
case 'gnumakefile':
// No dedicated mode; shell is a decent fallback for Make-ish files.
return shellLanguage;
}
const idx = normalized.lastIndexOf('.');
const ext = idx >= 0 ? normalized.slice(idx + 1) : '';
switch (ext) {
// JavaScript/TypeScript
case 'ts':
case 'tsx':
case 'mts':
case 'cts':
return javascript({ typescript: true, jsx: ext === 'tsx' });
case 'js':
case 'jsx':
case 'mjs':
case 'cjs':
return javascript({ typescript: false, jsx: ext === 'jsx' });
// Web
case 'json':
case 'jsonc':
case 'json5':
case 'jsonl':
case 'ndjson':
case 'geojson':
return json();
case 'css':
case 'scss':
case 'sass':
case 'less':
return css();
case 'html':
case 'htm':
return html();
case 'md':
case 'mdx':
case 'markdown':
case 'mdown':
case 'mkd':
return markdown({
codeLanguages: codeBlockLanguageResolver,
});
// Data/config
case 'yml':
case 'yaml':
return yamlLanguage();
case 'toml':
return tomlLanguage;
case 'ini':
case 'cfg':
case 'conf':
case 'config':
case 'properties':
return propertiesLanguage;
// Shell
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
case 'env':
return shellLanguage;
// Languages we already ship
case 'py':
case 'pyw':
case 'pyi':
return python();
case 'sql':
case 'psql':
case 'plsql':
return sql();
case 'xml':
case 'xsl':
case 'xslt':
case 'xsd':
case 'dtd':
case 'plist':
case 'svg':
return xml();
case 'rs':
return rust();
// Legacy modes
case 'rb':
case 'erb':
case 'rake':
case 'gemspec':
return rubyLanguage;
default:
return null;
}
}
+17
View File
@@ -5,6 +5,8 @@ import { getSafeStorage } from './utils/safeStorage';
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal' | 'files';
export type MainTabGuard = (nextTab: MainTab) => boolean;
export type EventStreamStatus =
| 'idle'
| 'connecting'
@@ -24,6 +26,7 @@ interface UIStore {
hasManuallyResizedLeftSidebar: boolean;
isSessionSwitcherOpen: boolean;
activeMainTab: MainTab;
mainTabGuard: MainTabGuard | null;
pendingDiffFile: string | null;
isMobile: boolean;
isKeyboardOpen: boolean;
@@ -64,6 +67,7 @@ interface UIStore {
setSidebarWidth: (width: number) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void;
setPendingDiffFile: (filePath: string | null) => void;
navigateToDiff: (filePath: string) => void;
consumePendingDiffFile: () => string | null;
@@ -120,6 +124,7 @@ export const useUIStore = create<UIStore>()(
hasManuallyResizedLeftSidebar: false,
isSessionSwitcherOpen: false,
activeMainTab: 'chat',
mainTabGuard: null,
pendingDiffFile: null,
isMobile: false,
isKeyboardOpen: false,
@@ -195,7 +200,15 @@ export const useUIStore = create<UIStore>()(
set({ isSessionSwitcherOpen: open });
},
setMainTabGuard: (guard) => {
set({ mainTabGuard: guard });
},
setActiveMainTab: (tab) => {
const guard = get().mainTabGuard;
if (guard && !guard(tab)) {
return;
}
set({ activeMainTab: tab });
},
@@ -204,6 +217,10 @@ export const useUIStore = create<UIStore>()(
},
navigateToDiff: (filePath) => {
const guard = get().mainTabGuard;
if (guard && !guard('diff')) {
return;
}
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
},