diff --git a/scripts/verify-open-export.mjs b/scripts/verify-open-export.mjs
new file mode 100644
index 0000000..ff04ecf
--- /dev/null
+++ b/scripts/verify-open-export.mjs
@@ -0,0 +1,148 @@
+import { _electron as electron } from 'playwright-core';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+const APP_DIR = '/home/amith/apps/markdown-converter';
+const electronBin = path.join(APP_DIR, 'node_modules/electron/dist/electron');
+
+const testMd = '/tmp/verify-test.md';
+const outputDocx = '/tmp/verify-output.docx';
+const outputHtml = '/tmp/verify-output.html';
+
+// Cleanup previous outputs
+if (fs.existsSync(testMd)) fs.unlinkSync(testMd);
+if (fs.existsSync(outputDocx)) fs.unlinkSync(outputDocx);
+if (fs.existsSync(outputHtml)) fs.unlinkSync(outputHtml);
+
+// Write test Markdown file
+fs.writeFileSync(
+ testMd,
+ '# Test Document\n\nThis is a verification test for opening and exporting.\n\n- Point A\n- Point B\n'
+);
+
+console.log('Launching Electron...');
+const app = await electron.launch({
+ executablePath: electronBin,
+ args: [
+ '--no-sandbox',
+ '--disable-gpu',
+ '--disable-software-rasterizer',
+ '--disable-dev-shm-usage',
+ '.',
+ ],
+ env: {
+ ...process.env,
+ DISPLAY: ':0',
+ ELECTRON_DISABLE_SANDBOX: '1',
+ VITE_DEV_SERVER_URL: 'http://localhost:5173',
+ },
+ cwd: APP_DIR,
+});
+
+app.process().stdout.on('data', (data) => console.log('[MAIN-OUT]', data.toString().trim()));
+app.process().stderr.on('data', (data) => console.log('[MAIN-ERR]', data.toString().trim()));
+
+const win = await app.firstWindow();
+await win.waitForLoadState('domcontentloaded');
+await win.waitForSelector('.cm-editor, [role="toolbar"]', { timeout: 10000 });
+console.log('App loaded.');
+
+// Dismiss welcome wizard if present
+const wizardCount = await win.locator('[data-testid="first-run-wizard"]').count();
+if (wizardCount > 0) {
+ console.log('Dismissing first run wizard...');
+ await win.click('[data-testid="first-run-wizard"] >> text=Skip');
+ await new Promise((r) => setTimeout(r, 300));
+}
+
+// Stub dialog.showSaveDialogSync in main process to automatically return output paths
+await app.evaluate(({ dialog }, { outputDocx, outputHtml }) => {
+ dialog.showSaveDialogSync = (window, options) => {
+ const filters = options?.filters || [];
+ if (filters.some(f => f.extensions.includes('docx'))) {
+ return outputDocx;
+ }
+ return outputHtml;
+ };
+}, { outputDocx, outputHtml });
+
+// Simulate opening the test Md file by sending IPC from main
+console.log('Opening test markdown file...');
+const fileContent = fs.readFileSync(testMd, 'utf-8');
+await app.evaluate(({ BrowserWindow }, { filePath, content }) => {
+ const wins = BrowserWindow.getAllWindows();
+ const main = wins.find((w) => !w.isDestroyed());
+ if (!main) throw new Error('No main window');
+ main.webContents.send('file-opened', { path: filePath, content });
+}, { filePath: testMd, content: fileContent });
+
+// Wait for editor to display the content
+await win.waitForFunction(
+ (content) => {
+ const editor = document.querySelector('.cm-content');
+ return editor && editor.textContent.includes('Test Document');
+ },
+ fileContent,
+ { timeout: 5000 }
+);
+console.log('File successfully opened in editor.');
+
+// Let's wait a moment for currentFile synchronization to trigger in the main process
+await new Promise((r) => setTimeout(r, 500));
+
+
+// Trigger DOCX Export (calls performExportWithOptions under the hood)
+console.log('Exporting to DOCX...');
+await win.evaluate(() => {
+ window.electronAPI.export.withOptions('docx', {});
+});
+
+// Wait for file to be written to disk
+let docxExported = false;
+for (let i = 0; i < 20; i++) {
+ if (fs.existsSync(outputDocx) && fs.statSync(outputDocx).size > 0) {
+ docxExported = true;
+ break;
+ }
+ await new Promise((r) => setTimeout(r, 250));
+}
+
+if (docxExported) {
+ console.log('✅ DOCX exported successfully.');
+} else {
+ console.error('❌ DOCX export failed (file not created or empty).');
+}
+
+// Wait 2.5 seconds to bypass the conversion rate limiter (2000ms debounce)
+console.log('Waiting for rate limiter...');
+await new Promise((r) => setTimeout(r, 2500));
+
+// Trigger HTML Export
+console.log('Exporting to HTML...');
+await win.evaluate(() => {
+ window.electronAPI.export.withOptions('html', {});
+});
+
+let htmlExported = false;
+for (let i = 0; i < 20; i++) {
+ if (fs.existsSync(outputHtml) && fs.statSync(outputHtml).size > 0) {
+ htmlExported = true;
+ break;
+ }
+ await new Promise((r) => setTimeout(r, 250));
+}
+
+if (htmlExported) {
+ console.log('✅ HTML exported successfully.');
+} else {
+ console.error('❌ HTML export failed (file not created or empty).');
+}
+
+await app.close();
+console.log('Verification completed.');
+
+if (docxExported && htmlExported) {
+ process.exit(0);
+} else {
+ process.exit(1);
+}
diff --git a/src/main/menu/items.js b/src/main/menu/items.js
index 49d2cc5..d61eb55 100644
--- a/src/main/menu/items.js
+++ b/src/main/menu/items.js
@@ -221,13 +221,20 @@ function viewItems(mainWindow) {
accelerator: 'CmdOrCtrl+Shift+V',
click: () => mainWindow.webContents.send('toggle-preview')
},
+ {
+ label: 'Writing Analytics',
+ accelerator: 'CmdOrCtrl+Shift+A',
+ click: () => mainWindow.webContents.send('show-analytics-dialog')
+ },
// NOTE: Command Palette removed — handled by useCommandStore
{ type: 'separator' },
{
label: 'Sidebar',
submenu: [
{ label: 'Files', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
- { label: 'Outline', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
+ { label: 'Outline', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'outline') },
+ { label: 'Snippets', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
+ { label: 'Templates', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates') },
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') }
]
},
diff --git a/src/preload.js b/src/preload.js
index 5da0805..1a12b3c 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -246,6 +246,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
// Updater
'updater:status',
+ 'show-analytics-dialog',
// File dialog / directory listing
'list-directory',
diff --git a/src/renderer/components/modals/ModalLayer.tsx b/src/renderer/components/modals/ModalLayer.tsx
index afc032e..515739d 100644
--- a/src/renderer/components/modals/ModalLayer.tsx
+++ b/src/renderer/components/modals/ModalLayer.tsx
@@ -12,6 +12,7 @@ import { SettingsSheet } from './SettingsSheet';
import { TableGeneratorDialog } from './TableGeneratorDialog';
import { WelcomeDialog } from './WelcomeDialog';
import { WordExportDialog } from './WordExportDialog';
+import { WritingAnalyticsDialog } from './WritingAnalyticsDialog';
export function ModalLayer() {
const modal = useAppStore((s) => s.modal);
@@ -44,5 +45,7 @@ export function ModalLayer() {
return ;
case 'crashReports':
return ;
+ case 'writing-analytics':
+ return ;
}
}
\ No newline at end of file
diff --git a/src/renderer/components/modals/WritingAnalyticsDialog.tsx b/src/renderer/components/modals/WritingAnalyticsDialog.tsx
new file mode 100644
index 0000000..4f3bc3d
--- /dev/null
+++ b/src/renderer/components/modals/WritingAnalyticsDialog.tsx
@@ -0,0 +1,276 @@
+import { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { useAppStore } from '@/stores/app-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useEditorStore } from '@/stores/editor-store';
+import { analyzeText } from '@/lib/writing-analytics';
+import { BookOpen, Clock, FileText, BarChart2, Target, Award, Sparkles, Mic } from 'lucide-react';
+
+export function WritingAnalyticsDialog() {
+ const closeModal = useAppStore((s) => s.closeModal);
+ const isOpen = useAppStore((s) => s.modal.kind) === 'writing-analytics';
+ const { dailyGoal, setSetting } = useSettingsStore();
+ const activeId = useEditorStore((s) => s.activeId);
+ const buffer = activeId ? useEditorStore((s) => s.buffers.get(activeId)) : null;
+ const content = buffer?.content || '';
+
+ const metrics = analyzeText(content);
+ const percentage = dailyGoal > 0 ? Math.round((metrics.wordCount / dailyGoal) * 100) : 0;
+ const isGoalReached = metrics.wordCount >= dailyGoal && dailyGoal > 0;
+
+ // Track celebration state to trigger an animation on goal completion
+ const [celebrated, setCelebrated] = useState(false);
+
+ useEffect(() => {
+ if (isGoalReached && !celebrated) {
+ setCelebrated(true);
+ } else if (!isGoalReached && celebrated) {
+ setCelebrated(false);
+ }
+ }, [isGoalReached, celebrated]);
+
+ // Find max count for word cloud scaling
+ const maxWordCount = metrics.topWords.length > 0 ? Math.max(...metrics.topWords.map((w) => w.count)) : 1;
+
+ // Helper to determine color coding for Flesch Readability Ease
+ const getReadabilityColor = (score: number) => {
+ if (score >= 70) return 'text-[#1a7a56]'; // Easy / Very Easy
+ if (score >= 50) return 'text-amber-500'; // Standard
+ return 'text-rose-500'; // Difficult / Very Difficult
+ };
+
+ return (
+ !o && closeModal()}>
+
+
+
+
+ Writing Analytics
+
+
+ Real-time readability metrics, vocabulary analysis, and progress towards your daily word goal.
+
+
+
+
+ {/* Left Column: Readability, Structure, Timing */}
+
+ {/* Readability Card */}
+
+
+
+
Readability
+
+
+
+
Flesch Reading Ease
+
+ {metrics.fleschEase}
+
+
+ {metrics.readabilityLabel}
+
+
+
+
Grade Level
+
+ {metrics.fleschGrade}
+
+
US school grade
+
+
+
+
+ {/* Structure Card */}
+
+
+
+
Structure
+
+
+
+
Words
+
{metrics.wordCount}
+
+
+
Sentences
+
{metrics.sentenceCount}
+
+
+
Paragraphs
+
{metrics.paragraphCount}
+
+
+
+
+ Average Sentence Length:
+ {metrics.avgSentenceLength} words
+
+ {metrics.longestSentence && (
+
+
+ Longest Sentence ({metrics.longestSentenceLength} words):
+
+
+ "{metrics.longestSentence}"
+
+
+ )}
+
+
+
+ {/* Timing Card */}
+
+
+
+
Timing
+
+
+
+
+
+
+
+
Reading Time
+
{metrics.readingTime} min
+
+
+
+
+
+
+
+
Speaking Time
+
{metrics.speakingTime} min
+
+
+
+
+
+
+ {/* Right Column: Word Goal, Vocabulary */}
+
+ {/* Word Goal Card */}
+
+
+
+ {isGoalReached ? (
+
+ ) : (
+
+ )}
+
Daily Word Goal
+
+
+ Target:
+ {
+ const val = parseInt(e.target.value, 10);
+ if (!isNaN(val) && val > 0) {
+ setSetting('dailyGoal', val);
+ }
+ }}
+ className="h-7 w-20 text-xs px-2"
+ />
+
+
+
+
+
+
+ {metrics.wordCount} / {dailyGoal} words
+
+
+ {percentage}%
+
+
+
+
+
+ {isGoalReached && (
+
+
+ Goal accomplished! Keep writing to set a new record.
+
+ )}
+
+
+
+ {/* Vocabulary & Word Cloud Card */}
+
+
+
Vocabulary & Top Words
+
+ Lexical Diversity:
+ {metrics.lexicalDiversity}%
+
+
+
+
+ Unique words: {metrics.uniqueWordCount} /{' '}
+ {metrics.wordCount} ({metrics.wordCount > 0 ? Math.round((metrics.uniqueWordCount / metrics.wordCount) * 100) : 0}%)
+
+
+
+
Word Frequency Cloud
+ {metrics.topWords.length === 0 ? (
+
+ Add more words to see vocabulary statistics
+
+ ) : (
+
+ {metrics.topWords.map(({ word, count }) => {
+ // Normalize count relative to maximum frequency for font scaling
+ const scale = maxWordCount > 0 ? count / maxWordCount : 0.5;
+ const size = Math.round((0.8 + scale * 0.8) * 10) / 10; // 0.8rem to 1.6rem
+ const opacity = Math.round((0.5 + scale * 0.5) * 10) / 10; // 0.5 to 1.0
+
+ return (
+
+ {word}
+
+ ({count})
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
diff --git a/src/renderer/components/sidebar/Sidebar.tsx b/src/renderer/components/sidebar/Sidebar.tsx
index d29d248..d328eb9 100644
--- a/src/renderer/components/sidebar/Sidebar.tsx
+++ b/src/renderer/components/sidebar/Sidebar.tsx
@@ -5,6 +5,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { useFileStore } from '@/stores/file-store';
import { FileTree } from './FileTree';
import { Outline } from './Outline';
+import { Snippets } from './Snippets';
+import { Templates } from './Templates';
import { GitStatusPanel } from './GitStatusPanel';
export function Sidebar() {
@@ -16,20 +18,20 @@ export function Sidebar() {
}
return (
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
+ Files
+
+
+
{!tree ? (
No folder opened
@@ -40,55 +42,84 @@ export function Sidebar() {
) : (
)}
-
-
-
+
+
-
-
-
-
- Outline
-
-
-
-
+
+
+
+
+ Outline
+
+
+
-
-
-
+
+
-
-
-
-
- Git
-
-
-
-
+
+
+
+
+ Snippets
+
+
+
+
+
+
+
+
+
+
+
+ Templates
+
+
+
+
+
+
+
+
+
+
+
+ Git
+
+
+
-
-
-
+
+
+
{/* Hidden bridge: when `view.sidebarPanel` is dispatched, scroll the
matching section into view. The hidden elements expose a hook for
Playwright tests and the menu handler. */}
scrollToSection('Files')}>jump-explorer
+ scrollToSection('Outline')}>jump-outline
+ scrollToSection('Snippets')}>jump-snippets
+ scrollToSection('Templates')}>jump-templates
scrollToSection('Git')}>jump-git
- scrollToSection('Outline')}>jump-snippets
- scrollToSection('Files')}>jump-templates
-
+
);
}
diff --git a/src/renderer/components/sidebar/Snippets.tsx b/src/renderer/components/sidebar/Snippets.tsx
new file mode 100644
index 0000000..79902b6
--- /dev/null
+++ b/src/renderer/components/sidebar/Snippets.tsx
@@ -0,0 +1,241 @@
+import { useState, useEffect } from 'react';
+import { Plus, Trash2, Play, Search, FileText } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { toast } from 'sonner';
+import { insertSnippet } from '@/lib/editor-commands';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ DialogFooter,
+ DialogClose,
+} from '@/components/ui/dialog';
+
+interface Snippet {
+ id: string;
+ name: string;
+ language: string;
+ code: string;
+}
+
+export function Snippets() {
+ const [snippets, setSnippets] = useState([]);
+ const [search, setSearch] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ // Form states
+ const [name, setName] = useState('');
+ const [language, setLanguage] = useState('text');
+ const [code, setCode] = useState('');
+ const [open, setOpen] = useState(false);
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const list = await window.electronAPI.invoke('get-snippets');
+ setSnippets(list || []);
+ } catch (err: any) {
+ console.error(err);
+ toast.error('Failed to load snippets');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ const handleAdd = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim() || !code.trim()) {
+ toast.error('Name and Code are required');
+ return;
+ }
+ const snippet: Snippet = {
+ id: Date.now().toString(),
+ name: name.trim(),
+ language: language.trim() || 'text',
+ code,
+ };
+ try {
+ await window.electronAPI.invoke('save-snippet', snippet);
+ toast.success('Snippet added successfully');
+ setOpen(false);
+ setName('');
+ setLanguage('text');
+ setCode('');
+ load();
+ } catch (err: any) {
+ console.error(err);
+ toast.error('Failed to save snippet');
+ }
+ };
+
+ const handleDelete = async (id: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ try {
+ await window.electronAPI.invoke('delete-snippet', id);
+ toast.success('Snippet deleted');
+ load();
+ } catch (err: any) {
+ console.error(err);
+ toast.error('Failed to delete snippet');
+ }
+ };
+
+ const handleInsert = (text: string) => {
+ const success = insertSnippet(text);
+ if (success) {
+ toast.success('Snippet inserted');
+ } else {
+ toast.error('No active document open');
+ }
+ };
+
+ const filtered = snippets.filter(
+ (s) =>
+ s.name.toLowerCase().includes(search.toLowerCase()) ||
+ (s.language || '').toLowerCase().includes(search.toLowerCase())
+ );
+
+ return (
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-7 h-8 text-xs bg-background/50 border-border/60 focus-visible:ring-brand/50"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {loading && snippets.length === 0 ? (
+
Loading...
+ ) : filtered.length === 0 ? (
+
+
+ {search ? 'No snippets match search' : 'No snippets yet'}
+ {!search && (
+ setOpen(true)}
+ className="text-brand hover:text-brand-dark p-0 h-auto"
+ >
+ Add first snippet
+
+ )}
+
+ ) : (
+
+ {filtered.map((s) => (
+
handleInsert(s.code)}
+ className="group flex flex-col gap-1 rounded border border-border/40 bg-card/20 p-2 hover:bg-accent/40 cursor-pointer transition-colors"
+ >
+
+ {s.name}
+
+ {s.language}
+
+
+
+ {s.code}
+
+
+
handleDelete(s.id, e)}
+ title="Delete"
+ >
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/renderer/components/sidebar/Templates.tsx b/src/renderer/components/sidebar/Templates.tsx
new file mode 100644
index 0000000..5d3cf39
--- /dev/null
+++ b/src/renderer/components/sidebar/Templates.tsx
@@ -0,0 +1,60 @@
+import { FileText, Download } from 'lucide-react';
+import { toast } from 'sonner';
+import { insertSnippet } from '@/lib/editor-commands';
+
+const TEMPLATES = [
+ { name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
+ { name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
+ { name: 'Technical Spec', file: 'technical-spec.md', description: 'Requirements and architecture' },
+ { name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
+ { name: 'README', file: 'readme.md', description: 'Project documentation' },
+ { name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
+ { name: 'API Docs', file: 'api-docs.md', description: 'API endpoint documentation' },
+ { name: 'Tutorial', file: 'tutorial.md', description: 'Step-by-step guide' },
+ { name: 'Release Notes', file: 'release-notes.md', description: 'Version release summary' },
+ { name: 'Comparison', file: 'comparison.md', description: 'Feature comparison table' },
+];
+
+export function Templates() {
+ const handleSelect = async (file: string) => {
+ try {
+ const content = await window.electronAPI.invoke('load-template', file);
+ if (content) {
+ const success = insertSnippet(content);
+ if (success) {
+ toast.success(`Template "${file}" inserted successfully`);
+ } else {
+ toast.error('No active document open');
+ }
+ } else {
+ toast.error('Failed to load template content');
+ }
+ } catch (err: any) {
+ console.error(err);
+ toast.error('Error loading template');
+ }
+ };
+
+ return (
+
+
+ {TEMPLATES.map((t) => (
+
handleSelect(t.file)}
+ className="group flex items-start gap-2.5 rounded border border-border/40 bg-card/20 p-2 hover:bg-accent/40 cursor-pointer transition-colors"
+ >
+
+
+
+ {t.name}
+
+
+
{t.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/renderer/lib/commands/register-menu-commands.ts b/src/renderer/lib/commands/register-menu-commands.ts
index 23db0c7..a1be6cc 100644
--- a/src/renderer/lib/commands/register-menu-commands.ts
+++ b/src/renderer/lib/commands/register-menu-commands.ts
@@ -130,6 +130,9 @@ export function registerMenuCommands(): void {
const current = useAppStore.getState().zenMode;
useAppStore.getState().setZenMode(!current);
},
+ 'view.analytics': () => {
+ useAppStore.getState().openModal('writing-analytics');
+ },
'file.print': () => {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:print'));
},
@@ -296,6 +299,35 @@ export function registerMenuCommands(): void {
register('file.save', () => {
void useFileStore.getState().saveActiveBuffer();
});
+ register('file.saveAs', async (filePath?: string) => {
+ if (!filePath) return;
+ const { activeTabId } = useFileStore.getState();
+ if (!activeTabId) return;
+
+ const buffer = useEditorStore.getState().buffers.get(activeTabId);
+ if (!buffer) return;
+
+ const writeResult = await ipc.file.write(filePath, buffer.content);
+ if (!writeResult.ok) {
+ toast.error(`Failed to save: ${writeResult.error.message}`);
+ return;
+ }
+
+ useEditorStore.getState().renameBuffer(activeTabId, filePath, filePath);
+ useFileStore.setState((s) => {
+ const tab = s.openTabs.find((t) => t.id === activeTabId);
+ if (tab) {
+ tab.id = filePath;
+ tab.path = filePath;
+ tab.title = filePath.split('/').pop() ?? filePath;
+ tab.dirty = false;
+ }
+ s.activeTabId = filePath;
+ });
+
+ const title = filePath.split('/').pop() ?? filePath;
+ toast.success(`Saved ${title}`);
+ });
register('file.closeTab', () => {
const { activeTabId, closeTab } = useFileStore.getState();
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
@@ -337,6 +369,7 @@ export function useRegisterMenuCommands(): void {
export function useBridgeNativeMenu(): void {
useMenuAction('file-save', 'file.save');
useMenuAction('toggle-preview', 'view.togglePreview');
+ useMenuAction('show-analytics-dialog', 'view.analytics');
useMenuAction('toggle-sidebar-panel', 'view.sidebarPanel', (panel) => panel as string);
useMenuAction('toggle-bottom-panel', 'view.bottomPanel');
useMenuAction('toggle-find', 'find.toggle');
@@ -349,6 +382,7 @@ export function useBridgeNativeMenu(): void {
useMenuAction('print-preview', 'print.preview');
useMenuAction('print-preview-styled', 'print.previewStyled');
useMenuAction('file-opened', 'file.opened', (payload) => payload);
+ useMenuAction('get-content-for-save', 'file.saveAs', (filePath) => filePath as string);
useMenuAction('clear-recent-files', 'file.clearRecent');
useMenuAction('file-new', 'file.new');
useMenuAction('show-batch-converter', 'batch.showConverter', (type) => type as string);
diff --git a/src/renderer/lib/ipc.ts b/src/renderer/lib/ipc.ts
index cabc0eb..ebb16f7 100644
--- a/src/renderer/lib/ipc.ts
+++ b/src/renderer/lib/ipc.ts
@@ -82,6 +82,11 @@ export const ipc = {
safeCall('file', 'gitStatus', args),
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise> =>
safeCall('file', 'writeBuffer', args),
+ setCurrent: (path: string | null): void => {
+ if (typeof window !== 'undefined' && (window.electronAPI as any)?.file?.setCurrent) {
+ (window.electronAPI as any).file.setCurrent(path);
+ }
+ },
},
print: {
show: (args: { html: string }): Promise> =>
diff --git a/src/renderer/lib/validators.ts b/src/renderer/lib/validators.ts
index 2f3ad01..57c5f8a 100644
--- a/src/renderer/lib/validators.ts
+++ b/src/renderer/lib/validators.ts
@@ -25,6 +25,7 @@ export const settingsSchema = z.object({
updateChannel: z.enum(['github', 'concreteinfo']).default('github'),
autoCheckUpdates: z.boolean().default(true),
firstRun: z.boolean().default(true),
+ dailyGoal: z.number().int().min(1).default(1000),
});
export type Settings = z.infer;
diff --git a/src/renderer/lib/writing-analytics.ts b/src/renderer/lib/writing-analytics.ts
new file mode 100644
index 0000000..8620b84
--- /dev/null
+++ b/src/renderer/lib/writing-analytics.ts
@@ -0,0 +1,143 @@
+const STOP_WORDS = new Set([
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
+ 'should', 'may', 'might', 'shall', 'can', 'to', 'of', 'in', 'for',
+ 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
+ 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over',
+ 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
+ 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more',
+ 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own',
+ 'same', 'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and',
+ 'or', 'if', 'while', 'about', 'up', 'it', 'its', 'this', 'that',
+ 'these', 'those', 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he',
+ 'him', 'his', 'she', 'her', 'they', 'them', 'their', 'what', 'which',
+ 'who', 'whom', 'also'
+]);
+
+function countSyllables(word: string): number {
+ const normalized = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
+ const clean = normalized.replace(/^y/, '');
+ const matches = clean.match(/[aeiouy]{1,2}/gi);
+ return matches ? Math.max(1, matches.length) : 1;
+}
+
+function extractWords(text: string): string[] {
+ return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
+}
+
+function getReadabilityLabel(score: number): string {
+ if (score >= 90) return 'Very Easy';
+ if (score >= 70) return 'Easy';
+ if (score >= 50) return 'Standard';
+ if (score >= 30) return 'Difficult';
+ return 'Very Difficult';
+}
+
+export interface WritingMetrics {
+ wordCount: number;
+ sentenceCount: number;
+ paragraphCount: number;
+ fleschEase: number;
+ fleschGrade: number;
+ readabilityLabel: string;
+ readingTime: number;
+ speakingTime: number;
+ uniqueWordCount: number;
+ lexicalDiversity: number;
+ avgSentenceLength: number;
+ longestSentence: string;
+ longestSentenceLength: number;
+ topWords: Array<{ word: string; count: number }>;
+}
+
+export function analyzeText(text: string): WritingMetrics {
+ if (!text || !text.trim()) {
+ return {
+ wordCount: 0,
+ sentenceCount: 0,
+ paragraphCount: 0,
+ fleschEase: 0,
+ fleschGrade: 0,
+ readabilityLabel: 'N/A',
+ readingTime: 0,
+ speakingTime: 0,
+ uniqueWordCount: 0,
+ lexicalDiversity: 0,
+ avgSentenceLength: 0,
+ longestSentence: '',
+ longestSentenceLength: 0,
+ topWords: []
+ };
+ }
+
+ const words = extractWords(text);
+ const wordCount = words.length;
+
+ const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean);
+ const sentenceCount = Math.max(sentences.length, 1);
+
+ const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
+ const paragraphCount = Math.max(paragraphs.length, 1);
+
+ let totalSyllables = 0;
+ for (const w of words) {
+ totalSyllables += countSyllables(w);
+ }
+
+ const fleschEase = wordCount > 0 ? Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10 : 0;
+ const fleschGrade = wordCount > 0 ? Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10 : 0;
+ const readabilityLabel = getReadabilityLabel(fleschEase);
+
+ const readingTime = Math.ceil(wordCount / 200);
+ const speakingTime = Math.ceil(wordCount / 130);
+
+ const uniqueWords = new Set(words.map(w => w.toLowerCase()));
+ const uniqueWordCount = uniqueWords.size;
+ const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
+
+ const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
+
+ let longestSentence = '';
+ let longestSentenceLength = 0;
+ for (const s of sentences) {
+ const sWords = extractWords(s);
+ if (sWords.length > longestSentenceLength) {
+ longestSentenceLength = sWords.length;
+ longestSentence = s.trim();
+ }
+ }
+
+ if (longestSentence.length > 80) {
+ longestSentence = longestSentence.substring(0, 80) + '...';
+ }
+
+ const wordFreq: Record = {};
+ for (const w of words) {
+ const lower = w.toLowerCase();
+ if (!STOP_WORDS.has(lower) && lower.length > 1) {
+ wordFreq[lower] = (wordFreq[lower] || 0) + 1;
+ }
+ }
+
+ const topWords = Object.entries(wordFreq)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10)
+ .map(([word, count]) => ({ word, count }));
+
+ return {
+ wordCount,
+ sentenceCount,
+ paragraphCount,
+ fleschEase,
+ fleschGrade,
+ readabilityLabel,
+ readingTime,
+ speakingTime,
+ uniqueWordCount,
+ lexicalDiversity,
+ avgSentenceLength,
+ longestSentence,
+ longestSentenceLength,
+ topWords
+ };
+}
diff --git a/src/renderer/stores/app-store.ts b/src/renderer/stores/app-store.ts
index e2e74c5..d253f15 100644
--- a/src/renderer/stores/app-store.ts
+++ b/src/renderer/stores/app-store.ts
@@ -31,7 +31,8 @@ export type ModalState =
| { kind: 'ascii-generator' }
| { kind: 'table-generator' }
| { kind: 'find-in-files' }
- | { kind: 'crashReports' };
+ | { kind: 'crashReports' }
+ | { kind: 'writing-analytics' };
export type ModalKind = ModalState['kind'];
diff --git a/src/renderer/stores/editor-store.ts b/src/renderer/stores/editor-store.ts
index 92fb5e1..816e56a 100644
--- a/src/renderer/stores/editor-store.ts
+++ b/src/renderer/stores/editor-store.ts
@@ -21,6 +21,7 @@ interface EditorState {
setCursor: (id: string, line: number, column: number) => void;
closeBuffer: (id: string) => void;
setActive: (id: string) => void;
+ renameBuffer: (oldId: string, newId: string, newPath: string) => void;
}
export const useEditorStore = create()(
@@ -59,5 +60,14 @@ export const useEditorStore = create()(
set((s) => {
s.activeId = id;
}),
+ renameBuffer: (oldId, newId, newPath) =>
+ set((s) => {
+ const buf = s.buffers.get(oldId);
+ if (buf) {
+ s.buffers.delete(oldId);
+ s.buffers.set(newId, { ...buf, id: newId, path: newPath });
+ if (s.activeId === oldId) s.activeId = newId;
+ }
+ }),
}))
);
\ No newline at end of file
diff --git a/src/renderer/stores/file-store.ts b/src/renderer/stores/file-store.ts
index 1bdf3ab..47f4e3c 100644
--- a/src/renderer/stores/file-store.ts
+++ b/src/renderer/stores/file-store.ts
@@ -247,15 +247,48 @@ export const useFileStore = create()(
const buffer = useEditorStore.getState().buffers.get(activeTabId);
if (!buffer) return false;
- const writeResult = await ipc.file.write(activeTabId, buffer.content);
+ let savePath = activeTabId;
+ const isUnsaved = activeTabId.startsWith('untitled-');
+
+ if (isUnsaved) {
+ const dialogResult = await ipc.app.showSaveDialog({
+ title: 'Save Markdown File',
+ defaultPath: 'document.md',
+ filters: [
+ { name: 'Markdown', extensions: ['md', 'markdown'] },
+ { name: 'All Files', extensions: ['*'] }
+ ]
+ });
+ if (!dialogResult.ok || !dialogResult.data) {
+ return false;
+ }
+ savePath = dialogResult.data;
+ }
+
+ const writeResult = await ipc.file.write(savePath, buffer.content);
if (!writeResult.ok) {
toast.error(`Failed to save: ${writeResult.error.message}`);
return false;
}
- useEditorStore.getState().markSaved(activeTabId);
- useFileStore.getState().markTabClean(activeTabId);
- const title = useFileStore.getState().openTabs.find(t => t.id === activeTabId)?.title ?? activeTabId.split('/').pop() ?? activeTabId;
+ if (isUnsaved) {
+ useEditorStore.getState().renameBuffer(activeTabId, savePath, savePath);
+ set((s) => {
+ const tab = s.openTabs.find((t) => t.id === activeTabId);
+ if (tab) {
+ tab.id = savePath;
+ tab.path = savePath;
+ tab.title = savePath.split('/').pop() ?? savePath;
+ tab.dirty = false;
+ }
+ s.activeTabId = savePath;
+ });
+ } else {
+ useEditorStore.getState().markSaved(activeTabId);
+ useFileStore.getState().markTabClean(activeTabId);
+ }
+
+ const title = savePath.split('/').pop() ?? savePath;
toast.success(`Saved ${title}`);
return true;
},
@@ -269,3 +302,16 @@ export const useFileStore = create()(
}
)
);
+
+// Synchronize active tab with main process's currentFile tracking
+let lastActiveTabId: string | null = undefined;
+useFileStore.subscribe((state) => {
+ const activeTabId = state.activeTabId;
+ if (activeTabId !== lastActiveTabId) {
+ lastActiveTabId = activeTabId;
+ const isRealFile = activeTabId && !activeTabId.startsWith('untitled-');
+ if (ipc.file && ipc.file.setCurrent) {
+ ipc.file.setCurrent(isRealFile ? activeTabId : null);
+ }
+ }
+});
diff --git a/src/renderer/test/setup.ts b/src/renderer/test/setup.ts
index ed703ba..51af273 100644
--- a/src/renderer/test/setup.ts
+++ b/src/renderer/test/setup.ts
@@ -7,8 +7,11 @@ declare global {
}
}
-if (typeof window !== 'undefined' && !window.electronAPI) {
- window.electronAPI = {};
+if (typeof window !== 'undefined') {
+ window.electronAPI = window.electronAPI || {};
+ if (!window.electronAPI.invoke) {
+ window.electronAPI.invoke = async () => null;
+ }
}
// Mock matchMedia for next-themes (jsdom doesn't have it)
diff --git a/tests/component/modals/WritingAnalyticsDialog.test.tsx b/tests/component/modals/WritingAnalyticsDialog.test.tsx
new file mode 100644
index 0000000..9b8c141
--- /dev/null
+++ b/tests/component/modals/WritingAnalyticsDialog.test.tsx
@@ -0,0 +1,73 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { WritingAnalyticsDialog } from '@/components/modals/WritingAnalyticsDialog';
+import { useAppStore } from '@/stores/app-store';
+import { useEditorStore } from '@/stores/editor-store';
+import { useSettingsStore } from '@/stores/settings-store';
+
+describe('WritingAnalyticsDialog', () => {
+ beforeEach(() => {
+ // Reset stores
+ useAppStore.setState({ modal: { kind: 'writing-analytics' } } as any);
+ useSettingsStore.setState({ dailyGoal: 10 } as any);
+ useEditorStore.setState({
+ activeId: 'test-id',
+ buffers: new Map([
+ [
+ 'test-id',
+ {
+ id: 'test-id',
+ path: 'test.md',
+ content: 'The quick brown fox jumps over the lazy dog. A smart programmer writes code.',
+ dirty: false,
+ },
+ ],
+ ]),
+ } as any);
+ });
+
+ it('renders title and descriptions', () => {
+ render( );
+ expect(screen.getByText(/writing analytics/i)).toBeInTheDocument();
+ expect(screen.getByText(/real-time readability/i)).toBeInTheDocument();
+ });
+
+ it('computes metrics from editor content correctly', () => {
+ render( );
+ // Content has 14 words: "The quick brown fox jumps over the lazy dog. A smart programmer writes code."
+ // Sentences: 2
+ // Paragraphs: 1
+ expect(screen.getByText('14')).toBeInTheDocument(); // Words count
+ expect(screen.getByText('2')).toBeInTheDocument(); // Sentences count
+ expect(screen.getByText('1')).toBeInTheDocument(); // Paragraphs count
+ });
+
+ it('updates daily word goal when input changes', async () => {
+ render( );
+ const input = screen.getByRole('spinbutton') as HTMLInputElement;
+ expect(input.value).toBe('10');
+
+ // Change target goal to 500
+ fireEvent.change(input, { target: { value: '500' } });
+ expect(useSettingsStore.getState().dailyGoal).toBe(500);
+ });
+
+ it('displays word frequency cloud tags', () => {
+ render( );
+ // "quick", "brown", "fox", "jumps", "lazy", "dog", "smart", "programmer", "writes", "code"
+ // (should exclude stop words like "the", "a", "over")
+ expect(screen.getByText('quick')).toBeInTheDocument();
+ expect(screen.getByText('lazy')).toBeInTheDocument();
+ expect(screen.getByText('programmer')).toBeInTheDocument();
+ expect(screen.queryByText('over')).toBeNull(); // stop word
+ });
+
+ it('closes when close button is clicked', async () => {
+ render( );
+ const closeButtons = await screen.findAllByRole('button', { name: /close/i });
+ const close = closeButtons[0];
+ await userEvent.click(close);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/component/sidebar/Sidebar.test.tsx b/tests/component/sidebar/Sidebar.test.tsx
index f92e9bf..71e4493 100644
--- a/tests/component/sidebar/Sidebar.test.tsx
+++ b/tests/component/sidebar/Sidebar.test.tsx
@@ -24,7 +24,7 @@ describe('Sidebar', () => {
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
- it('renders without crashing with empty state in both sections', () => {
+ it('renders without crashing with empty state in all sections', () => {
render(
@@ -32,6 +32,9 @@ describe('Sidebar', () => {
);
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Outline')).toBeInTheDocument();
+ expect(screen.getByText('Snippets')).toBeInTheDocument();
+ expect(screen.getByText('Templates')).toBeInTheDocument();
+ expect(screen.getByText('Git')).toBeInTheDocument();
expect(screen.getByText(/no folder opened/i)).toBeInTheDocument();
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
});
@@ -56,6 +59,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Outline')).toBeInTheDocument();
+ expect(screen.getByText('Snippets')).toBeInTheDocument();
+ expect(screen.getByText('Templates')).toBeInTheDocument();
+ expect(screen.getByText('Git')).toBeInTheDocument();
expect(screen.getByText('README.md')).toBeInTheDocument();
});
diff --git a/tests/unit/stores/editor-store.test.ts b/tests/unit/stores/editor-store.test.ts
index 0a5e59a..bbc8f36 100644
--- a/tests/unit/stores/editor-store.test.ts
+++ b/tests/unit/stores/editor-store.test.ts
@@ -33,4 +33,15 @@ describe('useEditorStore', () => {
useEditorStore.getState().closeBuffer('file-1');
expect(useEditorStore.getState().buffers.has('file-1')).toBe(false);
});
+
+ it('renames a buffer', () => {
+ useEditorStore.getState().openBuffer('file-1', '/foo.md', 'some content');
+ useEditorStore.getState().renameBuffer('file-1', 'file-2', '/bar.md');
+ const oldBuf = useEditorStore.getState().buffers.get('file-1');
+ const newBuf = useEditorStore.getState().buffers.get('file-2');
+ expect(oldBuf).toBeUndefined();
+ expect(newBuf?.content).toBe('some content');
+ expect(newBuf?.path).toBe('/bar.md');
+ expect(useEditorStore.getState().activeId).toBe('file-2');
+ });
});
\ No newline at end of file