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];