feat: 窗口尺寸位置与上次目录记忆、状态栏行数显示

This commit is contained in:
2026-08-18 12:39:26 +08:00
parent 3e3f9b173e
commit 6f7df57fba
6 changed files with 278 additions and 7 deletions
+50 -4
View File
@@ -1,14 +1,44 @@
import { join } from 'path' import { join, dirname } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme } from 'electron' import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme, screen } from 'electron'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite' import iconv from 'iconv-lite'
import fs from 'fs' 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 { function createWindow(): void {
// 恢复上次窗口尺寸/位置:越界 bounds 钳制回当前屏幕工作区,无记录时用默认尺寸
const wa = screen.getPrimaryDisplay().workArea
const bounds = appState.bounds ? clampBounds(appState.bounds, wa) : null
// 主窗口 // 主窗口
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1280, width: bounds?.width ?? 1280,
height: 820, height: bounds?.height ?? 820,
x: bounds?.x,
y: bounds?.y,
minWidth: 900, minWidth: 900,
minHeight: 600, minHeight: 600,
show: false, 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.on('ready-to-show', () => {
mainWindow.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) => { ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`, title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
// 记忆上次成功打开文件所在目录;无记录时传 undefined 走系统默认(最近使用位置)
defaultPath: appState.lastDir,
properties: ['openFile'], properties: ['openFile'],
filters: [ filters: [
// 与渲染进程 TEXT_EXTENSIONSsrc/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步 // 与渲染进程 TEXT_EXTENSIONSsrc/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 if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0] const filePath = result.filePaths[0]
// 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件)
appState.lastDir = dirname(filePath)
saveState()
const name = filePath.split(/[\\/]/).pop() ?? filePath const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer let buf: Buffer
try { try {
@@ -163,6 +206,9 @@ app.whenReady().then(() => {
// 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格 // 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格
nativeTheme.themeSource = 'dark' nativeTheme.themeSource = 'dark'
// 读取持久化应用状态(窗口 bounds + 上次文件目录;损坏数据回退空状态)
appState = loadState()
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
+99
View File
@@ -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)
})
})
+70
View File
@@ -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<string, unknown>
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) }
}
+6
View File
@@ -493,6 +493,10 @@ export default function App(): ReactElement {
const leftMeta = paneL?.meta ?? null const leftMeta = paneL?.meta ?? null
const rightMeta = paneR?.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 ( return (
<div className="app"> <div className="app">
<header className="app-header"> <header className="app-header">
@@ -580,9 +584,11 @@ export default function App(): ReactElement {
<div className="status-left"> <div className="status-left">
<span className="status-item"> <span className="status-item">
<b>{paneL ? paneL.meta.name : '—'}</b> <b>{paneL ? paneL.meta.name : '—'}</b>
{paneL ? ` · ${leftLines}` : ''}
</span> </span>
<span className="status-item"> <span className="status-item">
<b>{paneR ? paneR.meta.name : '—'}</b> <b>{paneR ? paneR.meta.name : '—'}</b>
{paneR ? ` · ${rightLines}` : ''}
</span> </span>
<span className="status-item"> <span className="status-item">
<b>{summary.changedLines}</b> <b>{summary.changedLines}</b>
+44
View File
@@ -791,6 +791,50 @@ describe('App - 字符级对比', () => {
}) })
}) })
describe('App - 状态栏行数', () => {
// 文件名在 <b> 子元素内,getByText 只拼直接文本子节点无法跨元素匹配,
// 用 container 取 .status-item 的完整 textContent 断言(含换行空白,正则放宽)
it('加载文件后状态栏显示对应行数(切分规则与引擎一致)', async () => {
const { container } = render(<App />)
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(<App />)
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(<App />)
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 - 交换左右侧', () => { describe('App - 交换左右侧', () => {
it('交换后左右面板文件互换', async () => { it('交换后左右面板文件互换', async () => {
window.api = mockApi({ window.api = mockApi({
+9 -3
View File
@@ -13,18 +13,24 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
globals: false, globals: false,
setupFiles: ['./src/renderer/src/test/setup.ts'], 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'], exclude: ['node_modules', 'dist', 'out'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'text-summary'], reporter: ['text', 'text-summary'],
include: ['src/renderer/src/**/*.{ts,tsx}'], include: ['src/renderer/src/**/*.{ts,tsx}', 'src/main/**/*.ts'],
exclude: [ exclude: [
'src/renderer/src/**/*.{test,spec}.{ts,tsx}', 'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
'src/renderer/src/test/**', 'src/renderer/src/test/**',
'src/renderer/src/env.d.ts', 'src/renderer/src/env.d.ts',
// worker 线程入口无法在 jsdom 执行,其调用的 computeDiff 已由 diffEngine 测试覆盖 // 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, all: true,
// CI 门禁:四项覆盖率不得低于 80%,否则测试失败 // CI 门禁:四项覆盖率不得低于 80%,否则测试失败