Compare commits

..
8 changed files with 313 additions and 6 deletions
@@ -0,0 +1,80 @@
import { useEffect, useRef } from 'react';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { oneDark } from '@codemirror/theme-one-dark';
import { useTheme } from 'next-themes';
import { lightTheme, lightHighlight } from './themes/light';
import { useEditorStore } from '@/stores/editor-store';
interface Props {
bufferId: string;
initialContent: string;
onChange?: (content: string) => void;
onCursorChange?: (line: number, column: number) => void;
}
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
const ref = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const themeCompartment = useRef(new Compartment());
const { resolvedTheme } = useTheme();
const updateContent = useEditorStore((s) => s.updateContent);
const setCursor = useEditorStore((s) => s.setCursor);
useEffect(() => {
if (!ref.current) return;
const state = EditorState.create({
doc: initialContent,
extensions: [
lineNumbers(),
highlightActiveLine(),
highlightSelectionMatches(),
history(),
drawSelection(),
markdown({ base: markdownLanguage, codeLanguages: [] }),
autocompletion(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]),
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]),
EditorView.lineWrapping,
EditorView.updateListener.of((v) => {
if (v.docChanged) {
const content = v.state.doc.toString();
updateContent(bufferId, content);
onChange?.(content);
}
if (v.selectionSet || v.docChanged) {
const head = v.state.selection.main.head;
const line = v.state.doc.lineAt(head);
const lineNo = line.number;
const col = head - line.from + 1;
setCursor(bufferId, lineNo, col);
onCursorChange?.(lineNo, col);
}
}),
],
});
const view = new EditorView({ state, parent: ref.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bufferId]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: themeCompartment.current.reconfigure(
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
),
});
}, [resolvedTheme]);
return <div ref={ref} className="h-full overflow-hidden" />;
}
@@ -0,0 +1,25 @@
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { useEditorStore } from '@/stores/editor-store';
export function EditorPane() {
const { buffers, activeId } = useEditorStore();
const buf = activeId ? buffers.get(activeId) : null;
if (!buf) {
return (
<div className="flex h-full items-center justify-center bg-background text-muted-foreground">
<p>No file open. Use File Open to start.</p>
</div>
);
}
return (
<div className="h-full">
<CodeMirrorEditor
key={buf.id}
bufferId={buf.id}
initialContent={buf.content}
/>
</div>
);
}
@@ -0,0 +1,55 @@
import { EditorView } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
const colors = {
background: '#ffffff',
foreground: '#0d0b09',
cursor: '#e5461f',
selection: 'rgba(229, 70, 31, 0.15)',
gutterBackground: '#fafbfc',
gutterForeground: '#7a7878',
lineHighlight: 'rgba(0, 0, 0, 0.04)',
};
export const lightTheme = EditorView.theme(
{
'&': {
backgroundColor: colors.background,
color: colors.foreground,
height: '100%',
},
'.cm-content': {
caretColor: colors.cursor,
fontFamily: 'JetBrains Mono, Fira Code, monospace',
fontSize: '13.5px',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: colors.cursor },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
backgroundColor: colors.selection,
},
'.cm-gutters': {
backgroundColor: colors.gutterBackground,
color: colors.gutterForeground,
border: 'none',
},
'.cm-activeLine': { backgroundColor: colors.lineHighlight },
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: '#e5461f' },
},
{ dark: false }
);
const highlightStyle = HighlightStyle.define([
{ tag: t.heading1, color: '#0d0b09', fontWeight: '700' },
{ tag: t.heading2, color: '#0d0b09', fontWeight: '700' },
{ tag: t.heading3, color: '#464646', fontWeight: '600' },
{ tag: t.link, color: '#e5461f', textDecoration: 'underline' },
{ tag: t.url, color: '#e5461f' },
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strong, fontWeight: '700' },
{ tag: t.monospace, color: '#c93a18' },
{ tag: t.list, color: '#0ea5e9' },
{ tag: t.quote, color: '#7a7878', fontStyle: 'italic' },
]);
export const lightHighlight = syntaxHighlighting(highlightStyle);
+2 -3
View File
@@ -3,6 +3,7 @@ import { TabBar } from './TabBar';
import { Toolbar } from './Toolbar'; import { Toolbar } from './Toolbar';
import { Breadcrumb } from './Breadcrumb'; import { Breadcrumb } from './Breadcrumb';
import { StatusBar } from './StatusBar'; import { StatusBar } from './StatusBar';
import { EditorPane } from '@/components/editor/EditorPane';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
@@ -31,9 +32,7 @@ export function AppShell() {
</> </>
)} )}
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}> <ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
<section className="h-full bg-background p-4 text-sm text-muted-foreground"> <EditorPane />
Editor placeholder
</section>
</ResizablePanel> </ResizablePanel>
{previewVisible && ( {previewVisible && (
<> <>
+13 -2
View File
@@ -1,12 +1,23 @@
import { useEditorStore } from '@/stores/editor-store';
function countWords(text: string): number {
return text.trim().length === 0 ? 0 : text.trim().split(/\s+/).length;
}
export function StatusBar() { export function StatusBar() {
const { buffers, activeId } = useEditorStore();
const buf = activeId ? buffers.get(activeId) : null;
const wordCount = buf ? countWords(buf.content) : 0;
const cursor = buf?.cursor ?? { line: 1, column: 1 };
return ( return (
<footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground"> <footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span>0 words</span> <span>{wordCount} words</span>
<span>UTF-8</span> <span>UTF-8</span>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span>Ln 1, Col 1</span> <span>Ln {cursor.line}, Col {cursor.column}</span>
<span>Markdown</span> <span>Markdown</span>
</div> </div>
</footer> </footer>
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { enableMapSet } from 'immer';
enableMapSet();
export interface Buffer {
id: string;
path: string;
content: string;
dirty: boolean;
cursor?: { line: number; column: number };
}
interface EditorState {
buffers: Map<string, Buffer>;
activeId: string | null;
openBuffer: (id: string, path: string, content: string) => void;
updateContent: (id: string, content: string) => void;
markSaved: (id: string) => void;
setCursor: (id: string, line: number, column: number) => void;
closeBuffer: (id: string) => void;
setActive: (id: string) => void;
}
export const useEditorStore = create<EditorState>()(
immer((set) => ({
buffers: new Map(),
activeId: null,
openBuffer: (id, path, content) =>
set((s) => {
s.buffers.set(id, { id, path, content, dirty: false });
s.activeId = id;
}),
updateContent: (id, content) =>
set((s) => {
const buf = s.buffers.get(id);
if (buf) {
buf.content = content;
buf.dirty = true;
}
}),
markSaved: (id) =>
set((s) => {
const buf = s.buffers.get(id);
if (buf) buf.dirty = false;
}),
setCursor: (id, line, column) =>
set((s) => {
const buf = s.buffers.get(id);
if (buf) buf.cursor = { line, column };
}),
closeBuffer: (id) =>
set((s) => {
s.buffers.delete(id);
if (s.activeId === id) s.activeId = null;
}),
setActive: (id) =>
set((s) => {
s.activeId = id;
}),
}))
);
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';
import { EditorPane } from '@/components/editor/EditorPane';
import { useEditorStore } from '@/stores/editor-store';
import { CodeMirrorEditor } from '@/components/editor/CodeMirrorEditor';
vi.mock('@/components/editor/CodeMirrorEditor', () => ({
CodeMirrorEditor: ({ initialContent }: { initialContent: string }) => (
<div data-testid="codemirror-mock">{initialContent}</div>
),
}));
describe('EditorPane', () => {
beforeEach(() => {
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
it('renders the empty state when no buffer is open', () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<EditorPane />
</ThemeProvider>
);
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
});
it('renders the editor when a buffer is open', () => {
useEditorStore.getState().openBuffer('b1', '/x.md', '# hello');
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<EditorPane />
</ThemeProvider>
);
expect(screen.getByTestId('codemirror-mock')).toBeInTheDocument();
expect(screen.getByTestId('codemirror-mock')).toHaveTextContent('# hello');
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useEditorStore } from '@/stores/editor-store';
describe('useEditorStore', () => {
beforeEach(() => {
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
it('creates a new buffer with the given content', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '# hello');
const buf = useEditorStore.getState().buffers.get('file-1');
expect(buf?.content).toBe('# hello');
expect(buf?.path).toBe('/foo.md');
});
it('updates content and marks dirty', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().updateContent('file-1', 'new content');
const buf = useEditorStore.getState().buffers.get('file-1');
expect(buf?.content).toBe('new content');
expect(buf?.dirty).toBe(true);
});
it('marks a buffer clean after save', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().updateContent('file-1', 'x');
useEditorStore.getState().markSaved('file-1');
expect(useEditorStore.getState().buffers.get('file-1')?.dirty).toBe(false);
});
it('closes a buffer and removes it', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().closeBuffer('file-1');
expect(useEditorStore.getState().buffers.has('file-1')).toBe(false);
});
});