diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 6f24aed827..b50b420a5d 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -79,6 +79,12 @@ "yes": "Yes", "cancel": "Cancel" }, + "saveLogFile": { + "title": "Save Log File", + "zipFiles": "ZIP Files", + "logFiles": "Log Files", + "allFiles": "All Files" + }, "screenshare": { "title": "Share Your Screen", "announcement": "Select a screen to share" @@ -113,6 +119,11 @@ "message": "We will notify you when the next update is available\nIf you change your mind you can check for updates from the About menu.", "ok": "OK" }, + "selectLogFile": { + "title": "Select Log File", + "logFiles": "Log Files", + "allFiles": "All Files" + }, "selectClientCertificate": { "announcement": "Select Certificate", "select": "Select", @@ -726,6 +737,7 @@ "entries_one": "{{count}} entry", "entries_other": "{{count}} entries", "entriesOfTotal": "{{count}} of {{total}} entries", + "matches": "{{count}} matches", "noEntries": "No entries" }, "buttons": { @@ -738,6 +750,7 @@ "copied": "Copied", "save": "Save", "saved": "Saved", + "showInFolder": "Show in Folder", "retry": "Retry", "dismissError": "Dismiss error", "clear": "Clear all", @@ -792,6 +805,7 @@ }, "messages": { "noLogsFound": "No logs found", + "newEntriesPaused": "{{count}} new entries — click to resume", "adjustFilters": "Try adjusting your filters or refresh the logs", "loadFailed": "Failed to load logs", "saveFailed": "Failed to save logs" diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index 2dbe442df0..52477f6b49 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -123,6 +123,10 @@ type ChannelToArgsMap = { lastModifiedTime?: number; error?: string; }; + 'log-viewer-window/reveal-log-file': (options?: { filePath?: string }) => { + success: boolean; + error?: string; + }; 'log-viewer-window/confirm-clear-logs': () => boolean; 'log-viewer-window/clear-logs': () => { success: boolean; error?: string }; 'log-viewer-window/save-logs': (options: { diff --git a/src/logViewerWindow/LogViewerToolbar.tsx b/src/logViewerWindow/LogViewerToolbar.tsx index 3970943245..ebc2f84eac 100644 --- a/src/logViewerWindow/LogViewerToolbar.tsx +++ b/src/logViewerWindow/LogViewerToolbar.tsx @@ -13,6 +13,7 @@ export type LogViewerToolbarProps = { isStreaming: boolean; onOpenLogFile: () => void; onOpenDefaultLog: () => void; + onRevealLogFile: () => void; onRefresh: () => void; onToggleStreaming: () => void; onCopy: () => void; @@ -28,6 +29,7 @@ export const LogViewerToolbar = ({ isStreaming, onOpenLogFile, onOpenDefaultLog, + onRevealLogFile, onRefresh, onToggleStreaming, onCopy, @@ -48,6 +50,13 @@ export const LogViewerToolbar = ({ aria-label={t('logViewer.buttons.openLogFile')} onClick={onOpenLogFile} /> + {!isDefaultLog && ( = {}): LogEntryType => ({ contextTags: ['main', 'open.rocket.chat'], context: 'main open.rocket.chat', raw: 'raw line', + searchText: 'hello world main open.rocket.chat', + rawLower: 'raw line', ...overrides, }); diff --git a/src/logViewerWindow/__tests__/LogTimeline.spec.tsx b/src/logViewerWindow/__tests__/LogTimeline.spec.tsx index 31fb3312df..7badc20e29 100644 --- a/src/logViewerWindow/__tests__/LogTimeline.spec.tsx +++ b/src/logViewerWindow/__tests__/LogTimeline.spec.tsx @@ -24,6 +24,8 @@ const makeEntry = (offsetMs: number): LogEntryType => ({ context: '', message: 'message', raw: 'raw', + searchText: 'message ', + rawLower: 'raw', }); const entries: LogEntryType[] = Array.from({ length: 20 }, (_unused, index) => diff --git a/src/logViewerWindow/__tests__/ipc.spec.ts b/src/logViewerWindow/__tests__/ipc.spec.ts new file mode 100644 index 0000000000..5af4732dc2 --- /dev/null +++ b/src/logViewerWindow/__tests__/ipc.spec.ts @@ -0,0 +1,115 @@ +import { + countLogEntries, + getLastNEntries, + trimBufferToLastNewline, +} from '../ipc'; + +describe('trimBufferToLastNewline', () => { + it('returns nothing consumed when the buffer has no newline', () => { + const buf = Buffer.from('partial line without newline'); + const { consumed, bytesConsumed } = trimBufferToLastNewline(buf); + expect(bytesConsumed).toBe(0); + expect(consumed.length).toBe(0); + }); + + it('consumes up to and including the last newline mid-buffer', () => { + const buf = Buffer.from('line one\nline two\npartial line three'); + const { consumed, bytesConsumed } = trimBufferToLastNewline(buf); + + expect(consumed.toString('utf-8')).toBe('line one\nline two\n'); + expect(bytesConsumed).toBe(Buffer.byteLength('line one\nline two\n')); + expect(buf.subarray(bytesConsumed).toString('utf-8')).toBe( + 'partial line three' + ); + }); + + it('consumes the entire buffer when it ends with a newline', () => { + const buf = Buffer.from('line one\nline two\n'); + const { consumed, bytesConsumed } = trimBufferToLastNewline(buf); + + expect(bytesConsumed).toBe(buf.length); + expect(consumed.equals(buf)).toBe(true); + }); + + it('does not split a multi-byte UTF-8 character across the cut point', () => { + // "café" ends with a 2-byte UTF-8 char (é = 0xC3 0xA9); make sure the + // cut happens strictly after a newline, never inside a multi-byte char. + const completeLine = 'café logged\n'; + const partialMultiByteTail = 'café'; // "café" without trailing newline + const buf = Buffer.concat([ + Buffer.from(completeLine, 'utf-8'), + Buffer.from(partialMultiByteTail, 'utf-8'), + ]); + + const { consumed, bytesConsumed } = trimBufferToLastNewline(buf); + + expect(bytesConsumed).toBe(Buffer.byteLength(completeLine, 'utf-8')); + expect(consumed.toString('utf-8')).toBe(completeLine); + + const remainder = buf.subarray(bytesConsumed); + expect(remainder.toString('utf-8')).toBe(partialMultiByteTail); + }); + + it('returns an empty consumed buffer for an empty input', () => { + const { consumed, bytesConsumed } = trimBufferToLastNewline( + Buffer.alloc(0) + ); + expect(bytesConsumed).toBe(0); + expect(consumed.length).toBe(0); + }); +}); + +describe('getLastNEntries', () => { + const buildLog = (count: number): string => + Array.from( + { length: count }, + (_, i) => + `[2024-01-01 10:00:${String(i).padStart(2, '0')}.000] [info] [main] Entry ${i}` + ).join('\n'); + + it('returns all content and correct total when limit exceeds entry count', () => { + const log = buildLog(3); + const { content, totalEntries } = getLastNEntries(log, 10); + expect(totalEntries).toBe(3); + expect(content).toBe(log); + }); + + it('returns only the last N entries', () => { + const log = buildLog(5); + const { content, totalEntries } = getLastNEntries(log, 2); + expect(totalEntries).toBe(5); + expect(content.split('\n')).toHaveLength(2); + expect(content).toContain('Entry 3'); + expect(content).toContain('Entry 4'); + expect(content).not.toContain('Entry 2'); + }); + + it('returns empty content and zero entries for a non-positive limit', () => { + const { content, totalEntries } = getLastNEntries(buildLog(3), 0); + expect(content).toBe(''); + expect(totalEntries).toBe(0); + }); + + it('falls back to the last N lines when no entries match the log pattern', () => { + const content = 'plain line 1\nplain line 2\nplain line 3'; + const result = getLastNEntries(content, 2); + expect(result.totalEntries).toBe(0); + expect(result.content).toBe('plain line 2\nplain line 3'); + }); +}); + +describe('countLogEntries', () => { + it('counts entry-start lines and ignores continuation lines', () => { + const content = [ + '[2024-01-01 10:00:00.000] [info] [main] First', + ' continuation line', + '[2024-01-01 10:00:01.000] [warn] [main] Second', + ].join('\n'); + + expect(countLogEntries(content)).toBe(2); + }); + + it('returns 0 for content with no matching entries', () => { + expect(countLogEntries('just some text\nanother line')).toBe(0); + }); +}); diff --git a/src/logViewerWindow/__tests__/parseLogs.spec.ts b/src/logViewerWindow/__tests__/parseLogs.spec.ts index 89aa9c34a8..ddb1e9bc6c 100644 --- a/src/logViewerWindow/__tests__/parseLogs.spec.ts +++ b/src/logViewerWindow/__tests__/parseLogs.spec.ts @@ -86,6 +86,30 @@ describe('parseLogLines', () => { expect(entry.level).toBe('info'); }); + + it('precomputes searchText and rawLower', () => { + const [entry] = parseLogLines( + '[2026-08-07 10:00:00.000] [info] [Main] [Server-1] HELLO World' + ); + + expect(entry.searchText).toBe( + `${entry.message} ${entry.context}`.toLowerCase() + ); + expect(entry.rawLower).toBe(entry.raw.toLowerCase()); + expect(entry.contextTags).toEqual(['Main', 'Server-1']); + }); + + it('updates searchText when continuation lines are folded in', () => { + const [entry] = parseLogLines( + [ + '[2026-08-07 10:00:00.000] [error] [main] Boom', + ' at foo (foo.js:1:1)', + ].join('\n') + ); + + expect(entry.searchText).toContain('at foo'); + expect(entry.rawLower).toContain('at foo'); + }); }); describe('timestamp helpers', () => { diff --git a/src/logViewerWindow/__tests__/textHighlight.spec.ts b/src/logViewerWindow/__tests__/textHighlight.spec.ts new file mode 100644 index 0000000000..e070fb96a4 --- /dev/null +++ b/src/logViewerWindow/__tests__/textHighlight.spec.ts @@ -0,0 +1,83 @@ +import { + MAX_COLLAPSED_MESSAGE_LINES, + splitMessageForCollapse, + splitTextForHighlight, +} from '../textHighlight'; + +describe('splitTextForHighlight', () => { + it('returns a single unmatched segment when query is empty', () => { + expect(splitTextForHighlight('hello world', '')).toEqual([ + { text: 'hello world', matched: false }, + ]); + }); + + it('returns a single unmatched segment when there is no match', () => { + expect(splitTextForHighlight('hello world', 'xyz')).toEqual([ + { text: 'hello world', matched: false }, + ]); + }); + + it('splits and marks a single case-insensitive match', () => { + const result = splitTextForHighlight('Hello World', 'world'); + expect(result).toEqual([ + { text: 'Hello ', matched: false }, + { text: 'World', matched: true }, + ]); + }); + + it('marks all occurrences of the query', () => { + const result = splitTextForHighlight('foo bar foo', 'foo'); + expect(result).toEqual([ + { text: 'foo', matched: true }, + { text: ' bar ', matched: false }, + { text: 'foo', matched: true }, + ]); + }); + + it('escapes regex special characters in the query', () => { + const result = splitTextForHighlight('a.b*c', '.b*'); + expect(result).toEqual([ + { text: 'a', matched: false }, + { text: '.b*', matched: true }, + { text: 'c', matched: false }, + ]); + }); +}); + +describe('splitMessageForCollapse', () => { + it('returns all lines with zero hidden count when under the threshold', () => { + const message = Array.from({ length: 3 }, (_, i) => `line ${i}`).join('\n'); + const { visibleLines, hiddenLineCount } = splitMessageForCollapse(message); + expect(visibleLines).toHaveLength(3); + expect(hiddenLineCount).toBe(0); + }); + + it('returns exactly the threshold with zero hidden count at the boundary', () => { + const message = Array.from( + { length: MAX_COLLAPSED_MESSAGE_LINES }, + (_, i) => `line ${i}` + ).join('\n'); + const { visibleLines, hiddenLineCount } = splitMessageForCollapse(message); + expect(visibleLines).toHaveLength(MAX_COLLAPSED_MESSAGE_LINES); + expect(hiddenLineCount).toBe(0); + }); + + it('truncates to the threshold and reports the hidden count when over it', () => { + const totalLines = MAX_COLLAPSED_MESSAGE_LINES + 4; + const message = Array.from( + { length: totalLines }, + (_, i) => `line ${i}` + ).join('\n'); + const { visibleLines, hiddenLineCount } = splitMessageForCollapse(message); + expect(visibleLines).toHaveLength(MAX_COLLAPSED_MESSAGE_LINES); + expect(visibleLines).toEqual([ + 'line 0', + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + ]); + expect(hiddenLineCount).toBe(4); + }); +}); diff --git a/src/logViewerWindow/__tests__/timeline.spec.ts b/src/logViewerWindow/__tests__/timeline.spec.ts index 7453a82635..0aaf08e14b 100644 --- a/src/logViewerWindow/__tests__/timeline.spec.ts +++ b/src/logViewerWindow/__tests__/timeline.spec.ts @@ -14,6 +14,8 @@ const entry = (timestamp: string, level: LogLevel = 'info'): LogEntryType => ({ context: 'main', message: 'message', raw: `[${timestamp}] [${level}] [main] message`, + searchText: 'message main', + rawLower: `[${timestamp}] [${level}] [main] message`.toLowerCase(), }); describe('buildTimeline', () => { diff --git a/src/logViewerWindow/__tests__/types.spec.ts b/src/logViewerWindow/__tests__/types.spec.ts index c015a31a71..bb581dc657 100644 --- a/src/logViewerWindow/__tests__/types.spec.ts +++ b/src/logViewerWindow/__tests__/types.spec.ts @@ -1,4 +1,4 @@ -import { isLogLevel, parseLogLevel } from '../types'; +import { isAtLeastLevel, isLogLevel, parseLogLevel } from '../types'; describe('logViewerWindow types', () => { it('recognizes valid log levels', () => { @@ -20,3 +20,30 @@ describe('logViewerWindow types', () => { expect(parseLogLevel(42)).toBe('info'); }); }); + +describe('isAtLeastLevel', () => { + it('orders levels silly < verbose < debug < info < warn < error', () => { + expect(isAtLeastLevel('silly', 'silly')).toBe(true); + expect(isAtLeastLevel('verbose', 'silly')).toBe(true); + expect(isAtLeastLevel('debug', 'verbose')).toBe(true); + expect(isAtLeastLevel('info', 'debug')).toBe(true); + expect(isAtLeastLevel('warn', 'info')).toBe(true); + expect(isAtLeastLevel('error', 'warn')).toBe(true); + }); + + it('returns false when level is below the minimum', () => { + expect(isAtLeastLevel('silly', 'error')).toBe(false); + expect(isAtLeastLevel('debug', 'warn')).toBe(false); + expect(isAtLeastLevel('info', 'error')).toBe(false); + }); + + it('returns true when level equals the minimum', () => { + expect(isAtLeastLevel('warn', 'warn')).toBe(true); + expect(isAtLeastLevel('error', 'error')).toBe(true); + }); + + it('treats error as the highest severity, matching only itself and above', () => { + expect(isAtLeastLevel('error', 'error')).toBe(true); + expect(isAtLeastLevel('warn', 'error')).toBe(false); + }); +}); diff --git a/src/logViewerWindow/constants.ts b/src/logViewerWindow/constants.ts index 85fbe5fb03..d2d6e71eee 100644 --- a/src/logViewerWindow/constants.ts +++ b/src/logViewerWindow/constants.ts @@ -33,3 +33,6 @@ export const PAGE_SIZE = 100; /** Time slices drawn in the distribution timeline, and the plot's height */ export const TIMELINE_BUCKET_COUNT = 96; export const TIMELINE_PLOT_HEIGHT = 40; + +/** Window during which scroll events are ignored after a programmatic scroll */ +export const AUTO_SCROLL_GUARD_MS = 150; diff --git a/src/logViewerWindow/ipc.ts b/src/logViewerWindow/ipc.ts index a93cee517a..bb158e4349 100644 --- a/src/logViewerWindow/ipc.ts +++ b/src/logViewerWindow/ipc.ts @@ -1,10 +1,9 @@ import fs, { createWriteStream } from 'fs'; import path from 'path'; -import { promisify } from 'util'; import archiver from 'archiver'; import type { Event } from 'electron'; -import { app, BrowserWindow, screen, dialog } from 'electron'; +import { app, BrowserWindow, screen, dialog, shell } from 'electron'; import i18next from 'i18next'; import { packageJsonInformation } from '../app/main/app'; @@ -35,8 +34,19 @@ const t = i18next.t.bind(i18next); const isMac = process.platform === 'darwin'; -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); +const { readFile, writeFile, mkdir, stat } = fs.promises; + +const pathExists = async (targetPath: string): Promise => { + try { + await stat(targetPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +}; let logViewerWindow: BrowserWindow | null = null; const allowedLogPaths = new Set(); @@ -80,7 +90,18 @@ const validateLogFilePath = ( const LOG_ENTRY_REGEX = /^\[([^\]]+)\]\s+\[([^\]]+)\]/; -const getLastNEntries = ( +export const countLogEntries = (content: string): number => { + const lines = content.split(/\r?\n/); + let count = 0; + lines.forEach((line) => { + if (LOG_ENTRY_REGEX.test(line)) { + count += 1; + } + }); + return count; +}; + +export const getLastNEntries = ( content: string, limit: number ): { content: string; totalEntries: number } => { @@ -112,6 +133,21 @@ const getLastNEntries = ( }; }; +const NEWLINE_BYTE = 0x0a; + +export const trimBufferToLastNewline = ( + buf: Buffer +): { consumed: Buffer; bytesConsumed: number } => { + const lastNewlineIndex = buf.lastIndexOf(NEWLINE_BYTE); + + if (lastNewlineIndex === -1) { + return { consumed: Buffer.alloc(0), bytesConsumed: 0 }; + } + + const bytesConsumed = lastNewlineIndex + 1; + return { consumed: buf.subarray(0, bytesConsumed), bytesConsumed }; +}; + /** Set while a window is being built; see `createLogViewerWindow`. */ let pendingCreation: Promise | null = null; @@ -287,10 +323,13 @@ export const startLogViewerWindowHandler = (): void => { } const result = await dialog.showOpenDialog(logViewerWindow, { - title: 'Select Log File', + title: t('dialog.selectLogFile.title'), filters: [ - { name: 'Log Files', extensions: ['log', 'txt'] }, - { name: 'All Files', extensions: ['*'] }, + { + name: t('dialog.selectLogFile.logFiles'), + extensions: ['log', 'txt'], + }, + { name: t('dialog.selectLogFile.allFiles'), extensions: ['*'] }, ], properties: ['openFile'], }); @@ -345,11 +384,11 @@ export const startLogViewerWindowHandler = (): void => { } const limit = options?.limit; - if (!fs.existsSync(logPath)) { + if (!(await pathExists(logPath))) { if (!options?.filePath) { const logDir = path.dirname(logPath); - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); + if (!(await pathExists(logDir))) { + await mkdir(logDir, { recursive: true }); } await writeFile(logPath, ''); } else { @@ -366,13 +405,14 @@ export const startLogViewerWindowHandler = (): void => { if (limit === 'all' || !limit) { logContent = fileContent; + totalEntries = countLogEntries(fileContent); } else { const result = getLastNEntries(fileContent, limit); logContent = result.content; totalEntries = result.totalEntries; } - const stats = fs.statSync(logPath); + const stats = await stat(logPath); const lastModifiedTime = stats.mtime.getTime(); return { @@ -419,11 +459,11 @@ export const startLogViewerWindowHandler = (): void => { logPath = getLogFilePath(); } - if (!fs.existsSync(logPath)) { + if (!(await pathExists(logPath))) { return { success: false, error: 'Log file does not exist' }; } - const stats = fs.statSync(logPath); + const stats = await stat(logPath); return { success: true, lastModifiedTime: stats.mtime.getTime(), @@ -463,11 +503,11 @@ export const startLogViewerWindowHandler = (): void => { logPath = getLogFilePath(); } - if (!fs.existsSync(logPath)) { + if (!(await pathExists(logPath))) { return { success: false, error: 'Log file does not exist' }; } - const stats = fs.statSync(logPath); + const stats = await stat(logPath); const rawFromByte = Number(options.fromByte); const fromByte = Number.isFinite(rawFromByte) && rawFromByte >= 0 @@ -478,7 +518,7 @@ export const startLogViewerWindowHandler = (): void => { return { success: true, logs: '', - newSize: stats.size, + newSize: fromByte, lastModifiedTime: stats.mtime.getTime(), }; } @@ -494,12 +534,24 @@ export const startLogViewerWindowHandler = (): void => { stream.on('error', (err) => reject(err)); }); - const newContent = Buffer.concat(chunks).toString('utf-8'); + const rawChunk = Buffer.concat(chunks); + const { consumed, bytesConsumed } = trimBufferToLastNewline(rawChunk); + + if (bytesConsumed === 0) { + return { + success: true, + logs: '', + newSize: fromByte, + lastModifiedTime: stats.mtime.getTime(), + }; + } + + const newContent = consumed.toString('utf-8'); return { success: true, logs: newContent, - newSize: stats.size, + newSize: fromByte + bytesConsumed, lastModifiedTime: stats.mtime.getTime(), }; } catch (error) { @@ -509,6 +561,46 @@ export const startLogViewerWindowHandler = (): void => { } ); + handle( + 'log-viewer-window/reveal-log-file', + async (_, options?: { filePath?: string }) => { + try { + let logPath: string; + if (options?.filePath) { + const validation = validateLogFilePath(options.filePath); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + const normalizedPath = path.normalize(options.filePath); + const defaultLogPath = path.normalize(getLogFilePath()); + if ( + normalizedPath !== defaultLogPath && + !allowedLogPaths.has(normalizedPath) + ) { + return { + success: false, + error: + 'Log file not authorized. Please select it via the file dialog first.', + }; + } + logPath = normalizedPath; + } else { + logPath = getLogFilePath(); + } + + if (!(await pathExists(logPath))) { + return { success: false, error: 'Log file does not exist' }; + } + + shell.showItemInFolder(logPath); + return { success: true }; + } catch (error) { + console.error('Failed to reveal log file:', error); + return { success: false, error: (error as Error).message }; + } + } + ); + handle('log-viewer-window/confirm-clear-logs', async () => { if (!logViewerWindow || logViewerWindow.isDestroyed()) { return false; @@ -546,11 +638,12 @@ export const startLogViewerWindowHandler = (): void => { } const result = await dialog.showSaveDialog(logViewerWindow, { - title: 'Save Log File', + title: t('dialog.saveLogFile.title'), defaultPath: options.defaultFileName, filters: [ - { name: 'ZIP Files', extensions: ['zip'] }, - { name: 'All Files', extensions: ['*'] }, + { name: t('dialog.saveLogFile.zipFiles'), extensions: ['zip'] }, + { name: t('dialog.saveLogFile.logFiles'), extensions: ['log'] }, + { name: t('dialog.saveLogFile.allFiles'), extensions: ['*'] }, ], }); @@ -558,6 +651,14 @@ export const startLogViewerWindowHandler = (): void => { return { success: false, canceled: true }; } + if (result.filePath.toLowerCase().endsWith('.log')) { + await writeFile(result.filePath, options.content, 'utf-8'); + return { + success: true, + filePath: result.filePath, + }; + } + await new Promise((resolve, reject) => { const output = createWriteStream(result.filePath!); const archive = archiver('zip', { diff --git a/src/logViewerWindow/logFormatters.ts b/src/logViewerWindow/logFormatters.ts index 945e50e743..e02f94fa58 100644 --- a/src/logViewerWindow/logFormatters.ts +++ b/src/logViewerWindow/logFormatters.ts @@ -13,6 +13,13 @@ export const formatFileSize = (bytes: number): string => { return `${mb.toFixed(1)} MB`; }; +const buildEntryDerivedFields = ( + entry: Pick +): Pick => ({ + searchText: `${entry.message} ${entry.context}`.toLowerCase(), + rawLower: entry.raw.toLowerCase(), +}); + export const parseLogLines = (logText: string): LogEntryType[] => { if (!logText || logText.trim() === '') { return []; @@ -38,18 +45,29 @@ export const parseLogLines = (logText: string): LogEntryType[] => { entries.push(currentEntry); } + const parsedMessage = message.trim(); + const context = contextTags.join(' '); + currentEntry = { id: `log-${entries.length}`, timestamp, level: parseLogLevel(level), contextTags, - context: contextTags.join(' '), - message: message.trim(), + context, + message: parsedMessage, raw: line, + ...buildEntryDerivedFields({ + message: parsedMessage, + context, + raw: line, + }), }; } else if (currentEntry && line.trim()) { currentEntry.message += `\n${line}`; currentEntry.raw += `\n${line}`; + const derived = buildEntryDerivedFields(currentEntry); + currentEntry.searchText = derived.searchText; + currentEntry.rawLower = derived.rawLower; } }); diff --git a/src/logViewerWindow/logViewerWindow.tsx b/src/logViewerWindow/logViewerWindow.tsx index 7481296a0c..ed30e4c5cf 100644 --- a/src/logViewerWindow/logViewerWindow.tsx +++ b/src/logViewerWindow/logViewerWindow.tsx @@ -2,6 +2,7 @@ import { Box, Button, Callout, + Icon, IconButton, States, StatesAction, @@ -46,6 +47,7 @@ import { LOG_LEVELS } from './appearance'; import { TRANSPARENCY_CHANNEL, AUTO_REFRESH_INTERVAL_MS, + AUTO_SCROLL_GUARD_MS, PAGE_SIZE, SCROLL_DELAY_MS, SEARCH_DEBOUNCE_MS, @@ -134,8 +136,11 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { const lastModifiedTimeRef = useRef(undefined); const lastKnownSizeRef = useRef(0); const isAutoScrollingRef = useRef(false); + const lastAutoScrollAtRef = useRef(0); + const isSuspendedRef = useRef(false); const parseGenerationRef = useRef(0); const loadRequestIdRef = useRef(0); + const [pendingNewEntryCount, setPendingNewEntryCount] = useState(0); const [expandedEntryIds, setExpandedEntryIds] = useState>( () => new Set() ); @@ -349,11 +354,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { const matchesSearch = useCallback( (entry: LogEntryType): boolean => { if (!debouncedSearchFilter) return true; - const needle = debouncedSearchFilter.toLowerCase(); - return ( - entry.message.toLowerCase().includes(needle) || - entry.context.toLowerCase().includes(needle) - ); + return entry.searchText.includes(debouncedSearchFilter.toLowerCase()); }, [debouncedSearchFilter] ); @@ -691,6 +692,10 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { return [...newEntries, ...prev]; }); + if (isSuspendedRef.current) { + setPendingNewEntryCount((prev) => prev + newEntries.length); + } + setFileInfo((prev) => prev ? { @@ -728,6 +733,13 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { } }, [autoScroll]); + useEffect(() => { + isSuspendedRef.current = autoScroll && userHasScrolled; + if (!isSuspendedRef.current) { + setPendingNewEntryCount(0); + } + }, [autoScroll, userHasScrolled]); + useEffect(() => { if ( autoScroll && @@ -737,6 +749,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { ) { const timeoutId = setTimeout(() => { isAutoScrollingRef.current = true; + lastAutoScrollAtRef.current = Date.now(); if (virtuosoRef.current && autoScroll && !userHasScrolled) { virtuosoRef.current.scrollToIndex({ index: 0, @@ -753,11 +766,23 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { const handleScroll = useCallback(() => { if (isAutoScrollingRef.current) return; + if (Date.now() - lastAutoScrollAtRef.current < AUTO_SCROLL_GUARD_MS) return; if (autoScroll && !userHasScrolled) { setUserHasScrolled(true); } }, [autoScroll, userHasScrolled]); + const handleResumeAutoScroll = useCallback(() => { + setUserHasScrolled(false); + setPendingNewEntryCount(0); + if (virtuosoRef.current) { + isAutoScrollingRef.current = true; + lastAutoScrollAtRef.current = Date.now(); + virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'smooth' }); + isAutoScrollingRef.current = false; + } + }, []); + const handleCopyEntry = useCallback((entry: LogEntryType) => { navigator.clipboard.writeText(entry.raw).catch((error) => { console.error('Failed to copy entry to clipboard:', error); @@ -843,6 +868,24 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { }); }, []); + const handleRevealLogFile = useCallback(async () => { + try { + const response = (await ipcRenderer.invoke( + 'log-viewer-window/reveal-log-file', + { + filePath: currentLogFile.isDefaultLog + ? undefined + : currentLogFile.filePath, + } + )) as { success: boolean; error?: string }; + if (!response?.success) { + console.error('Failed to reveal log file:', response?.error); + } + } catch (error) { + console.error('Failed to reveal log file:', error); + } + }, [currentLogFile.filePath, currentLogFile.isDefaultLog]); + const handleRefresh = useCallback(() => { loadLogs(); }, [loadLogs]); @@ -965,6 +1008,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { isStreaming={isStreaming} onOpenLogFile={handleOpenLogFile} onOpenDefaultLog={handleOpenDefaultLog} + onRevealLogFile={handleRevealLogFile} onRefresh={handleRefresh} onToggleStreaming={handleToggleStreaming} onCopy={handleCopyLogs} @@ -1111,20 +1155,57 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { )} {!loadError && visibleLogs.length > 0 && ( - entry?.id ?? `day-${index}`} - itemContent={renderLogEntry} - overscan={VIRTUOSO_OVERSCAN} - style={{ height: '100%', width: '100%' }} - onScroll={handleScroll} - endReached={handleEndReached} - /> + + {autoScroll && + userHasScrolled && + pendingNewEntryCount > 0 && ( + + + + {t('logViewer.messages.newEntriesPaused', { + count: pendingNewEntryCount, + })} + + + )} + + entry?.id ?? `day-${index}` + } + itemContent={renderLogEntry} + overscan={VIRTUOSO_OVERSCAN} + style={{ height: '100%', width: '100%' }} + onScroll={handleScroll} + endReached={handleEndReached} + /> + )} diff --git a/src/logViewerWindow/main/ipc.main.spec.ts b/src/logViewerWindow/main/ipc.main.spec.ts index 4e90ea34f4..cbf74afba0 100644 --- a/src/logViewerWindow/main/ipc.main.spec.ts +++ b/src/logViewerWindow/main/ipc.main.spec.ts @@ -26,8 +26,10 @@ jest.mock('fs', () => { promises: { readFile: jest.fn(async () => logContent), writeFile: jest.fn(async () => undefined), + mkdir: jest.fn(async () => undefined), stat: jest.fn(async () => ({ size: 128, + mtime: new Date('2026-01-01T00:00:02.000Z'), mtimeMs: Date.parse('2026-01-01T00:00:02.000Z'), })), }, diff --git a/src/logViewerWindow/parseLogs.ts b/src/logViewerWindow/parseLogs.ts index 2e0b637e44..3d838fe729 100644 --- a/src/logViewerWindow/parseLogs.ts +++ b/src/logViewerWindow/parseLogs.ts @@ -4,6 +4,13 @@ const LOG_LINE_REGEX = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s*(.*)$/; const CONTEXT_RUN_REGEX = /^((?:\[[^\]]*\]\s*)+)(.*)$/; const CONTEXT_TAG_REGEX = /\[([^\]]*)\]/g; +const buildEntryDerivedFields = ( + entry: Pick +): Pick => ({ + searchText: `${entry.message} ${entry.context}`.toLowerCase(), + rawLower: entry.raw.toLowerCase(), +}); + /** * Parse `[timestamp] [level] [tag] [tag] message` lines into entries, newest * first. Lines that do not open a new entry are folded into the previous @@ -46,18 +53,29 @@ export const parseLogLines = ( entries.push(currentEntry); } + const parsedMessage = message.trim(); + const context = contextTags.join(' '); + currentEntry = { id: `${idPrefix}-${entries.length}`, timestamp, level: parseLogLevel(level), contextTags, - context: contextTags.join(' '), - message: message.trim(), + context, + message: parsedMessage, raw: line, + ...buildEntryDerivedFields({ + message: parsedMessage, + context, + raw: line, + }), }; } else if (currentEntry && line.trim()) { currentEntry.message += `\n${line}`; currentEntry.raw += `\n${line}`; + const derived = buildEntryDerivedFields(currentEntry); + currentEntry.searchText = derived.searchText; + currentEntry.rawLower = derived.rawLower; } }); diff --git a/src/logViewerWindow/textHighlight.ts b/src/logViewerWindow/textHighlight.ts new file mode 100644 index 0000000000..5d589c14b2 --- /dev/null +++ b/src/logViewerWindow/textHighlight.ts @@ -0,0 +1,49 @@ +export interface ITextSegment { + text: string; + matched: boolean; +} + +export type TextSegment = ITextSegment; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const splitTextForHighlight = ( + text: string, + query: string +): TextSegment[] => { + if (!query) { + return [{ text, matched: false }]; + } + + const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + const parts = text.split(regex); + + if (parts.length === 1) { + return [{ text, matched: false }]; + } + + const lowerQuery = query.toLowerCase(); + return parts + .filter((part) => part !== '') + .map((part) => ({ + text: part, + matched: part.toLowerCase() === lowerQuery, + })); +}; + +export const MAX_COLLAPSED_MESSAGE_LINES = 6; + +export const splitMessageForCollapse = ( + message: string +): { visibleLines: string[]; hiddenLineCount: number } => { + const lines = message.split('\n'); + if (lines.length <= MAX_COLLAPSED_MESSAGE_LINES) { + return { visibleLines: lines, hiddenLineCount: 0 }; + } + + return { + visibleLines: lines.slice(0, MAX_COLLAPSED_MESSAGE_LINES), + hiddenLineCount: lines.length - MAX_COLLAPSED_MESSAGE_LINES, + }; +}; diff --git a/src/logViewerWindow/types.ts b/src/logViewerWindow/types.ts index 3698e970ca..a778667f1d 100644 --- a/src/logViewerWindow/types.ts +++ b/src/logViewerWindow/types.ts @@ -16,6 +16,10 @@ export interface ILogEntryType { context: string; message: string; raw: string; + /** Lowercased `message` + `context` for filter matching without reallocating. */ + searchText: string; + /** Lowercased `raw` for full-entry search without reallocating. */ + rawLower: string; } export interface IReadLogsResponse { @@ -84,3 +88,15 @@ export const parseLogLevel = (value: unknown): LogLevel => { const trimmed = value.trim().toLowerCase(); return isLogLevel(trimmed) ? trimmed : 'info'; }; + +const LOG_LEVEL_ORDER: Record = { + silly: 0, + verbose: 1, + debug: 2, + info: 3, + warn: 4, + error: 5, +}; + +export const isAtLeastLevel = (level: LogLevel, minLevel: LogLevel): boolean => + LOG_LEVEL_ORDER[level] >= LOG_LEVEL_ORDER[minLevel];