mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6a55dd09 | ||
|
|
0e80e37d9d | ||
|
|
9a7e994224 | ||
|
|
3effacf816 | ||
|
|
1be10f9ae5 | ||
|
|
4c0b7d6e84 | ||
|
|
db04ea5854 | ||
|
|
567726022c | ||
|
|
1999a7716e | ||
|
|
16e071ae5e | ||
|
|
7ecf54ceb0 | ||
|
|
95e2f45f1f | ||
|
|
7fbbd60525 | ||
|
|
c715bb354c | ||
|
|
6525de7667 | ||
|
|
a2c1d0c11a | ||
|
|
e0d6315204 | ||
|
|
68ff7d076a | ||
|
|
67b57eae78 | ||
|
|
301c51ac84 | ||
|
|
904ffbdd5b | ||
|
|
ea383783ec | ||
|
|
e758a81c4c | ||
|
|
ea0f3f93bf | ||
|
|
188e73d7c1 | ||
|
|
8265e4ff88 | ||
|
|
8a8671b128 |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/renderer/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Generated
+1423
-107
File diff suppressed because it is too large
Load Diff
+24
-1
@@ -8,6 +8,9 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:renderer": "vitest run",
|
||||||
|
"test:renderer:watch": "vitest",
|
||||||
|
"test:renderer:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src tests",
|
"lint": "eslint src tests",
|
||||||
"lint:fix": "eslint src tests --fix",
|
"lint:fix": "eslint src tests --fix",
|
||||||
"format": "prettier --write src tests",
|
"format": "prettier --write src tests",
|
||||||
@@ -43,10 +46,16 @@
|
|||||||
"url": "https://github.com/amitwh/markdown-converter"
|
"url": "https://github.com/amitwh/markdown-converter"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"@vitest/ui": "^4.1.8",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -58,6 +67,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
@@ -66,7 +76,8 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
@@ -83,6 +94,11 @@
|
|||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.16",
|
"@codemirror/view": "^6.39.16",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
@@ -93,18 +109,25 @@
|
|||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
|
"immer": "^11.1.8",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"marked-footnote": "^1.4.0",
|
"marked-footnote": "^1.4.0",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
|
"motion": "^12.40.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.5.207",
|
"pdfjs-dist": "^5.5.207",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"pizzip": "^3.2.0",
|
"pizzip": "^3.2.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-hook-form": "^7.77.0",
|
||||||
|
"react-resizable-panels": "^4.11.2",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
+2
-42
@@ -1,47 +1,7 @@
|
|||||||
import React from 'react';
|
import { AppShell } from './components/layout/AppShell';
|
||||||
import { AppHeader } from './components/layout/AppHeader';
|
|
||||||
import { TabBar } from './components/layout/TabBar';
|
|
||||||
import { Toolbar } from './components/layout/Toolbar';
|
|
||||||
import { Breadcrumb } from './components/layout/Breadcrumb';
|
|
||||||
import { Sidebar } from './components/sidebar/Sidebar';
|
|
||||||
import { EditorPane } from './components/editor/EditorPane';
|
|
||||||
import { PreviewPane } from './components/preview/PreviewPane';
|
|
||||||
import { StatusBar } from './components/layout/StatusBar';
|
|
||||||
import { CommandPalette } from './components/modals/CommandPalette';
|
|
||||||
import { ExportDialog } from './components/modals/ExportDialog';
|
|
||||||
import { SettingsDialog } from './components/modals/SettingsDialog';
|
|
||||||
import { useAppStore } from './stores/appStore';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { sidebarVisible, previewVisible, activeTabId } = useAppStore();
|
return <AppShell />;
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-screen bg-background text-foreground overflow-hidden">
|
|
||||||
<AppHeader />
|
|
||||||
<TabBar />
|
|
||||||
<Toolbar />
|
|
||||||
<Breadcrumb />
|
|
||||||
|
|
||||||
<main className="flex flex-1 overflow-hidden">
|
|
||||||
{sidebarVisible && <Sidebar />}
|
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
<EditorPane className={previewVisible ? 'flex-1' : 'flex-1'} />
|
|
||||||
{previewVisible && (
|
|
||||||
<>
|
|
||||||
<div className="w-px bg-border cursor-col-resize hover:bg-brand transition-colors" />
|
|
||||||
<PreviewPane className="flex-1" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<StatusBar />
|
|
||||||
<CommandPalette />
|
|
||||||
<ExportDialog />
|
|
||||||
<SettingsDialog />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { PanelLeft, PanelRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const { sidebarVisible, previewVisible, toggleSidebar, togglePreview } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="h-7 w-7 rounded-md bg-gradient-to-br from-brand to-brand-dark shadow-[var(--shadow-glow-brand)]"
|
||||||
|
aria-label="MarkdownConverter logo"
|
||||||
|
/>
|
||||||
|
<h1 className="font-display text-lg font-bold tracking-tight">MarkdownConverter</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
aria-pressed={sidebarVisible}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle preview"
|
||||||
|
aria-pressed={previewVisible}
|
||||||
|
onClick={togglePreview}
|
||||||
|
>
|
||||||
|
<PanelRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { AppHeader } from './AppHeader';
|
||||||
|
import { TabBar } from './TabBar';
|
||||||
|
import { Toolbar } from './Toolbar';
|
||||||
|
import { Breadcrumb } from './Breadcrumb';
|
||||||
|
import { StatusBar } from './StatusBar';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
<AppHeader />
|
||||||
|
<TabBar />
|
||||||
|
<Toolbar />
|
||||||
|
<Breadcrumb />
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
|
||||||
|
>
|
||||||
|
{sidebarVisible && (
|
||||||
|
<>
|
||||||
|
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
|
||||||
|
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
|
||||||
|
File tree placeholder
|
||||||
|
</aside>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
|
||||||
|
<EditorPane />
|
||||||
|
</ResizablePanel>
|
||||||
|
{previewVisible && (
|
||||||
|
<>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
||||||
|
<section className="h-full border-l border-border bg-card/10 p-4 text-sm text-muted-foreground">
|
||||||
|
Preview placeholder
|
||||||
|
</section>
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</main>
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function Breadcrumb() {
|
||||||
|
return (
|
||||||
|
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No file selected</span>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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() {
|
||||||
|
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 (
|
||||||
|
<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">
|
||||||
|
<span>{wordCount} words</span>
|
||||||
|
<span>UTF-8</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>Ln {cursor.line}, Col {cursor.column}</span>
|
||||||
|
<span>Markdown</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function TabBar() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No files open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function Toolbar() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3">
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Bold"><Bold className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Italic"><Italic className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Unordered list"><List className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Ordered list"><ListOrdered className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Code"><Code className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Link"><LinkIcon className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Toggle theme" className="opacity-0">
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { GripVertical } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ResizablePanelGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ResizablePrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ResizablePanelGroup.displayName = "ResizablePanelGroup"
|
||||||
|
|
||||||
|
const ResizablePanel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ResizablePrimitive.Panel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Panel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Panel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ResizablePanel.displayName = "ResizablePanel"
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Separator> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.Separator
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.Separator>
|
||||||
|
)
|
||||||
|
ResizableHandle.displayName = "ResizableHandle"
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type {
|
||||||
|
IpcResult,
|
||||||
|
FileResult,
|
||||||
|
FileEntry,
|
||||||
|
PdfOptions,
|
||||||
|
DocxOptions,
|
||||||
|
HtmlOptions,
|
||||||
|
ExportResult,
|
||||||
|
BatchItem,
|
||||||
|
BatchOptions,
|
||||||
|
BatchResult,
|
||||||
|
} from '@/types/ipc';
|
||||||
|
|
||||||
|
type ChannelMissing = { code: 'CHANNEL_MISSING'; message: string };
|
||||||
|
|
||||||
|
function wrap<T>(fn: () => Promise<T>): Promise<IpcResult<T | ChannelMissing>> {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fn().then(
|
||||||
|
(data) => ({ ok: true as const, data }),
|
||||||
|
(err: Error) => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: { code: err.name || 'IPC_ERROR', message: err.message || String(err) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCall<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
channel: string,
|
||||||
|
method: string,
|
||||||
|
...args: Parameters<T>
|
||||||
|
): Promise<IpcResult<Awaited<ReturnType<T>> | ChannelMissing>> {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const target = (window.electronAPI as any)[channel]?.[method];
|
||||||
|
if (!target) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'CHANNEL_MISSING', message: `Missing channel: ${channel}.${method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof target !== 'function') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'CHANNEL_MISSING', message: `Not a function: ${channel}.${method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wrap(() => target(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ipc = {
|
||||||
|
file: {
|
||||||
|
open: (): Promise<IpcResult<FileResult | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'open'),
|
||||||
|
read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'read', path),
|
||||||
|
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'write', path, content),
|
||||||
|
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'list', dir),
|
||||||
|
onChange: (cb: (path: string) => void): (() => void) => {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
return window.electronAPI.file.onChange(cb);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'pdf', opts),
|
||||||
|
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'docx', opts),
|
||||||
|
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'html', opts),
|
||||||
|
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'batch', items, opts),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: (): Promise<IpcResult<string | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'getVersion'),
|
||||||
|
openExternal: (url: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'openExternal', url),
|
||||||
|
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'showItemInFolder', path),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Transition, Variants } from 'motion/react';
|
||||||
|
|
||||||
|
export const fadeIn: Transition = {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slideInRight: Variants = {
|
||||||
|
x: '100%',
|
||||||
|
initial: { x: '100%', opacity: 0 },
|
||||||
|
animate: { x: 0, opacity: 1, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] } },
|
||||||
|
exit: { x: '100%', opacity: 0, transition: { duration: 0.2, ease: 'easeIn' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modalPop: Variants = {
|
||||||
|
scale: 0.96,
|
||||||
|
opacity: 0,
|
||||||
|
initial: { scale: 0.96, opacity: 0 },
|
||||||
|
animate: { scale: 1, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' } },
|
||||||
|
exit: { scale: 0.96, opacity: 0, transition: { duration: 0.15, ease: 'easeIn' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastSpring: Transition = {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sidebarToggle: Transition = {
|
||||||
|
duration: 0.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
width: {
|
||||||
|
duration: 0.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tabSwitch: Transition = {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut',
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { ThemeProvider } from './components/theme-provider';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class" enableSystem>
|
||||||
<App />
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface PaneSizes {
|
||||||
|
sidebar: number;
|
||||||
|
editor: number;
|
||||||
|
preview: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
sidebarVisible: boolean;
|
||||||
|
previewVisible: boolean;
|
||||||
|
zenMode: boolean;
|
||||||
|
paneSizes: PaneSizes;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
togglePreview: () => void;
|
||||||
|
setZenMode: (value: boolean) => void;
|
||||||
|
setPaneSizes: (sizes: PaneSizes) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
|
||||||
|
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
|
||||||
|
setZenMode: (value) => set({ zenMode: value }),
|
||||||
|
setPaneSizes: (sizes) => set({ paneSizes: sizes }),
|
||||||
|
}),
|
||||||
|
{ name: 'mc-app-store' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
@@ -24,6 +24,14 @@
|
|||||||
--input: 0 0% 90%;
|
--input: 0 0% 90%;
|
||||||
--ring: 11 79% 51%;
|
--ring: 11 79% 51%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -46,6 +54,14 @@
|
|||||||
--border: 30 6% 15%;
|
--border: 30 6% 15%;
|
||||||
--input: 30 6% 15%;
|
--input: 30 6% 15%;
|
||||||
--ring: 11 79% 51%;
|
--ring: 11 79% 51%;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// Mock window.electronAPI for tests
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||||
|
window.electronAPI = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock matchMedia for next-themes (jsdom doesn't have it)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom environment
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
|
||||||
|
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type IpcResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size?: number;
|
||||||
|
modifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileResult {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
format?: 'letter' | 'a4' | 'legal';
|
||||||
|
margins?: { top: number; right: number; bottom: number; left: number };
|
||||||
|
toc?: boolean;
|
||||||
|
embedFonts?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocxOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
template?: string;
|
||||||
|
referenceDoc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtmlOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
standalone?: boolean;
|
||||||
|
highlightStyle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
outputPath: string;
|
||||||
|
bytes: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchItem {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOptions {
|
||||||
|
format: 'pdf' | 'docx' | 'html' | 'png';
|
||||||
|
concurrency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchResult {
|
||||||
|
total: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{ item: BatchItem; ok: boolean; error?: string }>;
|
||||||
|
}
|
||||||
+60
-20
@@ -1,3 +1,5 @@
|
|||||||
|
const animate = require('tailwindcss-animate')
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ['class'],
|
darkMode: ['class'],
|
||||||
@@ -6,42 +8,80 @@ module.exports = {
|
|||||||
'./src/renderer/index.html',
|
'./src/renderer/index.html',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: { '2xl': '1400px' },
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
brand: {
|
brand: {
|
||||||
DEFAULT: '#e5461f',
|
DEFAULT: '#e5461f',
|
||||||
dark: '#c93a18',
|
dark: '#c93a18',
|
||||||
light: '#ff6b47',
|
light: '#ff6b47',
|
||||||
},
|
},
|
||||||
success: '#1a7a56',
|
success: '#1a7a56',
|
||||||
warning: '#eab308',
|
warning: 'hsl(45 93% 47%)',
|
||||||
danger: '#ef4444',
|
danger: 'hsl(0 84% 60%)',
|
||||||
info: '#0ea5e9',
|
info: 'hsl(199 89% 48%)',
|
||||||
warm: {
|
warm: {
|
||||||
50: '#fafbfc',
|
50: '#fafbfc', 100: '#f5f5f5', 200: '#e3e3e3', 300: '#d1d1d1',
|
||||||
100: '#f5f5f5',
|
400: '#b0b0b0', 500: '#9a9696', 600: '#7a7878', 700: '#5a5858',
|
||||||
200: '#e3e3e3',
|
800: '#4e4e4e', 900: '#464646', 950: '#0d0b09',
|
||||||
300: '#d1d1d1',
|
|
||||||
400: '#b0b0b0',
|
|
||||||
500: '#9a9696',
|
|
||||||
600: '#7a7878',
|
|
||||||
700: '#5a5858',
|
|
||||||
800: '#4e4e4e',
|
|
||||||
900: '#464646',
|
|
||||||
950: '#0d0b09',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||||
display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'],
|
display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'],
|
||||||
mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'],
|
mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'],
|
||||||
},
|
},
|
||||||
borderRadius: {
|
keyframes: {
|
||||||
lg: '0.75rem',
|
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
|
||||||
md: '0.5rem',
|
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
|
||||||
sm: '0.25rem',
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate')],
|
plugins: [animate],
|
||||||
};
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { AppHeader } from '@/components/layout/AppHeader';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('AppHeader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the app title', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sidebar when sidebar button clicked', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom environment
|
||||||
|
vi.mock('@/components/ui/resizable', () => ({
|
||||||
|
ResizablePanelGroup: ({ children, direction, onLayout }: any) => (
|
||||||
|
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizablePanel: ({ children, defaultSize, minSize, maxSize }: any) => (
|
||||||
|
<div data-testid="resizable-panel" data-size={defaultSize} data-min={minSize} data-max={maxSize}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AppShell', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all shell surfaces when sidebar and preview are visible', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppShell />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no file selected/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sidebar when sidebarVisible is false', () => {
|
||||||
|
useAppStore.setState({ sidebarVisible: false });
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppShell />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { StatusBar } from '@/components/layout/StatusBar';
|
||||||
|
|
||||||
|
describe('StatusBar', () => {
|
||||||
|
it('renders zero word count when no file is open', () => {
|
||||||
|
render(<StatusBar />);
|
||||||
|
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { TabBar } from '@/components/layout/TabBar';
|
||||||
|
|
||||||
|
describe('TabBar', () => {
|
||||||
|
it('renders an empty state when no tabs are open', () => {
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Toolbar } from '@/components/layout/Toolbar';
|
||||||
|
|
||||||
|
describe('Toolbar', () => {
|
||||||
|
it('renders formatting buttons', () => {
|
||||||
|
render(<Toolbar />);
|
||||||
|
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove('dark', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles between light and dark when clicked', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="light" attribute="class">
|
||||||
|
<ThemeToggle />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).toHaveAccessibleName(/toggle theme/i);
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Button>Click me</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick when clicked', async () => {
|
||||||
|
let count = 0;
|
||||||
|
render(<Button onClick={() => count++}>+</Button>);
|
||||||
|
await userEvent.click(screen.getByRole('button'));
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the brand variant with primary styling', () => {
|
||||||
|
render(<Button variant="default">Brand</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.className).toContain('bg-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
describe('cn', () => {
|
||||||
|
it('merges class names', () => {
|
||||||
|
expect(cn('a', 'b')).toBe('a b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates conflicting tailwind classes', () => {
|
||||||
|
expect(cn('p-2', 'p-4')).toBe('p-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores falsy values', () => {
|
||||||
|
expect(cn('a', false, null, undefined, '', 'b')).toBe('a b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts conditional class objects', () => {
|
||||||
|
expect(cn('base', { active: true, disabled: false })).toBe('base active');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import type { FileResult } from '@/types/ipc';
|
||||||
|
|
||||||
|
describe('ipc wrapper', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.electronAPI = {
|
||||||
|
file: {
|
||||||
|
read: vi.fn().mockResolvedValue('# hello'),
|
||||||
|
write: vi.fn().mockResolvedValue(undefined),
|
||||||
|
list: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file.read returns ok result on success', async () => {
|
||||||
|
const result = await ipc.file.read('/foo.md');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toBe('# hello');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file.read returns err result when channel throws', async () => {
|
||||||
|
window.electronAPI.file.read = vi.fn().mockRejectedValue(new Error('ENOENT'));
|
||||||
|
const result = await ipc.file.read('/missing.md');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.message).toBe('ENOENT');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file.read returns err result when channel missing', async () => {
|
||||||
|
delete (window.electronAPI.file as any).read;
|
||||||
|
const result = await ipc.file.read('/foo.md');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.code).toBe('CHANNEL_MISSING');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
fadeIn,
|
||||||
|
slideInRight,
|
||||||
|
modalPop,
|
||||||
|
toastSpring,
|
||||||
|
sidebarToggle,
|
||||||
|
} from '@/lib/motion';
|
||||||
|
|
||||||
|
describe('motion presets', () => {
|
||||||
|
it('fadeIn is a valid transition object', () => {
|
||||||
|
expect(fadeIn.duration).toBeGreaterThan(0);
|
||||||
|
expect(fadeIn.ease).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slideInRight uses translateX transform', () => {
|
||||||
|
expect(slideInRight.x).toBe('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('modalPop uses scale and opacity', () => {
|
||||||
|
expect(modalPop.scale).toBeDefined();
|
||||||
|
expect(modalPop.opacity).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toastSpring has spring physics', () => {
|
||||||
|
expect(toastSpring.type).toBe('spring');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebarToggle animates width', () => {
|
||||||
|
expect(sidebarToggle.width.duration).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('useAppStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sidebar visibility', () => {
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(true);
|
||||||
|
useAppStore.getState().toggleSidebar();
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles preview visibility', () => {
|
||||||
|
useAppStore.getState().togglePreview();
|
||||||
|
expect(useAppStore.getState().previewVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates pane sizes', () => {
|
||||||
|
useAppStore.getState().setPaneSizes({ sidebar: 25, editor: 50, preview: 25 });
|
||||||
|
expect(useAppStore.getState().paneSizes).toEqual({ sidebar: 25, editor: 50, preview: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables and disables zen mode', () => {
|
||||||
|
useAppStore.getState().setZenMode(true);
|
||||||
|
expect(useAppStore.getState().zenMode).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/renderer/test/setup.ts'],
|
||||||
|
include: ['tests/**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/renderer/**/*.{ts,tsx}'],
|
||||||
|
exclude: ['src/renderer/test/**', 'src/renderer/**/*.test.*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.join(__dirname, 'src/renderer'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user