diff --git a/package.json b/package.json
index 06e73a7..c8abcf8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "marklite",
- "version": "0.3.4",
+ "version": "0.3.5",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts
index 21b52bd..5d1cfa6 100644
--- a/src/main/ipc-handlers.ts
+++ b/src/main/ipc-handlers.ts
@@ -197,15 +197,16 @@ export function registerIpcHandlers(
// 标签切换
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
- state.activeFilePath = filePath || null
- if (filePath && !validatePath(filePath)) {
+ const normalizedPath = filePath || null
+ if (normalizedPath && !validatePath(normalizedPath)) {
fileWatcher.stop()
return
}
- fileWatcher.start(filePath || '')
+ state.activeFilePath = normalizedPath
+ fileWatcher.start(normalizedPath || '')
const win = getMainWindow()
if (win && !win.isDestroyed()) {
- win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite')
+ win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite')
}
})
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 407a63a..74ccc84 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -74,7 +74,7 @@ export function App() {
const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return
const result = await window.electronAPI.readFile(externallyModified.filePath)
- if (result.success && result.content) {
+ if (result.success && result.content !== undefined) {
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
}
diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx
index 6993401..3144867 100644
--- a/src/renderer/components/Sidebar/Sidebar.tsx
+++ b/src/renderer/components/Sidebar/Sidebar.tsx
@@ -9,7 +9,7 @@ import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useFolderOperations } from '../../hooks/useFolderOperations'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
-import { getEditorView } from '../../stores/editorStore'
+import { getEditorView, useEditorStore } from '../../stores/editorStore'
import { TextSelection } from '@milkdown/prose/state'
const norm = (p: string) => p.replace(/\\/g, '/')
@@ -29,6 +29,7 @@ export const Sidebar = React.memo(function Sidebar() {
const activeFilePath = activeTab?.filePath ?? null
const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations()
+ const setLoading = useEditorStore(s => s.setLoading)
useAutoExpandDir(activeFilePath)
// Parse headings from active tab content
@@ -56,12 +57,17 @@ export const Sidebar = React.memo(function Sidebar() {
const existing = tabs.find(t => t.filePath === path)
if (existing) { switchToTab(existing.id); return }
if (!window.electronAPI) return
- const result = await window.electronAPI.readFile(path)
- if (result.success && result.content) {
- createTab(path, result.content)
- recentFilesRepository.add(path)
+ setLoading('file-open', true)
+ try {
+ const result = await window.electronAPI.readFile(path)
+ if (result.success && result.content !== undefined) {
+ createTab(path, result.content)
+ recentFilesRepository.add(path)
+ }
+ } finally {
+ setLoading('file-open', false)
}
- }, [tabs, switchToTab, createTab])
+ }, [tabs, switchToTab, createTab, setLoading])
const independentFiles = tabs.filter(t => {
if (!t.filePath) return false
diff --git a/src/renderer/components/StatusBar/StatusBar.tsx b/src/renderer/components/StatusBar/StatusBar.tsx
index ee2bd4c..a6899fa 100644
--- a/src/renderer/components/StatusBar/StatusBar.tsx
+++ b/src/renderer/components/StatusBar/StatusBar.tsx
@@ -1,12 +1,14 @@
import React from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
+import { useDocStats } from '../../hooks/useDocStats'
import { getFileName } from '../../lib/fileUtils'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
export const StatusBar = React.memo(function StatusBar() {
const activeTab = useTabStore(s => s.getActiveTab())
const loadingStates = useEditorStore(s => s.loadingStates)
+ const stats = useDocStats(activeTab?.content)
// 判断是否有任何加载状态激活
const hasLoading = Object.values(loadingStates).some(Boolean)
@@ -32,6 +34,22 @@ export const StatusBar = React.memo(function StatusBar() {
+ {activeTab && (
+ <>
+
+ {stats.lines} 行
+
+ |
+
+ {stats.words} 词
+
+ |
+
+ {stats.chars} 字符
+
+ |
+ >
+ )}
UTF-8
|
Markdown
diff --git a/src/renderer/hooks/__tests__/useDocStats.test.ts b/src/renderer/hooks/__tests__/useDocStats.test.ts
new file mode 100644
index 0000000..20cd170
--- /dev/null
+++ b/src/renderer/hooks/__tests__/useDocStats.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect } from 'vitest'
+import { computeDocStats } from '../useDocStats'
+
+describe('computeDocStats', () => {
+ it('should return zeros for undefined content', () => {
+ expect(computeDocStats(undefined)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
+ })
+
+ it('should return zeros for null content', () => {
+ expect(computeDocStats(null)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
+ })
+
+ it('should return zeros for empty string', () => {
+ expect(computeDocStats('')).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
+ })
+
+ it('should count single word', () => {
+ const result = computeDocStats('hello')
+ expect(result.words).toBe(1)
+ expect(result.chars).toBe(5)
+ expect(result.charsNoSpace).toBe(5)
+ expect(result.lines).toBe(1)
+ })
+
+ it('should count multiple words', () => {
+ const result = computeDocStats('hello world')
+ expect(result.words).toBe(2)
+ expect(result.chars).toBe(11) // 'hello world' = 11 chars
+ expect(result.charsNoSpace).toBe(10) // without the space
+ })
+
+ it('should count characters without spaces', () => {
+ const result = computeDocStats('a b c')
+ expect(result.chars).toBe(5)
+ expect(result.charsNoSpace).toBe(3)
+ })
+
+ it('should count lines', () => {
+ const result = computeDocStats('line1\nline2\nline3')
+ expect(result.lines).toBe(3)
+ expect(result.words).toBe(3)
+ })
+
+ it('should handle Windows line endings', () => {
+ const result = computeDocStats('line1\r\nline2\r\nline3')
+ expect(result.lines).toBe(3)
+ })
+
+ it('should handle trailing newline', () => {
+ const result = computeDocStats('hello\n')
+ expect(result.lines).toBe(2)
+ expect(result.words).toBe(1)
+ })
+
+ it('should count markdown content correctly', () => {
+ const md = '# Title\n\nThis is a **paragraph** with some *text*.\n\n- List item 1\n- List item 2\n'
+ const result = computeDocStats(md)
+ expect(result.words).toBe(17)
+ expect(result.lines).toBe(7)
+ expect(result.chars).toBe(md.length)
+ })
+
+ it('should handle whitespace-only content', () => {
+ const result = computeDocStats(' \n \n ')
+ expect(result.words).toBe(0)
+ expect(result.chars).toBe(9)
+ expect(result.charsNoSpace).toBe(0)
+ expect(result.lines).toBe(3)
+ })
+
+ it('should handle content with non-ASCII characters', () => {
+ const result = computeDocStats('中文测试 日本語 한국어')
+ expect(result.words).toBe(3)
+ expect(result.chars).toBe(12)
+ })
+})
diff --git a/src/renderer/hooks/useDocStats.ts b/src/renderer/hooks/useDocStats.ts
new file mode 100644
index 0000000..7bf1a8c
--- /dev/null
+++ b/src/renderer/hooks/useDocStats.ts
@@ -0,0 +1,39 @@
+import { useMemo } from 'react'
+
+export interface DocStats {
+ /** 单词数(按空白字符分割) */
+ words: number
+ /** 总字符数(含空白字符) */
+ chars: number
+ /** 字符数(不含空白字符) */
+ charsNoSpace: number
+ /** 行数 */
+ lines: number
+}
+
+/**
+ * 计算文档统计信息:单词数、字符数(含/不含空格)、行数。
+ * 对空文档或 undefined 返回全零值。
+ */
+export function computeDocStats(content: string | undefined | null): DocStats {
+ if (!content) {
+ return { words: 0, chars: 0, charsNoSpace: 0, lines: 0 }
+ }
+
+ const chars = content.length
+ const charsNoSpace = content.replace(/\s/g, '').length
+ const words = content.trim()
+ ? content.trim().split(/\s+/).length
+ : 0
+ const lines = content === '' ? 0 : content.split(/\r?\n/).length
+
+ return { words, chars, charsNoSpace, lines }
+}
+
+/**
+ * Hook:根据文档内容实时计算统计信息。
+ * 使用 useMemo 避免不必要的重新计算。
+ */
+export function useDocStats(content: string | undefined | null): DocStats {
+ return useMemo(() => computeDocStats(content), [content])
+}
diff --git a/src/renderer/hooks/useFileOperations.ts b/src/renderer/hooks/useFileOperations.ts
index 9087329..c1daa27 100644
--- a/src/renderer/hooks/useFileOperations.ts
+++ b/src/renderer/hooks/useFileOperations.ts
@@ -70,7 +70,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
setLoading('file-open', true)
try {
const result = await window.electronAPI.readFile(filePath)
- if (result.success) {
+ if (result.success && result.content !== undefined) {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css
index 81c4782..f2eeb54 100644
--- a/src/renderer/styles/global.css
+++ b/src/renderer/styles/global.css
@@ -566,6 +566,10 @@ body {
color: var(--border);
}
+.status-stat {
+ white-space: nowrap;
+}
+
/* Drop Overlay */
#drop-overlay {
position: fixed;