Skip to content

Commit 84ff98b

Browse files
ozgesolidkeyclaude
andcommitted
Add column filtering, highlight groups, and window controls
- Column filter utility for search/save operations with visible columns - Highlight group CRUD: save, load, delete groups via ~/.logan/highlight-groups.json - Frameless window with native traffic lights (macOS) and custom controls - Window minimize/maximize/close IPC handlers - Bookmark lineText field for storing line content - Updated saveSelectedLines and saveToNotes to support column config Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e36baed commit 84ff98b

5 files changed

Lines changed: 200 additions & 55 deletions

File tree

src/main/fileHandler.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,27 @@ import * as readline from 'readline';
33
import { spawn } from 'child_process';
44
import { FileInfo, LineData, SearchMatch, SearchOptions } from '../shared/types';
55

6+
// Shared column filter utility — filters a line to only visible columns
7+
export interface ColumnConfig {
8+
delimiter: string;
9+
columns: Array<{ index: number; visible: boolean }>;
10+
}
11+
12+
export function filterLineToVisibleColumns(
13+
line: string,
14+
columnConfig: ColumnConfig | undefined
15+
): string {
16+
if (!columnConfig) return line;
17+
if (!columnConfig.columns.some(c => !c.visible)) return line;
18+
19+
const { delimiter, columns } = columnConfig;
20+
const parts = delimiter === ' ' ? line.split(/\s+/) : line.split(delimiter);
21+
const visibleParts = parts.filter((_, idx) =>
22+
idx < columns.length ? columns[idx].visible : true
23+
);
24+
return visibleParts.join(delimiter === ' ' ? ' ' : delimiter);
25+
}
26+
627
// Check if ripgrep is available
728
let ripgrepAvailable: boolean | null = null;
829
async function checkRipgrep(): Promise<boolean> {
@@ -295,13 +316,21 @@ export class FileHandler {
295316
return this.searchWithStream(options, onProgress, signal);
296317
}
297318

319+
// NOTE: searchWithRipgrep does NOT support column filtering.
320+
// It searches raw file text. When columns are hidden, the caller (search())
321+
// must route to searchWithStream() instead, which applies column filtering.
298322
private async searchWithRipgrep(
299323
options: SearchOptions,
300324
onProgress?: (percent: number, matchCount: number) => void,
301325
signal?: { cancelled: boolean }
302326
): Promise<SearchMatch[]> {
303327
if (!this.filePath) return [];
304328

329+
// Defensive: refuse to run if column filtering is active
330+
if (options.columnConfig && options.columnConfig.columns.some(c => !c.visible)) {
331+
return this.searchWithStream(options, onProgress, signal);
332+
}
333+
305334
const matches: SearchMatch[] = [];
306335
const MAX_MATCHES = 50000;
307336

@@ -413,28 +442,12 @@ export class FileHandler {
413442
});
414443
}
415444

416-
// Helper to filter line to visible columns
445+
// Helper to filter line to visible columns (delegates to shared utility)
417446
private filterLineToVisibleColumns(
418447
line: string,
419448
columnConfig: SearchOptions['columnConfig']
420449
): string {
421-
if (!columnConfig) return line;
422-
423-
const { delimiter, columns } = columnConfig;
424-
let parts: string[];
425-
426-
if (delimiter === ' ') {
427-
parts = line.split(/\s+/);
428-
} else {
429-
parts = line.split(delimiter);
430-
}
431-
432-
// Build filtered text with only visible columns
433-
const visibleParts = parts.filter((_, idx) => {
434-
return idx < columns.length ? columns[idx].visible : true;
435-
});
436-
437-
return visibleParts.join(delimiter === ' ' ? ' ' : delimiter);
450+
return filterLineToVisibleColumns(line, columnConfig);
438451
}
439452

440453
private async searchWithStream(

src/main/index.ts

Lines changed: 127 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import * as fs from 'fs';
44
import * as os from 'os';
55
import { spawn } from 'child_process';
66
import * as pty from 'node-pty';
7-
import { FileHandler } from './fileHandler';
8-
import { IPC, SearchOptions, Bookmark, Highlight } from '../shared/types';
7+
import { FileHandler, filterLineToVisibleColumns, ColumnConfig } from './fileHandler';
8+
import { IPC, SearchOptions, Bookmark, Highlight, HighlightGroup } from '../shared/types';
99
import { analyzerRegistry, AnalyzerOptions } from './analyzers';
1010

1111
let mainWindow: BrowserWindow | null = null;
@@ -47,6 +47,7 @@ const highlights = new Map<string, Highlight>();
4747
// Config folder path (~/.logan/)
4848
const getConfigDir = () => path.join(os.homedir(), '.logan');
4949
const getHighlightsPath = () => path.join(getConfigDir(), 'highlights.json');
50+
const getHighlightGroupsPath = () => path.join(getConfigDir(), 'highlight-groups.json');
5051
const getBookmarksPath = () => path.join(getConfigDir(), 'bookmarks.json');
5152

5253
// Ensure config directory exists
@@ -235,9 +236,17 @@ function saveBookmarksForCurrentFile(): void {
235236
}
236237

237238
function createWindow() {
239+
const isMac = process.platform === 'darwin';
240+
238241
mainWindow = new BrowserWindow({
239242
width: 1400,
240243
height: 900,
244+
show: false,
245+
frame: false,
246+
...(isMac ? {
247+
titleBarStyle: 'hidden',
248+
trafficLightPosition: { x: 10, y: 6 },
249+
} : {}),
241250
webPreferences: {
242251
nodeIntegration: false,
243252
contextIsolation: true,
@@ -248,6 +257,10 @@ function createWindow() {
248257

249258
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
250259

260+
mainWindow.once('ready-to-show', () => {
261+
mainWindow?.show();
262+
});
263+
251264
mainWindow.on('closed', () => {
252265
mainWindow = null;
253266
// Close all cached file handlers
@@ -275,6 +288,28 @@ app.on('window-all-closed', () => {
275288
}
276289
});
277290

291+
// === Window Controls ===
292+
293+
ipcMain.handle('window-minimize', () => {
294+
mainWindow?.minimize();
295+
});
296+
297+
ipcMain.handle('window-maximize', () => {
298+
if (mainWindow?.isMaximized()) {
299+
mainWindow.unmaximize();
300+
} else {
301+
mainWindow?.maximize();
302+
}
303+
});
304+
305+
ipcMain.handle('window-close', () => {
306+
mainWindow?.close();
307+
});
308+
309+
ipcMain.handle('get-platform', () => {
310+
return process.platform;
311+
});
312+
278313
// === File Operations ===
279314

280315
ipcMain.handle(IPC.OPEN_FILE_DIALOG, async () => {
@@ -859,10 +894,8 @@ ipcMain.handle('export-bookmarks', async () => {
859894
.sort((a, b) => a.lineNumber - b.lineNumber);
860895

861896
for (const bookmark of sortedBookmarks) {
862-
// Get the line text
863-
const [lineData] = handler?.getLines(bookmark.lineNumber, 1) || [];
864-
const lineText = lineData?.text || '';
865-
const truncatedText = lineText.length > 100 ? lineText.substring(0, 100) + '...' : lineText;
897+
// Use stored lineText if available, otherwise fetch from file
898+
const lineText = bookmark.lineText || (handler?.getLines(bookmark.lineNumber, 1)?.[0]?.text) || '';
866899

867900
lines.push(`## Line ${bookmark.lineNumber + 1}`);
868901
lines.push(``);
@@ -874,7 +907,7 @@ ipcMain.handle('export-bookmarks', async () => {
874907
lines.push(`**Link:** \`${fileInfo.path}:${bookmark.lineNumber + 1}\``);
875908
lines.push(``);
876909
lines.push(`\`\`\``);
877-
lines.push(truncatedText);
910+
lines.push(lineText);
878911
lines.push(`\`\`\``);
879912
lines.push(``);
880913
lines.push(`---`);
@@ -941,6 +974,45 @@ ipcMain.handle('highlight-get-next-color', async () => {
941974
return { success: true, color: getNextColor() };
942975
});
943976

977+
// === Highlight Groups ===
978+
979+
function loadHighlightGroups(): HighlightGroup[] {
980+
try {
981+
const filePath = getHighlightGroupsPath();
982+
if (fs.existsSync(filePath)) {
983+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
984+
}
985+
} catch { /* ignore */ }
986+
return [];
987+
}
988+
989+
function saveHighlightGroups(groups: HighlightGroup[]): void {
990+
ensureConfigDir();
991+
fs.writeFileSync(getHighlightGroupsPath(), JSON.stringify(groups, null, 2), 'utf-8');
992+
}
993+
994+
ipcMain.handle('highlight-group-list', async () => {
995+
return { success: true, groups: loadHighlightGroups() };
996+
});
997+
998+
ipcMain.handle('highlight-group-save', async (_, group: HighlightGroup) => {
999+
const groups = loadHighlightGroups();
1000+
const existingIdx = groups.findIndex(g => g.id === group.id);
1001+
if (existingIdx >= 0) {
1002+
groups[existingIdx] = group;
1003+
} else {
1004+
groups.push(group);
1005+
}
1006+
saveHighlightGroups(groups);
1007+
return { success: true };
1008+
});
1009+
1010+
ipcMain.handle('highlight-group-delete', async (_, groupId: string) => {
1011+
const groups = loadHighlightGroups().filter(g => g.id !== groupId);
1012+
saveHighlightGroups(groups);
1013+
return { success: true };
1014+
});
1015+
9441016
// === Utility ===
9451017

9461018
ipcMain.handle('get-file-info', async () => {
@@ -984,7 +1056,7 @@ ipcMain.handle('open-external-url', async (_, url: string) => {
9841056

9851057
// === Save Selected Lines ===
9861058

987-
ipcMain.handle('save-selected-lines', async (_, startLine: number, endLine: number) => {
1059+
ipcMain.handle('save-selected-lines', async (_, startLine: number, endLine: number, columnConfig?: ColumnConfig) => {
9881060
const handler = getFileHandler();
9891061
if (!handler) return { success: false, error: 'No file open' };
9901062

@@ -1014,8 +1086,8 @@ ipcMain.handle('save-selected-lines', async (_, startLine: number, endLine: numb
10141086
const filename = `${timestamp}.log`;
10151087
const filePath = path.join(selectedDir, filename);
10161088

1017-
// Write lines to file
1018-
const content = lines.map(l => l.text).join('\n');
1089+
// Write lines to file, respecting column visibility
1090+
const content = lines.map(l => filterLineToVisibleColumns(l.text, columnConfig)).join('\n');
10191091
fs.writeFileSync(filePath, content, 'utf-8');
10201092

10211093
return { success: true, filePath, lineCount: lines.length };
@@ -1088,7 +1160,8 @@ ipcMain.handle('save-to-notes', async (
10881160
startLine: number,
10891161
endLine: number,
10901162
note?: string,
1091-
targetFilePath?: string // If provided, append to this file; otherwise create new
1163+
targetFilePath?: string, // If provided, append to this file; otherwise create new
1164+
columnConfig?: ColumnConfig
10921165
) => {
10931166
const handler = getFileHandler();
10941167
if (!handler) return { success: false, error: 'No file open' };
@@ -1153,7 +1226,7 @@ ipcMain.handle('save-to-notes', async (
11531226
content += [
11541227
'',
11551228
`--- [${timestamp}] Lines ${startLine + 1}-${endLine + 1}${noteDesc} ---`,
1156-
...lines.map(l => l.text),
1229+
...lines.map(l => filterLineToVisibleColumns(l.text, columnConfig)),
11571230
'',
11581231
].join('\n');
11591232

@@ -1258,7 +1331,8 @@ interface FilterConfig {
12581331
levels: string[];
12591332
includePatterns: string[];
12601333
excludePatterns: string[];
1261-
collapseDuplicates: boolean;
1334+
matchCase?: boolean;
1335+
exactMatch?: boolean;
12621336
contextLines?: number;
12631337
advancedFilter?: AdvancedFilterConfig;
12641338
}
@@ -1334,6 +1408,31 @@ ipcMain.handle('apply-filter', async (_, config: FilterConfig) => {
13341408
? compileAdvancedFilter(config.advancedFilter!)
13351409
: null;
13361410

1411+
// For basic filter: separate include and exclude passes
1412+
// Include matches get context window, exclude removes exact lines only
1413+
const hasBasicExclude = !useAdvancedFilter && config.excludePatterns.length > 0;
1414+
const excludeLines: Set<number> = new Set();
1415+
1416+
// Pattern matching helper respecting matchCase and exactMatch options
1417+
const caseSensitive = config.matchCase || false;
1418+
const exactMatch = config.exactMatch || false;
1419+
const matchPattern = (text: string, pattern: string): boolean => {
1420+
if (exactMatch) {
1421+
// Literal substring match
1422+
return caseSensitive
1423+
? text.includes(pattern)
1424+
: text.toLowerCase().includes(pattern.toLowerCase());
1425+
}
1426+
// Regex match with fallback to substring
1427+
try {
1428+
return new RegExp(pattern, caseSensitive ? '' : 'i').test(text);
1429+
} catch {
1430+
return caseSensitive
1431+
? text.includes(pattern)
1432+
: text.toLowerCase().includes(pattern.toLowerCase());
1433+
}
1434+
};
1435+
13371436
// Process in batches for performance
13381437
const batchSize = 10000;
13391438
for (let start = 0; start < totalLines; start += batchSize) {
@@ -1357,24 +1456,15 @@ ipcMain.handle('apply-filter', async (_, config: FilterConfig) => {
13571456

13581457
// Include patterns (OR logic)
13591458
if (matches && config.includePatterns.length > 0) {
1360-
matches = config.includePatterns.some(pattern => {
1361-
try {
1362-
return new RegExp(pattern, 'i').test(line.text);
1363-
} catch {
1364-
return line.text.toLowerCase().includes(pattern.toLowerCase());
1365-
}
1366-
});
1459+
matches = config.includePatterns.some(pattern => matchPattern(line.text, pattern));
13671460
}
13681461

1369-
// Exclude patterns (AND logic - all must not match)
1370-
if (matches && config.excludePatterns.length > 0) {
1371-
matches = !config.excludePatterns.some(pattern => {
1372-
try {
1373-
return new RegExp(pattern, 'i').test(line.text);
1374-
} catch {
1375-
return line.text.toLowerCase().includes(pattern.toLowerCase());
1376-
}
1377-
});
1462+
// Track exclude matches separately (exact lines only)
1463+
if (hasBasicExclude) {
1464+
const excluded = config.excludePatterns.some(pattern => matchPattern(line.text, pattern));
1465+
if (excluded) {
1466+
excludeLines.add(line.lineNumber);
1467+
}
13781468
}
13791469
}
13801470

@@ -1384,7 +1474,7 @@ ipcMain.handle('apply-filter', async (_, config: FilterConfig) => {
13841474
}
13851475
}
13861476

1387-
// Add context lines
1477+
// Add context lines around include matches (before exclude removal)
13881478
if (contextLines > 0) {
13891479
const matchArray = Array.from(matchingLines);
13901480
for (const lineNum of matchArray) {
@@ -1395,6 +1485,13 @@ ipcMain.handle('apply-filter', async (_, config: FilterConfig) => {
13951485
}
13961486
}
13971487

1488+
// Remove exact exclude lines after context expansion
1489+
if (hasBasicExclude) {
1490+
for (const lineNum of excludeLines) {
1491+
matchingLines.delete(lineNum);
1492+
}
1493+
}
1494+
13981495
// Sort and store
13991496
const sortedLines = Array.from(matchingLines).sort((a, b) => a - b);
14001497
filterState.set(currentFilePath, sortedLines);

0 commit comments

Comments
 (0)