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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "0.3.4",
|
"version": "0.3.5",
|
||||||
"description": "Lightweight Markdown Editor for Windows",
|
"description": "Lightweight Markdown Editor for Windows",
|
||||||
"main": "./dist/main/index.js",
|
"main": "./dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -197,15 +197,16 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
// 标签切换
|
// 标签切换
|
||||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||||
state.activeFilePath = filePath || null
|
const normalizedPath = filePath || null
|
||||||
if (filePath && !validatePath(filePath)) {
|
if (normalizedPath && !validatePath(normalizedPath)) {
|
||||||
fileWatcher.stop()
|
fileWatcher.stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fileWatcher.start(filePath || '')
|
state.activeFilePath = normalizedPath
|
||||||
|
fileWatcher.start(normalizedPath || '')
|
||||||
const win = getMainWindow()
|
const win = getMainWindow()
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite')
|
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function App() {
|
|||||||
const handleReloadModified = useCallback(async () => {
|
const handleReloadModified = useCallback(async () => {
|
||||||
if (!externallyModified?.filePath || !window.electronAPI) return
|
if (!externallyModified?.filePath || !window.electronAPI) return
|
||||||
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
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)
|
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||||
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
|
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useSidebarResize } from '../../hooks/useSidebarResize'
|
|||||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||||
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
||||||
import { getEditorView } from '../../stores/editorStore'
|
import { getEditorView, useEditorStore } from '../../stores/editorStore'
|
||||||
import { TextSelection } from '@milkdown/prose/state'
|
import { TextSelection } from '@milkdown/prose/state'
|
||||||
|
|
||||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||||
@@ -29,6 +29,7 @@ export const Sidebar = React.memo(function Sidebar() {
|
|||||||
const activeFilePath = activeTab?.filePath ?? null
|
const activeFilePath = activeTab?.filePath ?? null
|
||||||
const { sidebarRef, startResize } = useSidebarResize()
|
const { sidebarRef, startResize } = useSidebarResize()
|
||||||
const { handleOpenFolder } = useFolderOperations()
|
const { handleOpenFolder } = useFolderOperations()
|
||||||
|
const setLoading = useEditorStore(s => s.setLoading)
|
||||||
useAutoExpandDir(activeFilePath)
|
useAutoExpandDir(activeFilePath)
|
||||||
|
|
||||||
// Parse headings from active tab content
|
// 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)
|
const existing = tabs.find(t => t.filePath === path)
|
||||||
if (existing) { switchToTab(existing.id); return }
|
if (existing) { switchToTab(existing.id); return }
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
|
setLoading('file-open', true)
|
||||||
|
try {
|
||||||
const result = await window.electronAPI.readFile(path)
|
const result = await window.electronAPI.readFile(path)
|
||||||
if (result.success && result.content) {
|
if (result.success && result.content !== undefined) {
|
||||||
createTab(path, result.content)
|
createTab(path, result.content)
|
||||||
recentFilesRepository.add(path)
|
recentFilesRepository.add(path)
|
||||||
}
|
}
|
||||||
}, [tabs, switchToTab, createTab])
|
} finally {
|
||||||
|
setLoading('file-open', false)
|
||||||
|
}
|
||||||
|
}, [tabs, switchToTab, createTab, setLoading])
|
||||||
|
|
||||||
const independentFiles = tabs.filter(t => {
|
const independentFiles = tabs.filter(t => {
|
||||||
if (!t.filePath) return false
|
if (!t.filePath) return false
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useEditorStore } from '../../stores/editorStore'
|
import { useEditorStore } from '../../stores/editorStore'
|
||||||
|
import { useDocStats } from '../../hooks/useDocStats'
|
||||||
import { getFileName } from '../../lib/fileUtils'
|
import { getFileName } from '../../lib/fileUtils'
|
||||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||||
|
|
||||||
export const StatusBar = React.memo(function StatusBar() {
|
export const StatusBar = React.memo(function StatusBar() {
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
const activeTab = useTabStore(s => s.getActiveTab())
|
||||||
const loadingStates = useEditorStore(s => s.loadingStates)
|
const loadingStates = useEditorStore(s => s.loadingStates)
|
||||||
|
const stats = useDocStats(activeTab?.content)
|
||||||
|
|
||||||
// 判断是否有任何加载状态激活
|
// 判断是否有任何加载状态激活
|
||||||
const hasLoading = Object.values(loadingStates).some(Boolean)
|
const hasLoading = Object.values(loadingStates).some(Boolean)
|
||||||
@@ -32,6 +34,22 @@ export const StatusBar = React.memo(function StatusBar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-right">
|
<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 id="status-encoding">UTF-8</span>
|
||||||
<span className="status-divider">|</span>
|
<span className="status-divider">|</span>
|
||||||
<span id="status-lang">Markdown</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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
|
|||||||
setLoading('file-open', true)
|
setLoading('file-open', true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.readFile(filePath)
|
const result = await window.electronAPI.readFile(filePath)
|
||||||
if (result.success) {
|
if (result.success && result.content !== undefined) {
|
||||||
createTab(filePath, result.content)
|
createTab(filePath, result.content)
|
||||||
recentFilesRepository.add(filePath)
|
recentFilesRepository.add(filePath)
|
||||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||||
|
|||||||
@@ -566,6 +566,10 @@ body {
|
|||||||
color: var(--border);
|
color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-stat {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Drop Overlay */
|
/* Drop Overlay */
|
||||||
#drop-overlay {
|
#drop-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user