diff --git a/src/main/fileWatcher.ts b/src/main/fileWatcher.ts index c0a3693..58dbb91 100644 --- a/src/main/fileWatcher.ts +++ b/src/main/fileWatcher.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { log } from './logger' import { watch, type FSWatcher } from 'chokidar' import { BrowserWindow } from 'electron' @@ -42,7 +43,7 @@ function runGitStatus(cwd: string): Promise { return new Promise((resolve) => { execFile('git', ['status', '--porcelain'], { cwd }, (err, stdout) => { if (err) { - console.warn('[fileWatcher] git status failed for', cwd, (err as Error).message) + log.warn('fileWatcher', `git status failed for ${cwd}: ${(err as Error).message}`) resolve([]) return } diff --git a/src/main/github.ts b/src/main/github.ts index ec21147..766c5f4 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { log } from './logger' import type { GitHubRepo, GitHubPR, GitHubIssue } from '../shared/types' export type { GitHubRepo, GitHubPR, GitHubIssue } @@ -7,7 +8,7 @@ export function getGitHubRepo(cwd: string): Promise { return new Promise((resolve) => { execFile('git', ['remote', 'get-url', 'origin'], { cwd }, (err, stdout) => { if (err) { - console.warn('[github] Failed to get remote URL:', (err as Error).message) + log.warn('github', `Failed to get remote URL: ${(err as Error).message}`) resolve(null) return } @@ -39,7 +40,7 @@ export async function listPullRequests( { cwd }, (err, stdout) => { if (err) { - console.warn('[github] gh pr list failed:', (err as Error).message) + log.warn('github', `gh pr list failed: ${(err as Error).message}`) reject(err) return } @@ -99,7 +100,7 @@ export async function listIssues( { cwd }, (err, stdout) => { if (err) { - console.warn('[github] gh issue list failed:', (err as Error).message) + log.warn('github', `gh issue list failed: ${(err as Error).message}`) reject(err) return } diff --git a/src/main/index.ts b/src/main/index.ts index 7c57741..8e134eb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron' import { autoUpdater } from 'electron-updater' +import { log } from './logger' if (process.platform === 'linux') { app.commandLine.appendSwitch('no-sandbox') @@ -200,7 +201,7 @@ function registerSessionHandlers(ipc: typeof ipcMain, window: BrowserWindow): vo } context = snippets.join('\n\n') } catch (err) { - console.warn('[summary] Failed to read transcript:', (err as Error).message) + log.warn('summary', `Failed to read transcript: ${(err as Error).message}`) } if (!context) return '' @@ -214,7 +215,7 @@ function registerSessionHandlers(ipc: typeof ipcMain, window: BrowserWindow): vo { env: getEnv(), timeout: 15000 }, (err, stdout) => { if (err || !stdout) { - if (err) console.warn('[summary] Claude CLI failed:', (err as Error).message) + if (err) log.warn('summary', `Claude CLI failed: ${(err as Error).message}`) resolve('') return } @@ -258,7 +259,7 @@ function registerFileHandlers(ipc: typeof ipcMain): void { } else { execFile('git', ['diff', 'HEAD', '--', filePath], { cwd }, (err, stdout) => { if (err && !stdout) { - console.warn('[diff] git diff failed for', filePath, (err as Error).message) + log.warn('diff', `git diff failed for ${filePath}: ${(err as Error).message}`) resolve('') return } @@ -371,6 +372,18 @@ function registerUpdateHandlers(ipc: typeof ipcMain): void { ipc.on('install-update', () => { autoUpdater.quitAndInstall() }) + ipc.on('check-for-updates', () => { + if (is.dev) { + log.warn('updater', 'Auto-update is not available in development mode') + mainWindow?.webContents.send('update-status', { + status: 'error', + message: 'Auto-update is not available in development mode' + }) + return + } + autoUpdater.checkForUpdates() + }) + ipc.handle('get-logs', () => log.getHistory()) } // ─── App Lifecycle ──────────────────────────────────────────────────── @@ -383,6 +396,8 @@ app.whenReady().then(() => { }) createWindow() + log.setWindow(mainWindow!) + log.info('app', `Konductor started (${is.dev ? 'dev' : 'production'})`) registerStateHandlers(ipcMain) registerSessionHandlers(ipcMain, mainWindow!) @@ -398,30 +413,37 @@ app.whenReady().then(() => { autoUpdater.autoDownload = true autoUpdater.autoInstallOnAppQuit = true - autoUpdater.on('checking-for-update', () => console.log('[updater] Checking for update…')) + autoUpdater.on('checking-for-update', () => log.info('updater', 'Checking for update…')) autoUpdater.on('update-available', (info) => { - console.log(`[updater] Update available: ${info.version}`) + log.info('updater', `Update available: ${info.version}`) mainWindow?.webContents.send('update-status', { status: 'available', version: info.version }) }) autoUpdater.on('update-not-available', (info) => - console.log(`[updater] Up to date (${info.version})`) + log.info('updater', `Up to date (${info.version})`) ) autoUpdater.on('download-progress', (p) => - console.log(`[updater] Downloading: ${Math.round(p.percent)}%`) + log.info('updater', `Downloading: ${Math.round(p.percent)}%`) ) autoUpdater.on('update-downloaded', (info) => { - console.log(`[updater] Update downloaded: ${info.version} — will install on quit`) + log.info('updater', `Update downloaded: ${info.version} — will install on quit`) mainWindow?.webContents.send('update-status', { status: 'ready', version: info.version }) }) - autoUpdater.on('error', (err) => console.error('[updater] Error:', err.message)) + autoUpdater.on('error', (err) => { + log.error('updater', err.message) + mainWindow?.webContents.send('update-status', { + status: 'error', + message: err.message + }) + }) - autoUpdater.checkForUpdatesAndNotify() + autoUpdater.checkForUpdates() + setInterval(() => autoUpdater.checkForUpdates(), 30 * 60 * 1000) } app.on('activate', () => { diff --git a/src/main/logger.ts b/src/main/logger.ts new file mode 100644 index 0000000..a848564 --- /dev/null +++ b/src/main/logger.ts @@ -0,0 +1,98 @@ +import { appendFileSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import type { BrowserWindow } from 'electron' + +export interface LogEntry { + timestamp: number + level: 'info' | 'warn' | 'error' + category: string + message: string +} + +const LOG_DIR = join(homedir(), '.konductor') +const LOG_FILE = join(LOG_DIR, 'app.log') +const MAX_FILE_SIZE = 512 * 1024 // 512 KB + +class Logger { + private window: BrowserWindow | null = null + + constructor() { + try { + mkdirSync(LOG_DIR, { recursive: true }) + } catch { + // best-effort + } + } + + setWindow(win: BrowserWindow): void { + this.window = win + } + + info(category: string, message: string): void { + this.write('info', category, message) + console.log(`[${category}] ${message}`) + } + + warn(category: string, message: string): void { + this.write('warn', category, message) + console.warn(`[${category}] ${message}`) + } + + error(category: string, message: string): void { + this.write('error', category, message) + console.error(`[${category}] ${message}`) + } + + getHistory(): LogEntry[] { + try { + const raw = readFileSync(LOG_FILE, 'utf-8') + const entries: LogEntry[] = [] + for (const line of raw.split('\n')) { + if (!line) continue + try { + entries.push(JSON.parse(line)) + } catch { + // skip malformed lines + } + } + return entries + } catch { + return [] + } + } + + private write(level: LogEntry['level'], category: string, message: string): void { + const entry: LogEntry = { timestamp: Date.now(), level, category, message } + + try { + this.rotateIfNeeded() + appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n') + } catch { + // best-effort + } + + try { + this.window?.webContents.send('app-log', entry) + } catch { + // window may have been destroyed + } + } + + private rotateIfNeeded(): void { + try { + const stats = statSync(LOG_FILE) + if (stats.size > MAX_FILE_SIZE) { + // Keep the second half of the file + const raw = readFileSync(LOG_FILE, 'utf-8') + const lines = raw.split('\n') + const half = Math.floor(lines.length / 2) + writeFileSync(LOG_FILE, lines.slice(half).join('\n')) + } + } catch { + // file doesn't exist yet, that's fine + } + } +} + +export const log = new Logger() diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 7c18787..9f3dc23 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -8,6 +8,7 @@ import { BrowserWindow } from 'electron' import { is } from '@electron-toolkit/utils' import { createFileWatcher, FileWatcher } from './fileWatcher' import { shellQuote } from './shellEscape' +import { log } from './logger' import { ScrollbackBuffer } from './ringBuffer' const DEV_PLUGIN_PATH = join(__dirname, '../../claude-code-plugin') @@ -149,7 +150,7 @@ function spawnClaude( args.push('--prompt', prompt) } - console.log(`[session] spawn: ${getClaudePath()} ${args.join(' ')} cwd=${cwd}`) + log.info('session', `spawn: ${getClaudePath()} ${args.join(' ')} cwd=${cwd}`) return nodePty.spawn(getClaudePath(), args, { name: 'xterm-256color', @@ -178,8 +179,9 @@ export function createSession( const envScript = opts?.envScript ?? detectEnvScript(cwd) const env = envScript ? getProjectEnv(envScript, cwd) : undefined - console.log( - `[session] createSession id=${id} claude=${claudeSessionId} resume=${resume} cwd=${cwd}` + log.info( + 'session', + `createSession id=${id} claude=${claudeSessionId} resume=${resume} cwd=${cwd}` ) const pty = spawnClaude(cwd, claudeSessionId, name, resume, opts?.prompt, env) @@ -202,7 +204,7 @@ export function createSession( }) pty.onExit(({ exitCode }) => { - console.log(`[session] pty-exit id=${id} claude=${claudeSessionId} exitCode=${exitCode}`) + log.info('session', `pty-exit id=${id} claude=${claudeSessionId} exitCode=${exitCode}`) entry.alive = false if (!window.isDestroyed()) { window.webContents.send('pty-exit', { sessionId: id, exitCode }) @@ -299,14 +301,14 @@ export function ensurePluginInstalled(): void { // Add the marketplace (idempotent — no-ops if already added) execFileSync(claude, ['plugin', 'marketplace', 'add', MARKETPLACE_REPO], opts) } catch (err) { - console.warn('[plugin] Failed to add marketplace:', (err as Error).message) + log.warn('plugin', `Failed to add marketplace: ${(err as Error).message}`) } try { // Install the plugin (idempotent — no-ops if already installed) execFileSync(claude, ['plugin', 'install', MARKETPLACE_PLUGIN], opts) } catch (err) { - console.warn('[plugin] Failed to install plugin:', (err as Error).message) + log.warn('plugin', `Failed to install plugin: ${(err as Error).message}`) } } diff --git a/src/main/worktree.ts b/src/main/worktree.ts index 78228eb..12187f5 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { log } from './logger' import { join } from 'path' import { mkdir } from 'fs/promises' import type { WorktreeInfo, BranchDetail, PrInfo, PrState, BranchFile } from '../shared/types' @@ -30,7 +31,7 @@ function ghSafe(args: string[], cwd: string): Promise { return new Promise((resolve) => { execFile('gh', args, { cwd }, (err, stdout) => { if (err) { - console.warn('[gh]', args.slice(0, 3).join(' '), 'failed:', (err as Error).message) + log.warn('gh', `${args.slice(0, 3).join(' ')} failed: ${(err as Error).message}`) resolve('') } else { resolve((stdout || '').trim()) diff --git a/src/preload/index.ts b/src/preload/index.ts index c63f6c3..4beb846 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,7 +6,16 @@ import type { PersistedState } from '../main/store' import type { WorktreeInfo, BranchDetail, BranchFile } from '../shared/types' import type { GitHubRepo, GitHubPR, GitHubIssue } from '../shared/types' -export type UpdateStatus = { status: 'available' | 'ready'; version: string } +export type UpdateStatus = + | { status: 'available' | 'ready'; version: string } + | { status: 'error'; message: string } + +export interface LogEntry { + timestamp: number + level: 'info' | 'warn' | 'error' + category: string + message: string +} export interface KonductorAPI { loadState: () => Promise @@ -51,6 +60,9 @@ export interface KonductorAPI { generateSummary: (cwd: string, claudeSessionId: string) => Promise onUpdateStatus: (cb: (status: UpdateStatus) => void) => () => void installUpdate: () => void + checkForUpdates: () => void + getLogs: () => Promise + onAppLog: (cb: (entry: LogEntry) => void) => () => void onSessionActivity: ( cb: (claudeSessionId: string, state: ActivityState, tool: string, summary: string) => void ) => () => void @@ -169,6 +181,15 @@ const api: KonductorAPI = { return () => ipcRenderer.removeListener('update-status', handler) }, installUpdate: () => ipcRenderer.send('install-update'), + checkForUpdates: () => ipcRenderer.send('check-for-updates'), + getLogs: () => ipcRenderer.invoke('get-logs'), + onAppLog: (cb: (entry: LogEntry) => void) => { + const handler = (_event: Electron.IpcRendererEvent, entry: LogEntry): void => { + cb(entry) + } + ipcRenderer.on('app-log', handler) + return () => ipcRenderer.removeListener('app-log', handler) + }, onSessionActivity: ( cb: (claudeSessionId: string, state: ActivityState, tool: string, summary: string) => void diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c73630a..7f3bccc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import FocusView from './components/FocusView' import WorktreeModal from './components/WorktreeModal' import BranchesView from './components/BranchesView' import GitHubView from './components/GitHubView' +import SettingsView from './components/SettingsView' const savedViewMode = import.meta.hot?.data?.viewMode as ViewMode | undefined @@ -76,6 +77,10 @@ function App(): React.JSX.Element { setViewMode('github') }, []) + const handleShowSettings = useCallback(() => { + setViewMode('settings') + }, []) + const handleOpenBranchSession = useCallback( async (branch: string, isNew: boolean, prompt?: string) => { if (!activeProject || !activeProjectId) return @@ -309,6 +314,7 @@ function App(): React.JSX.Element { onUpdateProject={updateProject} onShowBranches={handleShowBranches} onShowGitHub={handleShowGitHub} + onShowSettings={handleShowSettings} />
@@ -362,6 +368,8 @@ function App(): React.JSX.Element { onOpenSession={handleOpenBranchSession} /> )} + + {effectiveViewMode === 'settings' && setViewMode('grid')} />}
{worktreeProject && ( diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx new file mode 100644 index 0000000..03b811b --- /dev/null +++ b/src/renderer/src/components/SettingsView.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { ChevronLeftIcon, RefreshIcon } from './Icons' +import type { UpdateStatus, LogEntry } from '../../../preload/index' + +const api = window.konductorAPI + +interface SettingsViewProps { + onBack: () => void +} + +const levelColors: Record = { + info: 'text-gray-400', + warn: 'text-yellow-400', + error: 'text-red-400' +} + +const levelBadgeColors: Record = { + info: 'bg-gray-700 text-gray-300', + warn: 'bg-yellow-900/50 text-yellow-400', + error: 'bg-red-900/50 text-red-400' +} + +function formatTime(ts: number): string { + const d = new Date(ts) + return d.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) +} + +export default function SettingsView({ onBack }: SettingsViewProps): React.JSX.Element { + const [updateStatus, setUpdateStatus] = useState(null) + const [checking, setChecking] = useState(false) + const [logs, setLogs] = useState([]) + const [filter, setFilter] = useState('') + const logEndRef = useRef(null) + const logContainerRef = useRef(null) + const isAtBottom = useRef(true) + + useEffect(() => { + return api.onUpdateStatus((status) => { + setUpdateStatus(status) + setChecking(false) + }) + }, []) + + useEffect(() => { + api.getLogs().then(setLogs) + return api.onAppLog((entry) => { + setLogs((prev) => { + const next = [...prev, entry] + return next.length > 500 ? next.slice(-500) : next + }) + }) + }, []) + + useEffect(() => { + if (isAtBottom.current) { + logEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + }, [logs]) + + const handleScroll = useCallback(() => { + const el = logContainerRef.current + if (!el) return + isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 + }, []) + + const handleCheckForUpdates = useCallback(() => { + setChecking(true) + setUpdateStatus(null) + api.checkForUpdates() + }, []) + + const filteredLogs = filter + ? logs.filter( + (l) => + l.category.toLowerCase().includes(filter.toLowerCase()) || + l.message.toLowerCase().includes(filter.toLowerCase()) + ) + : logs + + return ( +
+ {/* Header */} +
+ +

Settings

+
+ +
+ {/* Update Section */} +
+

+ Updates +

+
+
+
+
Konductor v{__APP_VERSION__}
+
+ {updateStatus === null && !checking && 'No update info yet'} + {checking && 'Checking for updates...'} + {updateStatus?.status === 'available' && ( + Downloading v{updateStatus.version}... + )} + {updateStatus?.status === 'ready' && ( + v{updateStatus.version} ready to install + )} + {updateStatus?.status === 'error' && ( + {updateStatus.message} + )} +
+
+
+ {updateStatus?.status === 'ready' ? ( + + ) : ( + + )} +
+
+ + {updateStatus?.status === 'available' && ( +
+
+
+ )} +
+
+ + {/* App Logs Section */} +
+
+

+ App Logs +

+ setFilter(e.target.value)} + className="bg-surface border border-surface-border rounded px-2 py-1 text-[10px] text-gray-300 placeholder-gray-600 w-40 focus:outline-none focus:border-gray-500" + /> +
+
+ {filteredLogs.length === 0 ? ( +
No logs yet
+ ) : ( +
+ {filteredLogs.map((entry, i) => ( +
+ {formatTime(entry.timestamp)} + + {entry.category} + + {entry.message} +
+ ))} +
+
+ )} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 6ef81d3..b772672 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -26,6 +26,7 @@ interface SidebarProps { onUpdateProject: (id: string, updates: Partial) => void onShowBranches: () => void onShowGitHub: () => void + onShowSettings: () => void } export default function Sidebar({ @@ -42,7 +43,8 @@ export default function Sidebar({ onRemoveProject, onUpdateProject, onShowBranches, - onShowGitHub + onShowGitHub, + onShowSettings }: SidebarProps): React.JSX.Element { const [expandedIds, setExpandedIds] = useState>(() => { const initial = new Set() @@ -339,7 +341,29 @@ export default function Sidebar({ + New Project - +
+ + +
@@ -418,15 +442,27 @@ function EnvScriptSection({ } function VersionIndicator(): React.JSX.Element { - const [update, setUpdate] = useState<{ status: 'available' | 'ready'; version: string } | null>( - null - ) + const [update, setUpdate] = useState< + { status: 'available' | 'ready'; version: string } | { status: 'error'; message: string } | null + >(null) useEffect(() => { return window.konductorAPI.onUpdateStatus((info) => setUpdate(info)) }, []) if (update) { + if (update.status === 'error') { + return ( +
+ + Update error +
+ ) + } + const isReady = update.status === 'ready' return (