Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { setupIpcHandlers } from './ipc-handlers'
import { PythonBridge } from './python-bridge'
import { logger } from './logger'
import { logger, archiveCurrentSession } from './logger'

let mainWindow: BrowserWindow | null = null
let pythonBridge: PythonBridge | null = null
Expand Down Expand Up @@ -58,6 +58,7 @@ process.on('unhandledRejection', (reason) => {
})

app.whenReady().then(async () => {
archiveCurrentSession()
logger.info(`App started — version ${app.getVersion()}`)
electronApp.setAppUserModelId('com.modly.app')

Expand Down
29 changes: 28 additions & 1 deletion electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ipcMain, BrowserWindow, dialog, app } from 'electron'
import { join } from 'path'
import { rm as rmAsync, readFile, writeFile, mkdir, readdir, rename, cp } from 'fs/promises'
import { existsSync } from 'fs'
import { existsSync, readdirSync, statSync } from 'fs'
import axios from 'axios'
import tar from 'tar'
import { PythonBridge, API_BASE_URL } from './python-bridge'
Expand All @@ -20,6 +20,33 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
// Logging from renderer
ipcMain.on('log:error', (_event, message: string) => logger.error(`[Renderer] ${message}`))
ipcMain.handle('log:getPath', () => join(app.getPath('userData'), 'logs', 'modly.log'))
ipcMain.handle('log:readAll', async (_event, session?: string) => {
const logsDir = join(app.getPath('userData'), 'logs')
const dir = session ? join(logsDir, 'sessions', session) : logsDir
const files = ['modly.log', 'errors.log', 'runtime.log']
const result: Record<string, string> = {}
for (const file of files) {
try {
const filePath = join(dir, file)
result[file] = existsSync(filePath) ? await readFile(filePath, 'utf-8') : ''
} catch {
result[file] = ''
}
}
return result
})
ipcMain.handle('log:listSessions', () => {
const sessionsDir = join(app.getPath('userData'), 'logs', 'sessions')
if (!existsSync(sessionsDir)) return []
try {
return readdirSync(sessionsDir)
.filter(f => statSync(join(sessionsDir, f)).isDirectory())
.sort()
.reverse()
} catch {
return []
}
})

// Window controls (frameless window)
ipcMain.on('window:minimize', () => getWindow()?.minimize())
Expand Down
48 changes: 34 additions & 14 deletions electron/main/logger.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,55 @@
import { app } from 'electron'
import { appendFileSync, mkdirSync, existsSync, statSync, renameSync } from 'fs'
import { appendFileSync, mkdirSync, existsSync, readdirSync, statSync, renameSync, rmSync } from 'fs'
import { join } from 'path'

const MAX_SIZE_BYTES = 5 * 1024 * 1024 // 5 MB
const MAX_SESSIONS = 10
const LOG_FILES = ['modly.log', 'errors.log', 'runtime.log']

function getLogsDir(): string {
const logsDir = join(app.getPath('userData'), 'logs')
mkdirSync(logsDir, { recursive: true })
return logsDir
}

function rotate(logPath: string): void {
function writeTo(filename: string, logLine: string): void {
try {
if (existsSync(logPath) && statSync(logPath).size > MAX_SIZE_BYTES) {
renameSync(logPath, logPath.replace('.log', '.old.log'))
}
} catch {}
}

function writeTo(filename: string, line: string): void {
try {
const logPath = join(getLogsDir(), filename)
rotate(logPath)
appendFileSync(logPath, line, 'utf-8')
appendFileSync(join(getLogsDir(), filename), logLine, 'utf-8')
} catch {}
}

function line(level: string, message: string): string {
return `[${new Date().toISOString()}] [${level}] ${message}\n`
}

export function archiveCurrentSession(): void {
const logsDir = getLogsDir()
const hasLogs = LOG_FILES.some(f => existsSync(join(logsDir, f)))
if (!hasLogs) return

const timestamp = new Date().toISOString().replace(/:/g, '-').slice(0, 19)
const sessionsDir = join(logsDir, 'sessions')
const sessionDir = join(sessionsDir, timestamp)
mkdirSync(sessionDir, { recursive: true })

for (const file of LOG_FILES) {
const src = join(logsDir, file)
if (existsSync(src)) {
try { renameSync(src, join(sessionDir, file)) } catch {}
}
}

// Keep only last MAX_SESSIONS
try {
const sessions = readdirSync(sessionsDir)
.filter(f => statSync(join(sessionsDir, f)).isDirectory())
.sort()
.reverse()
for (const old of sessions.slice(MAX_SESSIONS)) {
rmSync(join(sessionsDir, old), { recursive: true, force: true })
}
} catch {}
}

export const logger = {
info: (msg: string) => { console.log(msg); writeTo('modly.log', line('INFO', msg)) },
warn: (msg: string) => { console.warn(msg); writeTo('modly.log', line('WARN', msg)) },
Expand Down
2 changes: 2 additions & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ contextBridge.exposeInMainWorld('electron', {
log: {
error: (message: string) => ipcRenderer.send('log:error', message),
getPath: (): Promise<string> => ipcRenderer.invoke('log:getPath'),
readAll: (session?: string): Promise<Record<string, string>> => ipcRenderer.invoke('log:readAll', session),
listSessions: (): Promise<string[]> => ipcRenderer.invoke('log:listSessions'),
},

// Workspace filesystem-based persistence
Expand Down
17 changes: 16 additions & 1 deletion src/areas/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { StorageSection } from './components/StorageSection'
import { AboutSection } from './components/AboutSection'
import { LogsSection } from './components/LogsSection'

type Section = 'storage' | 'about'
type Section = 'storage' | 'logs' | 'about'

const SECTIONS: { id: Section; label: string; icon: JSX.Element }[] = [
{
Expand All @@ -16,6 +17,19 @@ const SECTIONS: { id: Section; label: string; icon: JSX.Element }[] = [
</svg>
)
},
{
id: 'logs',
label: 'Logs',
icon: (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
},
{
id: 'about',
label: 'About',
Expand Down Expand Up @@ -61,6 +75,7 @@ export default function SettingsPage(): JSX.Element {
<div className="flex-1 overflow-y-auto bg-surface-400">
<div className="p-8">
{section === 'storage' && <StorageSection />}
{section === 'logs' && <LogsSection />}
{section === 'about' && <AboutSection />}
</div>
</div>
Expand Down
140 changes: 140 additions & 0 deletions src/areas/settings/components/LogsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useEffect, useState, useCallback } from 'react'

const LOG_FILES = [
{ id: 'errors.log', label: 'Errors', description: 'All errors from Electron and Python' },
{ id: 'runtime.log', label: 'Runtime', description: 'FastAPI / Python output' },
{ id: 'modly.log', label: 'App', description: 'General Electron logs' },
]

function formatSession(id: string): string {
// id format: 2026-03-20T10-23-45
try {
const iso = id.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3')
const d = new Date(iso)
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return id
}
}

export function LogsSection(): JSX.Element {
const [sessions, setSessions] = useState<string[]>([])
const [activeSession, setActiveSession] = useState<string | null>(null) // null = current
const [activeFile, setActiveFile] = useState('errors.log')
const [logs, setLogs] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)

const loadSessions = useCallback(async () => {
const list = await window.electron.log.listSessions()
setSessions(list)
}, [])

const loadLogs = useCallback(async (session: string | null) => {
setLoading(true)
const result = await window.electron.log.readAll(session ?? undefined)
setLogs(result)
setLoading(false)
}, [])

useEffect(() => {
loadSessions()
loadLogs(null)
}, [loadSessions, loadLogs])

const handleSessionChange = (value: string) => {
const session = value === 'current' ? null : value
setActiveSession(session)
loadLogs(session)
}

const handleRefresh = () => {
loadSessions()
loadLogs(activeSession)
}

const content = logs[activeFile] ?? ''

const handleCopy = () => {
navigator.clipboard.writeText(content).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}

return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-sm font-semibold text-zinc-100">Logs</h2>
<p className="text-xs text-zinc-500 mt-0.5">Application log files — share these when reporting issues.</p>
</div>

{/* Session selector */}
<div className="flex items-center gap-3">
<label className="text-xs text-zinc-500 shrink-0">Session</label>
<select
value={activeSession ?? 'current'}
onChange={(e) => handleSessionChange(e.target.value)}
className="flex-1 bg-zinc-800/80 border border-zinc-700/60 text-zinc-200 text-xs rounded-lg px-3 py-2 outline-none focus:border-zinc-600"
>
<option value="current">Current session</option>
{sessions.map((s) => (
<option key={s} value={s}>{formatSession(s)}</option>
))}
</select>
</div>

{/* File tabs */}
<div className="flex items-center gap-1 border-b border-zinc-800">
{LOG_FILES.map((f) => (
<button
key={f.id}
onClick={() => setActiveFile(f.id)}
title={f.description}
className={`px-4 py-2 text-xs font-medium transition-colors border-b-2 -mb-px ${
activeFile === f.id
? 'border-accent text-accent-light'
: 'border-transparent text-zinc-500 hover:text-zinc-300'
}`}
>
{f.label}
</button>
))}

<div className="ml-auto flex items-center gap-2 pb-1">
<button
onClick={handleRefresh}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-zinc-400 hover:text-zinc-200 disabled:opacity-40 transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={loading ? 'animate-spin' : ''}>
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
Refresh
</button>
<button
onClick={handleCopy}
disabled={!content}
className="px-3 py-1.5 text-[11px] font-semibold rounded-lg bg-zinc-700/60 hover:bg-zinc-700 text-zinc-200 disabled:opacity-40 transition-colors"
>
{copied ? 'Copied!' : 'Copy all'}
</button>
</div>
</div>

{/* Log content */}
{loading ? (
<div className="flex items-center justify-center h-48 text-zinc-600 text-xs">Loading…</div>
) : content ? (
<pre className="text-[11px] font-mono text-zinc-400 bg-zinc-900/60 border border-zinc-800 rounded-xl px-4 py-3 max-h-[480px] overflow-y-auto whitespace-pre-wrap break-words select-text leading-relaxed">
{content}
</pre>
) : (
<div className="flex items-center justify-center h-48 text-zinc-600 text-xs border border-zinc-800 rounded-xl">
No entries in {activeFile}
</div>
)}
</div>
)
}
2 changes: 2 additions & 0 deletions src/shared/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ declare global {
log: {
error: (message: string) => void
getPath: () => Promise<string>
readAll: (session?: string) => Promise<Record<string, string>>
listSessions: () => Promise<string[]>
}
workspace: {
listCollections: () => Promise<string[]>
Expand Down