mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +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',
|
accelerator: 'CmdOrCtrl+Shift+V',
|
||||||
click: () => mainWindow.webContents.send('toggle-preview')
|
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
|
// NOTE: Command Palette removed — handled by useCommandStore
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Sidebar',
|
label: 'Sidebar',
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: 'Files', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
|
{ 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') }
|
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
|
|
||||||
// Updater
|
// Updater
|
||||||
'updater:status',
|
'updater:status',
|
||||||
|
'show-analytics-dialog',
|
||||||
|
|
||||||
// File dialog / directory listing
|
// File dialog / directory listing
|
||||||
'list-directory',
|
'list-directory',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SettingsSheet } from './SettingsSheet';
|
|||||||
import { TableGeneratorDialog } from './TableGeneratorDialog';
|
import { TableGeneratorDialog } from './TableGeneratorDialog';
|
||||||
import { WelcomeDialog } from './WelcomeDialog';
|
import { WelcomeDialog } from './WelcomeDialog';
|
||||||
import { WordExportDialog } from './WordExportDialog';
|
import { WordExportDialog } from './WordExportDialog';
|
||||||
|
import { WritingAnalyticsDialog } from './WritingAnalyticsDialog';
|
||||||
|
|
||||||
export function ModalLayer() {
|
export function ModalLayer() {
|
||||||
const modal = useAppStore((s) => s.modal);
|
const modal = useAppStore((s) => s.modal);
|
||||||
@@ -44,5 +45,7 @@ export function ModalLayer() {
|
|||||||
return <FindInFilesDialog />;
|
return <FindInFilesDialog />;
|
||||||
case 'crashReports':
|
case 'crashReports':
|
||||||
return <CrashReportModal onClose={useAppStore.getState().closeModal} />;
|
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 { useFileStore } from '@/stores/file-store';
|
||||||
import { FileTree } from './FileTree';
|
import { FileTree } from './FileTree';
|
||||||
import { Outline } from './Outline';
|
import { Outline } from './Outline';
|
||||||
|
import { Snippets } from './Snippets';
|
||||||
|
import { Templates } from './Templates';
|
||||||
import { GitStatusPanel } from './GitStatusPanel';
|
import { GitStatusPanel } from './GitStatusPanel';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@@ -16,20 +18,20 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3">
|
<ScrollArea className="h-full pr-3">
|
||||||
<Collapsible defaultOpen>
|
<div className="flex flex-col gap-4 pb-4">
|
||||||
<CollapsibleTrigger asChild>
|
<Collapsible defaultOpen>
|
||||||
<button
|
<CollapsibleTrigger asChild>
|
||||||
data-testid="sidebar-section-files"
|
<button
|
||||||
data-sidebar-section="Files"
|
data-testid="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"
|
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
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
</button>
|
Files
|
||||||
</CollapsibleTrigger>
|
</button>
|
||||||
<CollapsibleContent>
|
</CollapsibleTrigger>
|
||||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
<CollapsibleContent className="mt-1">
|
||||||
{!tree ? (
|
{!tree ? (
|
||||||
<div className="flex flex-col items-center gap-2 p-4 text-xs text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||||
<span>No folder opened</span>
|
<span>No folder opened</span>
|
||||||
@@ -40,55 +42,84 @@ export function Sidebar() {
|
|||||||
) : (
|
) : (
|
||||||
<FileTree />
|
<FileTree />
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</CollapsibleContent>
|
||||||
</CollapsibleContent>
|
</Collapsible>
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<Collapsible defaultOpen>
|
<Collapsible defaultOpen>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
data-testid="sidebar-section-outline"
|
data-testid="sidebar-section-outline"
|
||||||
data-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"
|
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" />
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
Outline
|
Outline
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent className="mt-1">
|
||||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
|
||||||
<Outline />
|
<Outline />
|
||||||
</ScrollArea>
|
</CollapsibleContent>
|
||||||
</CollapsibleContent>
|
</Collapsible>
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<Collapsible defaultOpen>
|
<Collapsible defaultOpen>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
data-testid="sidebar-section-git"
|
data-testid="sidebar-section-snippets"
|
||||||
data-sidebar-section="Git"
|
data-sidebar-section="Snippets"
|
||||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded"
|
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" />
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
Git
|
Snippets
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent className="mt-1">
|
||||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
<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 />
|
<GitStatusPanel />
|
||||||
</ScrollArea>
|
</CollapsibleContent>
|
||||||
</CollapsibleContent>
|
</Collapsible>
|
||||||
</Collapsible>
|
</div>
|
||||||
|
|
||||||
{/* Hidden bridge: when `view.sidebarPanel` is dispatched, scroll the
|
{/* Hidden bridge: when `view.sidebarPanel` is dispatched, scroll the
|
||||||
matching section into view. The hidden elements expose a hook for
|
matching section into view. The hidden elements expose a hook for
|
||||||
Playwright tests and the menu handler. */}
|
Playwright tests and the menu handler. */}
|
||||||
<div className="sr-only" aria-hidden="true">
|
<div className="sr-only" aria-hidden="true">
|
||||||
<button data-testid="sidebar-jump-explorer" onClick={() => scrollToSection('Files')}>jump-explorer</button>
|
<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-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>
|
||||||
</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;
|
const current = useAppStore.getState().zenMode;
|
||||||
useAppStore.getState().setZenMode(!current);
|
useAppStore.getState().setZenMode(!current);
|
||||||
},
|
},
|
||||||
|
'view.analytics': () => {
|
||||||
|
useAppStore.getState().openModal('writing-analytics');
|
||||||
|
},
|
||||||
'file.print': () => {
|
'file.print': () => {
|
||||||
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:print'));
|
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:print'));
|
||||||
},
|
},
|
||||||
@@ -296,6 +299,35 @@ export function registerMenuCommands(): void {
|
|||||||
register('file.save', () => {
|
register('file.save', () => {
|
||||||
void useFileStore.getState().saveActiveBuffer();
|
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', () => {
|
register('file.closeTab', () => {
|
||||||
const { activeTabId, closeTab } = useFileStore.getState();
|
const { activeTabId, closeTab } = useFileStore.getState();
|
||||||
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
|
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
|
||||||
@@ -337,6 +369,7 @@ export function useRegisterMenuCommands(): void {
|
|||||||
export function useBridgeNativeMenu(): void {
|
export function useBridgeNativeMenu(): void {
|
||||||
useMenuAction('file-save', 'file.save');
|
useMenuAction('file-save', 'file.save');
|
||||||
useMenuAction('toggle-preview', 'view.togglePreview');
|
useMenuAction('toggle-preview', 'view.togglePreview');
|
||||||
|
useMenuAction('show-analytics-dialog', 'view.analytics');
|
||||||
useMenuAction('toggle-sidebar-panel', 'view.sidebarPanel', (panel) => panel as string);
|
useMenuAction('toggle-sidebar-panel', 'view.sidebarPanel', (panel) => panel as string);
|
||||||
useMenuAction('toggle-bottom-panel', 'view.bottomPanel');
|
useMenuAction('toggle-bottom-panel', 'view.bottomPanel');
|
||||||
useMenuAction('toggle-find', 'find.toggle');
|
useMenuAction('toggle-find', 'find.toggle');
|
||||||
@@ -349,6 +382,7 @@ export function useBridgeNativeMenu(): void {
|
|||||||
useMenuAction('print-preview', 'print.preview');
|
useMenuAction('print-preview', 'print.preview');
|
||||||
useMenuAction('print-preview-styled', 'print.previewStyled');
|
useMenuAction('print-preview-styled', 'print.previewStyled');
|
||||||
useMenuAction('file-opened', 'file.opened', (payload) => payload);
|
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('clear-recent-files', 'file.clearRecent');
|
||||||
useMenuAction('file-new', 'file.new');
|
useMenuAction('file-new', 'file.new');
|
||||||
useMenuAction('show-batch-converter', 'batch.showConverter', (type) => type as string);
|
useMenuAction('show-batch-converter', 'batch.showConverter', (type) => type as string);
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ export const ipc = {
|
|||||||
safeCall('file', 'gitStatus', args),
|
safeCall('file', 'gitStatus', args),
|
||||||
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
|
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
safeCall('file', 'writeBuffer', args),
|
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: {
|
print: {
|
||||||
show: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
|
show: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const settingsSchema = z.object({
|
|||||||
updateChannel: z.enum(['github', 'concreteinfo']).default('github'),
|
updateChannel: z.enum(['github', 'concreteinfo']).default('github'),
|
||||||
autoCheckUpdates: z.boolean().default(true),
|
autoCheckUpdates: z.boolean().default(true),
|
||||||
firstRun: z.boolean().default(true),
|
firstRun: z.boolean().default(true),
|
||||||
|
dailyGoal: z.number().int().min(1).default(1000),
|
||||||
});
|
});
|
||||||
export type Settings = z.infer<typeof settingsSchema>;
|
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: 'ascii-generator' }
|
||||||
| { kind: 'table-generator' }
|
| { kind: 'table-generator' }
|
||||||
| { kind: 'find-in-files' }
|
| { kind: 'find-in-files' }
|
||||||
| { kind: 'crashReports' };
|
| { kind: 'crashReports' }
|
||||||
|
| { kind: 'writing-analytics' };
|
||||||
|
|
||||||
export type ModalKind = ModalState['kind'];
|
export type ModalKind = ModalState['kind'];
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface EditorState {
|
|||||||
setCursor: (id: string, line: number, column: number) => void;
|
setCursor: (id: string, line: number, column: number) => void;
|
||||||
closeBuffer: (id: string) => void;
|
closeBuffer: (id: string) => void;
|
||||||
setActive: (id: string) => void;
|
setActive: (id: string) => void;
|
||||||
|
renameBuffer: (oldId: string, newId: string, newPath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorStore = create<EditorState>()(
|
export const useEditorStore = create<EditorState>()(
|
||||||
@@ -59,5 +60,14 @@ export const useEditorStore = create<EditorState>()(
|
|||||||
set((s) => {
|
set((s) => {
|
||||||
s.activeId = id;
|
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);
|
const buffer = useEditorStore.getState().buffers.get(activeTabId);
|
||||||
if (!buffer) return false;
|
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) {
|
if (!writeResult.ok) {
|
||||||
toast.error(`Failed to save: ${writeResult.error.message}`);
|
toast.error(`Failed to save: ${writeResult.error.message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
useEditorStore.getState().markSaved(activeTabId);
|
if (isUnsaved) {
|
||||||
useFileStore.getState().markTabClean(activeTabId);
|
useEditorStore.getState().renameBuffer(activeTabId, savePath, savePath);
|
||||||
const title = useFileStore.getState().openTabs.find(t => t.id === activeTabId)?.title ?? activeTabId.split('/').pop() ?? activeTabId;
|
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}`);
|
toast.success(`Saved ${title}`);
|
||||||
return true;
|
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) {
|
if (typeof window !== 'undefined') {
|
||||||
window.electronAPI = {};
|
window.electronAPI = window.electronAPI || {};
|
||||||
|
if (!window.electronAPI.invoke) {
|
||||||
|
window.electronAPI.invoke = async () => null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock matchMedia for next-themes (jsdom doesn't have it)
|
// 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 });
|
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(
|
render(
|
||||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -32,6 +32,9 @@ describe('Sidebar', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText('Files')).toBeInTheDocument();
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Outline')).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 folder opened/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -56,6 +59,9 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Files')).toBeInTheDocument();
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Outline')).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();
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,4 +33,15 @@ describe('useEditorStore', () => {
|
|||||||
useEditorStore.getState().closeBuffer('file-1');
|
useEditorStore.getState().closeBuffer('file-1');
|
||||||
expect(useEditorStore.getState().buffers.has('file-1')).toBe(false);
|
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