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