v0.3.5: Bug修复+性能优化+状态栏文档统计

Bug修复:
- BUG-01: Sidebar norm函数正则修复(单反斜杠匹配), Windows路径规范化恢复
- BUG-02: 3处result.content truthy检查改为content !== undefined, 空文件可打开
- BUG-03: TAB_SWITCHED路径校验顺序修复, activeFilePath不在校验前写入

优化:
- OPT-01: Sidebar handleFileClick添加loading状态, 打开文件有视觉反馈

新功能:
- FEAT-01: 状态栏添加行数/单词数/字符数统计
  - useDocStats hook + computeDocStats 纯函数
  - StatusBar右侧显示 行/词/字符 统计信息
  - 12个单元测试覆盖空值/空白/ASCII/UTF-8/Markdown等场景

验证: TS 0错误, ESLint 0错误(1个预存警告), 90/90测试通过
This commit is contained in:
thzxx
2026-06-04 13:43:10 +08:00
parent 899d2b7914
commit a84ccb7353
9 changed files with 157 additions and 13 deletions
+1 -1
View File
@@ -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": {
+5 -4
View File
@@ -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')
}
})
+1 -1
View File
@@ -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) }
}
+12 -6
View File
@@ -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
@@ -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() {
</span>
</div>
<div className="status-right">
{activeTab && (
<>
<span className="status-stat" title="行数" aria-label={`${stats.lines}`}>
{stats.lines}
</span>
<span className="status-divider">|</span>
<span className="status-stat" title="单词数" aria-label={`${stats.words} 个单词`}>
{stats.words}
</span>
<span className="status-divider">|</span>
<span className="status-stat" title="字符数(含空格)" aria-label={`${stats.chars} 个字符`}>
{stats.chars}
</span>
<span className="status-divider">|</span>
</>
)}
<span id="status-encoding">UTF-8</span>
<span className="status-divider">|</span>
<span id="status-lang">Markdown</span>
@@ -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)
})
})
+39
View File
@@ -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])
}
+1 -1
View File
@@ -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)
+4
View File
@@ -566,6 +566,10 @@ body {
color: var(--border);
}
.status-stat {
white-space: nowrap;
}
/* Drop Overlay */
#drop-overlay {
position: fixed;