:lightning: macOS performance

This commit is contained in:
2026-06-08 19:11:44 +10:00
parent 927aaeb919
commit 9d9a18627e
7 changed files with 66 additions and 34 deletions

View File

@@ -2,7 +2,7 @@ import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipcHandlers'
import { getCollectionRoot } from './globalConfig'
import { getCollectionRoot, readGlobalConfig } from './globalConfig'
app.setName('Borges')
@@ -92,6 +92,8 @@ function buildAppMenu(win: BrowserWindow): void {
function createWindow(): BrowserWindow {
const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
const config = readGlobalConfig()
const isDark = config.theme !== 'light'
const mainWindow = new BrowserWindow({
width: 1440,
height: 900,
@@ -99,6 +101,8 @@ function createWindow(): BrowserWindow {
minHeight: 600,
title: 'Borges',
titleBarStyle: 'hiddenInset',
vibrancy: 'sidebar',
backgroundColor: isDark ? '#1c1a18' : '#f5f0ea',
icon,
show: false,
webPreferences: {

View File

@@ -1,4 +1,4 @@
import { ipcMain, dialog, BrowserWindow } from 'electron'
import { ipcMain, dialog, BrowserWindow, Menu } from 'electron'
import {
listStories, readStory, writeStory, createStory, renameStory, deleteStory,
getStoryMeta, setStoryMeta, getCollectionConfig, setCollectionContext,
@@ -60,6 +60,34 @@ export function registerIpcHandlers(): void {
return result.canceled ? null : result.filePaths[0]
})
// ── Native context menus ─────────────────────────────────────────────────────
ipcMain.handle('menu:editorContext', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
const menu = Menu.buildFromTemplate([
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ type: 'separator' },
{ role: 'selectAll' },
])
menu.popup({ window: win })
})
ipcMain.handle('menu:storyContext', async (event, _storyId: string) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
return new Promise<string | null>((resolve) => {
const menu = Menu.buildFromTemplate([
{ label: 'Rename', click: () => resolve('rename') },
{ type: 'separator' },
{ label: 'Delete', click: () => resolve('delete') },
])
menu.on('menu-will-close', () => setTimeout(() => resolve(null), 100))
menu.popup({ window: win })
})
})
// ── AI streaming ──────────────────────────────────────────────────────────────
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {

View File

@@ -66,6 +66,10 @@ contextBridge.exposeInMainWorld('api', {
})
},
// Native context menus
showEditorContextMenu: (): Promise<void> => ipcRenderer.invoke('menu:editorContext'),
showStoryContextMenu: (storyId: string): Promise<string | null> => ipcRenderer.invoke('menu:storyContext', storyId),
// Menu
onMenuAction: (handler: (action: string) => void): (() => void) => {
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)

View File

@@ -83,6 +83,7 @@ export function MarkdownEditor(): JSX.Element {
const editorRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const lastPathRef = useRef<string | null>(null)
const storeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Initialize editor
@@ -107,10 +108,17 @@ export function MarkdownEditor(): JSX.Element {
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString()
useBorgesStore.getState().setContent(content)
if (storeTimerRef.current) clearTimeout(storeTimerRef.current)
storeTimerRef.current = setTimeout(() => {
useBorgesStore.getState().setContent(content)
}, 300)
}
}),
EditorView.domEventHandlers({
contextmenu: (e) => {
e.preventDefault()
window.api.showEditorContextMenu()
},
keydown: (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()

View File

@@ -44,16 +44,9 @@ function StoryItem({ story, index, isActive, onClick, onContextMenu, onDragStart
)
}
interface ContextMenuState {
x: number
y: number
story: StoryFile
}
export function StorySidebar(): JSX.Element {
const { stories, activeStoryPath, activeStoryId, activeStoryContent, isDirty, setStories, moveStory, markSaved } = useBorgesStore()
const [search, setSearch] = useState('')
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [renaming, setRenaming] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const dragFrom = useRef<number>(-1)
@@ -95,15 +88,25 @@ export function StorySidebar(): JSX.Element {
}
const handleDelete = async (story: StoryFile): Promise<void> => {
setContextMenu(null)
await window.api.deleteStory(story.path)
if (story.path === activeStoryPath) useBorgesStore.getState().clearActiveStory()
const refreshed = await window.api.listStories()
setStories(refreshed)
}
const handleStoryContextMenu = async (e: React.MouseEvent, story: StoryFile): Promise<void> => {
e.preventDefault()
const action = await window.api.showStoryContextMenu(story.id)
if (action === 'rename') {
setRenaming(story.id)
setRenameValue(story.id)
} else if (action === 'delete') {
handleDelete(story)
}
}
return (
<div className="sidebar" onClick={() => contextMenu && setContextMenu(null)}>
<div className="sidebar">
<div className="sidebar-header">
<span className="sidebar-title">Stories</span>
<button className="sidebar-btn" onClick={handleNew} title="New story">+</button>
@@ -139,10 +142,7 @@ export function StorySidebar(): JSX.Element {
index={idx}
isActive={story.id === activeStoryId}
onClick={() => openStory(story)}
onContextMenu={(e) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, story })
}}
onContextMenu={(e) => handleStoryContextMenu(e, story)}
onDragStart={(i) => { dragFrom.current = i }}
onDragOver={(i) => { if (dragFrom.current !== -1 && dragFrom.current !== i) moveStory(dragFrom.current, i) }}
onDrop={() => { dragFrom.current = -1 }}
@@ -152,21 +152,6 @@ export function StorySidebar(): JSX.Element {
))}
</div>
{contextMenu && (
<div
className="context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button className="context-menu-item" onClick={() => {
setRenaming(contextMenu.story.id)
setRenameValue(contextMenu.story.id)
setContextMenu(null)
}}>Rename</button>
<div className="context-menu-sep" />
<button className="context-menu-item danger" onClick={() => handleDelete(contextMenu.story)}>Delete</button>
</div>
)}
</div>
)
}

View File

@@ -45,7 +45,7 @@ body {
font-size: 13px;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
-webkit-font-smoothing: subpixel-antialiased;
user-select: none;
}
@@ -161,7 +161,7 @@ textarea { resize: vertical; }
grid-area: sidebar;
display: flex;
flex-direction: column;
background: var(--bg2);
background: transparent;
border-right: 1px solid var(--border);
overflow: hidden;
min-width: 0;
@@ -296,12 +296,13 @@ textarea { resize: vertical; }
flex: 1;
overflow-y: auto;
padding: 32px 0;
overscroll-behavior: none;
}
/* CodeMirror overrides */
.cm-editor { height: 100%; }
.cm-editor.cm-focused { outline: none; }
.cm-scroller { font-family: 'Georgia', 'Times New Roman', serif; line-height: 1.8; font-size: 16px; transition: font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
.cm-scroller { font-family: 'Georgia', 'Times New Roman', serif; line-height: 1.8; font-size: 16px; transition: font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1); overscroll-behavior: none; }
.cm-content { max-width: 680px; margin: 0 auto; padding: 48px 24px 24px; transition: max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1), padding 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
.cm-line { padding: 0; margin-bottom: 0.75em; }
.cm-line:last-child { margin-bottom: 0; }

View File

@@ -57,6 +57,8 @@ declare global {
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
pickFolder(): Promise<string | null>
streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise<void>
showEditorContextMenu(): Promise<void>
showStoryContextMenu(storyId: string): Promise<string | null>
onMenuAction(handler: (action: string) => void): () => void
}
}