diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts index cfdbf1c..8fb1464 100644 --- a/src/main/fileSystem.ts +++ b/src/main/fileSystem.ts @@ -69,3 +69,33 @@ export async function writeMarkdownFile(filePath: string, content: string): Prom assertInDraftRoot(filePath) await writeFile(filePath, content, 'utf-8') } + +function countWords(text: string): number { + return text.trim() === '' ? 0 : text.trim().split(/\s+/).length +} + +async function collectMarkdownPaths(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths: string[] = [] + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + const nested = await collectMarkdownPaths(fullPath) + paths.push(...nested) + } else if (entry.name.endsWith('.md')) { + paths.push(fullPath) + } + } + return paths +} + +export async function getProjectWordCount(): Promise { + const paths = await collectMarkdownPaths(DRAFT_ROOT) + let total = 0 + for (const p of paths) { + const content = await readFile(p, 'utf-8') + total += countWords(content) + } + return total +} diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index fbd326a..e104e84 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import { listDraftFiles, readMarkdownFile, writeMarkdownFile } from './fileSystem' +import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount } from './fileSystem' import { streamMessage } from './aiService' import type { AIPayload } from '../renderer/types/editor' @@ -16,6 +16,10 @@ export function registerIpcHandlers(): void { await writeMarkdownFile(filePath, content) }) + ipcMain.handle('fs:projectWordCount', async () => { + return await getProjectWordCount() + }) + ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { try { await streamMessage(payload, (chunk: string) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9bbc466..81faac6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -43,5 +43,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.removeAllListeners('ai:chunk') ipcRenderer.removeAllListeners('ai:done') ipcRenderer.removeAllListeners('ai:error') - } + }, + + getProjectWordCount: (): Promise => ipcRenderer.invoke('fs:projectWordCount') }) diff --git a/src/renderer/components/Toolbar/AnalysisToolbar.tsx b/src/renderer/components/Toolbar/AnalysisToolbar.tsx index 7be6327..b5e83a0 100644 --- a/src/renderer/components/Toolbar/AnalysisToolbar.tsx +++ b/src/renderer/components/Toolbar/AnalysisToolbar.tsx @@ -1,8 +1,18 @@ +import { useEffect } from 'react' import { useEditorStore } from '../../store/editorStore' import { detectPassiveVoice } from '../../utils/passiveVoice' import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser' import './Toolbar.css' +function countWords(text: string): number { + return text.trim() === '' ? 0 : text.trim().split(/\s+/).length +} + +function formatWordCount(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + export function AnalysisToolbar(): JSX.Element { const { activeFilePath, @@ -19,9 +29,22 @@ export function AnalysisToolbar(): JSX.Element { appendToLastAssistantMessage, setAILoading, setAIError, - chatHistory + chatHistory, + projectWordCount, + setProjectWordCount } = useEditorStore() + useEffect(() => { + window.api.getProjectWordCount().then(setProjectWordCount).catch(() => {}) + }, []) + + // Refresh project count after a save (isDirty transitions from true โ†’ false) + useEffect(() => { + if (!isDirty) { + window.api.getProjectWordCount().then(setProjectWordCount).catch(() => {}) + } + }, [isDirty]) + const hasFile = Boolean(activeFilePath) const runPassiveVoice = (): void => { @@ -78,6 +101,7 @@ export function AnalysisToolbar(): JSX.Element { const passiveCount = annotations.filter((a) => a.type === 'passive_voice').length const otherCount = annotations.filter((a) => a.type !== 'passive_voice').length + const docWordCount = countWords(activeFileContent) return (
@@ -130,6 +154,16 @@ export function AnalysisToolbar(): JSX.Element {
+ {activeFilePath && ( + + {formatWordCount(docWordCount)} + / + {formatWordCount(projectWordCount)} + + )} {isDirty && ( โ— diff --git a/src/renderer/components/Toolbar/Toolbar.css b/src/renderer/components/Toolbar/Toolbar.css index 1877607..03ce930 100644 --- a/src/renderer/components/Toolbar/Toolbar.css +++ b/src/renderer/components/Toolbar/Toolbar.css @@ -95,3 +95,17 @@ white-space: nowrap; max-width: 260px; } + +.toolbar-wordcount { + color: var(--text-muted); + font-size: 11px; + font-family: var(--font-sans); + white-space: nowrap; + flex-shrink: 0; + cursor: default; +} + +.toolbar-wordcount-sep { + margin: 0 3px; + opacity: 0.5; +} diff --git a/src/renderer/store/editorStore.ts b/src/renderer/store/editorStore.ts index bb5c067..6b05811 100644 --- a/src/renderer/store/editorStore.ts +++ b/src/renderer/store/editorStore.ts @@ -34,6 +34,10 @@ interface EditorState { // Analysis mode analysisMode: AnalysisMode setAnalysisMode: (mode: AnalysisMode) => void + + // Word counts + projectWordCount: number + setProjectWordCount: (count: number) => void } export const useEditorStore = create((set, get) => ({ @@ -115,5 +119,8 @@ export const useEditorStore = create((set, get) => ({ clearAnnotations: () => set({ annotations: [], analysisMode: 'none' }), analysisMode: 'none', - setAnalysisMode: (analysisMode) => set({ analysisMode }) + setAnalysisMode: (analysisMode) => set({ analysisMode }), + + projectWordCount: 0, + setProjectWordCount: (projectWordCount) => set({ projectWordCount }) })) diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 9ca7abf..956f470 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -11,6 +11,7 @@ declare global { onChunk: (chunk: string) => void ) => Promise removeAIListener: () => void + getProjectWordCount: () => Promise } } }