diff --git a/src/main/index.ts b/src/main/index.ts index 93959f2..d5e22ad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,14 +1,44 @@ -import { join } from 'path' -import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme } from 'electron' +import { join, dirname } from 'path' +import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme, screen } from 'electron' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import iconv from 'iconv-lite' import fs from 'fs' +import { parseState, clampBounds, type AppState } from './windowState' + +/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */ +const stateFile = (): string => join(app.getPath('userData'), 'window-state.json') + +/** 当前生效的应用状态(启动时从磁盘读取,运行中增量更新) */ +let appState: AppState = {} + +/** 读取持久化状态:文件不存在/损坏一律回退空状态,不影响启动 */ +function loadState(): AppState { + try { + return parseState(fs.readFileSync(stateFile(), 'utf-8')) + } catch { + return {} + } +} + +/** 写回持久化状态:失败静默忽略(目录只读/磁盘满等,偏好记忆失效但不影响本次会话) */ +function saveState(): void { + try { + fs.writeFileSync(stateFile(), JSON.stringify(appState), 'utf-8') + } catch { + // 忽略写入失败 + } +} function createWindow(): void { + // 恢复上次窗口尺寸/位置:越界 bounds 钳制回当前屏幕工作区,无记录时用默认尺寸 + const wa = screen.getPrimaryDisplay().workArea + const bounds = appState.bounds ? clampBounds(appState.bounds, wa) : null // 主窗口 const mainWindow = new BrowserWindow({ - width: 1280, - height: 820, + width: bounds?.width ?? 1280, + height: bounds?.height ?? 820, + x: bounds?.x, + y: bounds?.y, minWidth: 900, minHeight: 600, show: false, @@ -24,6 +54,14 @@ function createWindow(): void { } }) + // 关闭时记忆窗口普通状态 bounds(最大化/全屏/最小化时不记录,避免把超屏尺寸存进偏好) + mainWindow.on('close', () => { + if (mainWindow.isMinimized() || mainWindow.isMaximized() || mainWindow.isFullScreen()) return + const b = mainWindow.getBounds() + appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height } + saveState() + }) + mainWindow.on('ready-to-show', () => { mainWindow.show() }) @@ -98,6 +136,8 @@ function decodeText(buf: Buffer): { text: string; encoding: string; binary: bool ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { const result = await dialog.showOpenDialog({ title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`, + // 记忆上次成功打开文件所在目录;无记录时传 undefined 走系统默认(最近使用位置) + defaultPath: appState.lastDir, properties: ['openFile'], filters: [ // 与渲染进程 TEXT_EXTENSIONS(src/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步 @@ -108,6 +148,9 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { }) if (result.canceled || result.filePaths.length === 0) return null const filePath = result.filePaths[0] + // 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件) + appState.lastDir = dirname(filePath) + saveState() const name = filePath.split(/[\\/]/).pop() ?? filePath let buf: Buffer try { @@ -163,6 +206,9 @@ app.whenReady().then(() => { // 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格 nativeTheme.themeSource = 'dark' + // 读取持久化应用状态(窗口 bounds + 上次文件目录;损坏数据回退空状态) + appState = loadState() + app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) diff --git a/src/main/windowState.test.ts b/src/main/windowState.test.ts new file mode 100644 index 0000000..689fcdf --- /dev/null +++ b/src/main/windowState.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { parseState, clampBounds, MIN_WIDTH, MIN_HEIGHT, type Rect } from './windowState' + +/** 1920×1040 工作区(常见全高清屏去掉任务栏) */ +const work: Rect = { x: 0, y: 0, width: 1920, height: 1040 } + +describe('parseState', () => { + it('空输入回退空状态', () => { + expect(parseState(null)).toEqual({}) + expect(parseState(undefined)).toEqual({}) + expect(parseState('')).toEqual({}) + }) + + it('非法 JSON 回退空状态', () => { + expect(parseState('{broken json!!')).toEqual({}) + }) + + it('合法 bounds 与 lastDir 被保留', () => { + const s = parseState( + JSON.stringify({ bounds: { x: 10, y: 20, width: 1000, height: 700 }, lastDir: 'C:/docs' }) + ) + expect(s.bounds).toEqual({ x: 10, y: 20, width: 1000, height: 700 }) + expect(s.lastDir).toBe('C:/docs') + }) + + it('bounds 字段类型异常或缺失时被忽略', () => { + expect(parseState(JSON.stringify({ bounds: { x: 'a', y: 2, width: 1000, height: 700 } })).bounds).toBeUndefined() + expect(parseState(JSON.stringify({ bounds: { x: 1, y: 2, width: 1000 } })).bounds).toBeUndefined() + expect(parseState(JSON.stringify({ bounds: null })).bounds).toBeUndefined() + }) + + it('bounds 含 NaN/Infinity 等非有限数值时被忽略', () => { + const s = parseState( + '{"bounds":{"x":NaN,"y":2,"width":1000,"height":700}}' + ) + expect(s.bounds).toBeUndefined() + }) + + it('lastDir 非字符串或空串时被忽略', () => { + expect(parseState(JSON.stringify({ lastDir: 42 })).lastDir).toBeUndefined() + expect(parseState(JSON.stringify({ lastDir: '' })).lastDir).toBeUndefined() + }) +}) + +describe('clampBounds', () => { + it('工作区内合法 bounds 原样保留', () => { + const b = { x: 100, y: 50, width: 1280, height: 820 } + expect(clampBounds(b, work)).toEqual(b) + }) + + it('小于最小尺寸时钳制到最小值', () => { + const c = clampBounds({ x: 100, y: 100, width: 400, height: 300 }, work) + expect(c.width).toBe(MIN_WIDTH) + expect(c.height).toBe(MIN_HEIGHT) + }) + + it('超出工作区尺寸时封顶到工作区大小', () => { + const c = clampBounds({ x: 0, y: 0, width: 4000, height: 3000 }, work) + expect(c.width).toBe(work.width) + expect(c.height).toBe(work.height) + }) + + it('窗口移出屏幕左侧时拉回(右边缘至少 60px 进入工作区)', () => { + const c = clampBounds({ x: -2000, y: 100, width: 1000, height: 700 }, work) + expect(c.x).toBe(work.x - 1000 + 60) + }) + + it('窗口移出屏幕右侧时拉回(左边缘至少 60px 进入工作区)', () => { + const c = clampBounds({ x: 5000, y: 100, width: 1000, height: 700 }, work) + expect(c.x).toBe(work.x + work.width - 60) + }) + + it('窗口移出屏幕下方时拉回', () => { + const c = clampBounds({ x: 100, y: 5000, width: 1000, height: 700 }, work) + expect(c.y).toBe(work.y + work.height - 60) + }) + + it('带偏移的工作区(多显示器副屏)同样钳制', () => { + const side: Rect = { x: 1920, y: 200, width: 1080, height: 900 } + const c = clampBounds({ x: 0, y: 0, width: 1000, height: 700 }, side) + // 左边缘至少 60px 进入副屏 + expect(c.x).toBe(side.x - 1000 + 60) + }) + + it('非整数尺寸与坐标被取整', () => { + const c = clampBounds({ x: 10.6, y: 20.2, width: 1000.7, height: 700.1 }, work) + expect(Number.isInteger(c.x)).toBe(true) + expect(Number.isInteger(c.y)).toBe(true) + expect(Number.isInteger(c.width)).toBe(true) + expect(Number.isInteger(c.height)).toBe(true) + }) + + it('工作区小于最小尺寸时尺寸仍保持最小值', () => { + const tiny: Rect = { x: 0, y: 0, width: 800, height: 500 } + const c = clampBounds({ x: 0, y: 0, width: 1000, height: 700 }, tiny) + expect(c.width).toBe(MIN_WIDTH) + expect(c.height).toBe(MIN_HEIGHT) + }) +}) diff --git a/src/main/windowState.ts b/src/main/windowState.ts new file mode 100644 index 0000000..7d3e287 --- /dev/null +++ b/src/main/windowState.ts @@ -0,0 +1,70 @@ +/** + * 应用状态持久化的纯数据模块:窗口尺寸/位置记忆 + 文件对话框上次目录记忆。 + * 不依赖 electron 与 fs(磁盘读写由主进程调用方完成),校验逻辑可被 vitest 直接测试。 + */ + +/** 矩形区域(窗口 bounds 与屏幕工作区共用结构,字段与 Electron Rectangle 一致) */ +export interface Rect { + x: number + y: number + width: number + height: number +} + +/** 持久化的应用状态 */ +export interface AppState { + /** 上次关闭时的窗口 bounds */ + bounds?: Rect + /** 上次成功打开文件所在目录(file:open 对话框 defaultPath) */ + lastDir?: string +} + +/** 最小窗口尺寸(与 BrowserWindow 的 minWidth/minHeight 保持一致,改动需双向同步) */ +export const MIN_WIDTH = 900 +export const MIN_HEIGHT = 600 + +function isRect(v: unknown): v is Rect { + if (typeof v !== 'object' || v === null) return false + const r = v as Record + return ( + typeof r.x === 'number' && + Number.isFinite(r.x) && + typeof r.y === 'number' && + Number.isFinite(r.y) && + typeof r.width === 'number' && + Number.isFinite(r.width) && + typeof r.height === 'number' && + Number.isFinite(r.height) + ) +} + +/** + * 解析持久化文本为状态对象:JSON 非法 / 字段类型异常一律回退空状态, + * 保证损坏数据不影响启动。 + */ +export function parseState(raw: string | null | undefined): AppState { + if (!raw) return {} + try { + const obj = JSON.parse(raw) as AppState + const state: AppState = {} + if (isRect(obj.bounds)) state.bounds = obj.bounds + if (typeof obj.lastDir === 'string' && obj.lastDir !== '') state.lastDir = obj.lastDir + return state + } catch { + return {} + } +} + +/** + * 把窗口 bounds 钳制到屏幕工作区: + * - 尺寸不小于最小值、不超过工作区大小(工作区小于最小值时以最小值为准); + * - 位置保证窗口至少 60px 落在工作区内(分辨率/显示器变更后不出现“看不见的窗口”); + * - 结果取整(BrowserWindow 接受整数坐标)。 + */ +export function clampBounds(b: Rect, workArea: Rect): Rect { + const width = Math.min(Math.max(b.width, MIN_WIDTH), Math.max(workArea.width, MIN_WIDTH)) + const height = Math.min(Math.max(b.height, MIN_HEIGHT), Math.max(workArea.height, MIN_HEIGHT)) + const x = Math.min(Math.max(b.x, workArea.x - width + 60), workArea.x + workArea.width - 60) + const y = Math.min(Math.max(b.y, workArea.y - height + 60), workArea.y + workArea.height - 60) + return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) } +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3a6d517..7ff598f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -493,6 +493,10 @@ export default function App(): ReactElement { const leftMeta = paneL?.meta ?? null const rightMeta = paneR?.meta ?? null + // 状态栏行数(面板内容不变时不重算;切分规则与 diff 引擎一致) + const leftLines = useMemo(() => (paneL ? countLines(paneL.text) : 0), [paneL]) + const rightLines = useMemo(() => (paneR ? countLines(paneR.text) : 0), [paneR]) + return (
@@ -580,9 +584,11 @@ export default function App(): ReactElement {
左:{paneL ? paneL.meta.name : '—'} + {paneL ? ` · ${leftLines} 行` : ''} 右:{paneR ? paneR.meta.name : '—'} + {paneR ? ` · ${rightLines} 行` : ''} 变更行:{summary.changedLines} diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 987ac58..8b461ac 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -791,6 +791,50 @@ describe('App - 字符级对比', () => { }) }) +describe('App - 状态栏行数', () => { + // 文件名在 子元素内,getByText 只拼直接文本子节点无法跨元素匹配, + // 用 container 取 .status-item 的完整 textContent 断言(含换行空白,正则放宽) + it('加载文件后状态栏显示对应行数(切分规则与引擎一致)', async () => { + const { container } = render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + const items = () => Array.from(container.querySelectorAll('.status-item'), (e) => e.textContent ?? '') + // mockApi 默认返回 'line1\nline2' = 2 行;右侧未加载无行数 + expect(items()[0]).toMatch(/左:\s*a\.txt\s*·\s*2 行/) + expect(items()[1]).toMatch(/右:\s*—$/) + }) + + it('两侧均加载时各自显示行数', async () => { + window.api = mockApi({ + openFile: async (side) => + side === 'left' + ? { path: '/tmp/a.txt', name: 'a.txt', text: 'a\nb\nc', encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.txt', name: 'b.txt', text: 'x\ny', encoding: 'UTF-8', binary: false } + }) + const { container } = render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findAllByText('b.txt') + const items = () => Array.from(container.querySelectorAll('.status-item'), (e) => e.textContent ?? '') + expect(items()[0]).toMatch(/左:\s*a\.txt\s*·\s*3 行/) + expect(items()[1]).toMatch(/右:\s*b\.txt\s*·\s*2 行/) + }) + + it('清空后行数展示随空态消失', async () => { + const { container } = render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + const items = () => Array.from(container.querySelectorAll('.status-item'), (e) => e.textContent ?? '') + expect(items()[0]).toMatch(/左:\s*a\.txt\s*·\s*2 行/) + fireEvent.click(screen.getByText('清空')) + await screen.findByText(/再次点击/) + fireEvent.click(screen.getByText('清空')) + await screen.findByText(/选择左侧文件/) + expect(items()[0]).not.toMatch(/·\s*2 行/) + }) +}) + describe('App - 交换左右侧', () => { it('交换后左右面板文件互换', async () => { window.api = mockApi({ diff --git a/vitest.config.ts b/vitest.config.ts index 53a7664..fb1a348 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,18 +13,24 @@ export default defineConfig({ environment: 'jsdom', globals: false, setupFiles: ['./src/renderer/src/test/setup.ts'], - include: ['src/renderer/src/**/*.{test,spec}.{ts,tsx}'], + include: [ + 'src/renderer/src/**/*.{test,spec}.{ts,tsx}', + // 主进程纯逻辑模块(无 electron 依赖)的单元测试 + 'src/main/**/*.{test,spec}.{ts,tsx}' + ], exclude: ['node_modules', 'dist', 'out'], coverage: { provider: 'v8', reporter: ['text', 'text-summary'], - include: ['src/renderer/src/**/*.{ts,tsx}'], + include: ['src/renderer/src/**/*.{ts,tsx}', 'src/main/**/*.ts'], exclude: [ 'src/renderer/src/**/*.{test,spec}.{ts,tsx}', 'src/renderer/src/test/**', 'src/renderer/src/env.d.ts', // worker 线程入口无法在 jsdom 执行,其调用的 computeDiff 已由 diffEngine 测试覆盖 - 'src/renderer/src/diff/diffWorker.ts' + 'src/renderer/src/diff/diffWorker.ts', + // 主进程入口依赖 electron 运行时,无法在 jsdom 测试(窗口/IPC 逻辑随发布人工自测) + 'src/main/index.ts' ], all: true, // CI 门禁:四项覆盖率不得低于 80%,否则测试失败