mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
feat: add writing analytics dashboard and daily word goal tracking
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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') }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -246,6 +246,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
|
||||
// Updater
|
||||
'updater:status',
|
||||
'show-analytics-dialog',
|
||||
|
||||
// File dialog / directory listing
|
||||
'list-directory',
|
||||
|
||||
@@ -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 <FindInFilesDialog />;
|
||||
case 'crashReports':
|
||||
return <CrashReportModal onClose={useAppStore.getState().closeModal} />;
|
||||
case 'writing-analytics':
|
||||
return <WritingAnalyticsDialog />;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent className="max-w-3xl overflow-y-auto max-h-[90vh] p-6">
|
||||
<DialogHeader className="border-b border-border pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="h-6 w-6 text-brand" />
|
||||
<DialogTitle className="text-xl font-bold font-display">Writing Analytics</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Real-time readability metrics, vocabulary analysis, and progress towards your daily word goal.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left Column: Readability, Structure, Timing */}
|
||||
<div className="space-y-6">
|
||||
{/* Readability Card */}
|
||||
<div className="rounded-xl border border-border bg-card/30 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border/50 pb-2 mb-3">
|
||||
<BookOpen className="h-5 w-5 text-brand" />
|
||||
<h3 className="font-semibold text-sm">Readability</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Flesch Reading Ease</p>
|
||||
<p className={`text-2xl font-bold ${getReadabilityColor(metrics.fleschEase)}`}>
|
||||
{metrics.fleschEase}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{metrics.readabilityLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Grade Level</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{metrics.fleschGrade}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">US school grade</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Structure Card */}
|
||||
<div className="rounded-xl border border-border bg-card/30 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border/50 pb-2 mb-3">
|
||||
<FileText className="h-5 w-5 text-brand" />
|
||||
<h3 className="font-semibold text-sm">Structure</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center mb-4">
|
||||
<div className="p-2 bg-secondary/50 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Words</p>
|
||||
<p className="text-lg font-bold text-foreground">{metrics.wordCount}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-secondary/50 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Sentences</p>
|
||||
<p className="text-lg font-bold text-foreground">{metrics.sentenceCount}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-secondary/50 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Paragraphs</p>
|
||||
<p className="text-lg font-bold text-foreground">{metrics.paragraphCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Average Sentence Length:</span>
|
||||
<span className="font-semibold">{metrics.avgSentenceLength} words</span>
|
||||
</div>
|
||||
{metrics.longestSentence && (
|
||||
<div className="pt-2 border-t border-border/30">
|
||||
<p className="text-muted-foreground mb-1">
|
||||
Longest Sentence ({metrics.longestSentenceLength} words):
|
||||
</p>
|
||||
<p className="italic bg-secondary/35 p-2 rounded text-muted-foreground leading-relaxed">
|
||||
"{metrics.longestSentence}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timing Card */}
|
||||
<div className="rounded-xl border border-border bg-card/30 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border/50 pb-2 mb-3">
|
||||
<Clock className="h-5 w-5 text-brand" />
|
||||
<h3 className="font-semibold text-sm">Timing</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand/10 rounded-lg text-brand">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Reading Time</p>
|
||||
<p className="text-base font-semibold">{metrics.readingTime} min</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-[#1a7a56]/10 rounded-lg text-[#1a7a56]">
|
||||
<Mic className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Speaking Time</p>
|
||||
<p className="text-base font-semibold">{metrics.speakingTime} min</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Word Goal, Vocabulary */}
|
||||
<div className="space-y-6">
|
||||
{/* Word Goal Card */}
|
||||
<div
|
||||
className={`rounded-xl border p-4 shadow-sm transition-all duration-500 ${
|
||||
isGoalReached
|
||||
? 'border-[#1a7a56] bg-[#1a7a56]/5 animate-pulse-slow'
|
||||
: 'border-border bg-card/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isGoalReached ? (
|
||||
<Award className="h-5 w-5 text-[#1a7a56]" />
|
||||
) : (
|
||||
<Target className="h-5 w-5 text-brand" />
|
||||
)}
|
||||
<h3 className="font-semibold text-sm">Daily Word Goal</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Target:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={dailyGoal}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val > 0) {
|
||||
setSetting('dailyGoal', val);
|
||||
}
|
||||
}}
|
||||
className="h-7 w-20 text-xs px-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-xs font-semibold">
|
||||
<span className="text-muted-foreground">
|
||||
{metrics.wordCount} / {dailyGoal} words
|
||||
</span>
|
||||
<span className={isGoalReached ? 'text-[#1a7a56]' : 'text-brand'}>
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 rounded-full ${
|
||||
isGoalReached ? 'bg-[#1a7a56]' : 'bg-brand'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isGoalReached && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[#1a7a56]/10 p-2 text-xs text-[#1a7a56] font-medium border border-[#1a7a56]/20">
|
||||
<Sparkles className="h-4 w-4 animate-spin-slow" />
|
||||
<span>Goal accomplished! Keep writing to set a new record.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vocabulary & Word Cloud Card */}
|
||||
<div className="rounded-xl border border-border bg-card/30 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-3">
|
||||
<h3 className="font-semibold text-sm">Vocabulary & Top Words</h3>
|
||||
<div className="text-right text-xs">
|
||||
<span className="text-muted-foreground">Lexical Diversity: </span>
|
||||
<span className="font-bold text-foreground">{metrics.lexicalDiversity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground mb-4">
|
||||
Unique words: <span className="font-semibold text-foreground">{metrics.uniqueWordCount}</span> /{' '}
|
||||
{metrics.wordCount} ({metrics.wordCount > 0 ? Math.round((metrics.uniqueWordCount / metrics.wordCount) * 100) : 0}%)
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2">Word Frequency Cloud</p>
|
||||
{metrics.topWords.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-card/10 text-xs text-muted-foreground">
|
||||
Add more words to see vocabulary statistics
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-2 p-3 bg-secondary/20 rounded-lg min-h-32 border border-border/30">
|
||||
{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 (
|
||||
<span
|
||||
key={word}
|
||||
style={{
|
||||
fontSize: `${size}rem`,
|
||||
opacity: opacity,
|
||||
}}
|
||||
className="font-medium inline-block text-brand hover:text-brand-light transition-colors duration-200 cursor-default"
|
||||
title={`Occurred ${count} time${count === 1 ? '' : 's'}`}
|
||||
>
|
||||
{word}
|
||||
<sub className="text-[9px] text-muted-foreground font-normal ml-0.5">
|
||||
({count})
|
||||
</sub>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end border-t border-border pt-4">
|
||||
<Button onClick={closeModal}>Close</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-files"
|
||||
data-sidebar-section="Files"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Files
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||
<ScrollArea className="h-full pr-3">
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-files"
|
||||
data-sidebar-section="Files"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Files
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
{!tree ? (
|
||||
<div className="flex flex-col items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||
<span>No folder opened</span>
|
||||
@@ -40,55 +42,84 @@ export function Sidebar() {
|
||||
) : (
|
||||
<FileTree />
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-outline"
|
||||
data-sidebar-section="Outline"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Outline
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-outline"
|
||||
data-sidebar-section="Outline"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Outline
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<Outline />
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-git"
|
||||
data-sidebar-section="Git"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Git
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-snippets"
|
||||
data-sidebar-section="Snippets"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Snippets
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<Snippets />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-templates"
|
||||
data-sidebar-section="Templates"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Templates
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<Templates />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-git"
|
||||
data-sidebar-section="Git"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Git
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<GitStatusPanel />
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="sr-only" aria-hidden="true">
|
||||
<button data-testid="sidebar-jump-explorer" onClick={() => scrollToSection('Files')}>jump-explorer</button>
|
||||
<button data-testid="sidebar-jump-outline" onClick={() => scrollToSection('Outline')}>jump-outline</button>
|
||||
<button data-testid="sidebar-jump-snippets" onClick={() => scrollToSection('Snippets')}>jump-snippets</button>
|
||||
<button data-testid="sidebar-jump-templates" onClick={() => scrollToSection('Templates')}>jump-templates</button>
|
||||
<button data-testid="sidebar-jump-git" onClick={() => scrollToSection('Git')}>jump-git</button>
|
||||
<button data-testid="sidebar-jump-snippets" onClick={() => scrollToSection('Outline')}>jump-snippets</button>
|
||||
<button data-testid="sidebar-jump-templates" onClick={() => scrollToSection('Files')}>jump-templates</button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Snippet[]>([]);
|
||||
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 (
|
||||
<div className="flex flex-col gap-2 p-1 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search snippets..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-7 h-8 text-xs bg-background/50 border-border/60 focus-visible:ring-brand/50"
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 shrink-0 hover:bg-accent border-border/60 hover:text-brand"
|
||||
title="Add snippet"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleAdd}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Snippet</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Code Block"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="language">Language</Label>
|
||||
<Input
|
||||
id="language"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
placeholder="e.g. javascript, markdown"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="code">Code</Label>
|
||||
<Textarea
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="Paste code or text here..."
|
||||
className="min-h-[150px] font-mono text-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" className="bg-brand hover:bg-brand-dark text-white">
|
||||
Save Snippet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{loading && snippets.length === 0 ? (
|
||||
<div className="py-6 text-center text-muted-foreground">Loading...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-1.5 py-6 text-center text-muted-foreground border border-dashed border-border/40 rounded-lg bg-card/5">
|
||||
<FileText className="h-5 w-5 text-muted-foreground/60" />
|
||||
<span>{search ? 'No snippets match search' : 'No snippets yet'}</span>
|
||||
{!search && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-brand hover:text-brand-dark p-0 h-auto"
|
||||
>
|
||||
Add first snippet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[300px] overflow-y-auto pr-1">
|
||||
{filtered.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-foreground truncate">{s.name}</span>
|
||||
<span className="rounded bg-accent/60 px-1 py-0.5 font-mono text-[9px] text-muted-foreground">
|
||||
{s.language}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="m-0 select-none overflow-hidden rounded bg-accent/20 p-1 font-mono text-[10px] text-muted-foreground/80 max-h-[60px] line-clamp-3">
|
||||
<code>{s.code}</code>
|
||||
</pre>
|
||||
<div className="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity pt-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 hover:text-destructive hover:bg-transparent"
|
||||
onClick={(e) => handleDelete(s.id, e)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 hover:text-brand hover:bg-transparent"
|
||||
title="Insert"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-1.5 p-1 text-xs">
|
||||
<div className="space-y-1.5 max-h-[300px] overflow-y-auto pr-1">
|
||||
{TEMPLATES.map((t) => (
|
||||
<div
|
||||
key={t.file}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<FileText className="h-4 w-4 mt-0.5 text-muted-foreground/80 group-hover:text-brand shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-foreground truncate">{t.name}</span>
|
||||
<Download className="h-3 w-3 opacity-0 group-hover:opacity-100 text-muted-foreground/80 hover:text-brand transition-opacity" />
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground line-clamp-1 mt-0.5">{t.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -82,6 +82,11 @@ export const ipc = {
|
||||
safeCall('file', 'gitStatus', args),
|
||||
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||
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<IpcResult<void | ChannelMissing>> =>
|
||||
|
||||
@@ -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<typeof settingsSchema>;
|
||||
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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<EditorState>()(
|
||||
@@ -59,5 +60,14 @@ export const useEditorStore = create<EditorState>()(
|
||||
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;
|
||||
}
|
||||
}),
|
||||
}))
|
||||
);
|
||||
@@ -247,15 +247,48 @@ export const useFileStore = create<FileState>()(
|
||||
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<FileState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(<WritingAnalyticsDialog />);
|
||||
expect(screen.getByText(/writing analytics/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/real-time readability/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('computes metrics from editor content correctly', () => {
|
||||
render(<WritingAnalyticsDialog />);
|
||||
// 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(<WritingAnalyticsDialog />);
|
||||
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(<WritingAnalyticsDialog />);
|
||||
// "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(<WritingAnalyticsDialog />);
|
||||
const closeButtons = await screen.findAllByRole('button', { name: /close/i });
|
||||
const close = closeButtons[0];
|
||||
await userEvent.click(close);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<Sidebar />
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user