diff --git a/src/main/index.ts b/src/main/index.ts index d051c7a..1375c95 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4232,6 +4232,24 @@ ipcMain.handle(IPC.GET_LINE_TIMESTAMP, async (_, lineNumber: number) => { } }); +// Batch timestamp fetch for Time Align +ipcMain.handle(IPC.GET_LINE_TIMESTAMPS, async (_, lineNumbers: number[]) => { + const handler = getFileHandler(); + if (!handler) return []; + const results: Array<{ lineNumber: number; epochMs: number }> = []; + try { + for (const ln of lineNumbers) { + const lines = handler.getLines(ln, 1); + if (lines.length === 0) continue; + const parsed = parseTimestampFast(lines[0].text); + if (parsed) { + results.push({ lineNumber: ln, epochMs: parsed.date.getTime() }); + } + } + } catch { /* ignore */ } + return results; +}); + ipcMain.handle('detect-time-gaps', async (_, options: TimeGapOptions) => { const handler = getFileHandler(); if (!handler || !currentFilePath) { diff --git a/src/preload/index.ts b/src/preload/index.ts index cac17ee..8717f76 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -65,6 +65,8 @@ const IPC = { CONTEXT_SEARCH_PROGRESS: 'context-search-progress', // Traceback TRACEBACK: 'traceback', + // Time Align + GET_LINE_TIMESTAMPS: 'get-line-timestamps', // Tabbed terminal TERMINAL_CREATE_LOCAL: 'terminal-create-local', TERMINAL_CREATE_SSH: 'terminal-create-ssh', @@ -432,6 +434,9 @@ const api = { getLineTimestamp: (lineNumber: number): Promise<{ epochMs: number | null; timestampStr: string | null }> => ipcRenderer.invoke(IPC.GET_LINE_TIMESTAMP, lineNumber), + getLineTimestamps: (lineNumbers: number[]): Promise> => + ipcRenderer.invoke(IPC.GET_LINE_TIMESTAMPS, lineNumbers), + // MCP navigation onNavigateToLine: (callback: (lineNumber: number) => void): (() => void) => { const handler = (_: any, lineNumber: number) => callback(lineNumber); diff --git a/src/renderer/index.html b/src/renderer/index.html index 7ee96b9..f1a2aac 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -315,6 +315,7 @@

LOGAN

+ @@ -525,6 +526,19 @@

LOGAN

+ +
+
+
+ + + +
+
+
+

Run Search Configs first, then open this tab to align timelines

+
+
diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 401c58a..5377d89 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -288,6 +288,11 @@ interface AppState { tracebackResult: any | null; tracebackSort: 'time' | 'score'; tracebackFilterCat: string; + // Time Align + timeAlignOffsets: Map; + timeAlignTimestamps: Map>; + timeAlignMinTime: number; + timeAlignMaxTime: number; } const state: AppState = { @@ -343,6 +348,10 @@ const state: AppState = { tracebackResult: null, tracebackSort: 'time' as 'time' | 'score', tracebackFilterCat: 'all', + timeAlignOffsets: new Map(), + timeAlignTimestamps: new Map>(), + timeAlignMinTime: 0, + timeAlignMaxTime: 0, }; // Constants @@ -840,6 +849,13 @@ const elements = { tracebackFilter: document.getElementById('traceback-filter') as HTMLSelectElement, tracebackSummary: document.getElementById('traceback-summary') as HTMLDivElement, tracebackResults: document.getElementById('traceback-results') as HTMLDivElement, + // Time Align + taRefreshBtn: document.getElementById('ta-refresh-btn') as HTMLButtonElement, + taResetBtn: document.getElementById('ta-reset-btn') as HTMLButtonElement, + taSummary: document.getElementById('ta-summary') as HTMLSpanElement, + taAxis: document.getElementById('ta-axis') as HTMLDivElement, + taLanes: document.getElementById('ta-lanes') as HTMLDivElement, + taPlaceholder: document.getElementById('ta-placeholder') as HTMLParagraphElement, // Bottom panel bottomPanel: document.getElementById('bottom-panel') as HTMLDivElement, bottomPanelResizeHandle: document.getElementById('bottom-panel-resize-handle') as HTMLDivElement, @@ -4680,6 +4696,11 @@ function openBottomTab(tabId: string): void { if (tabId === 'contexts') { loadContextDefinitions(); } + if (tabId === 'time-align') { + if (state.timeAlignTimestamps.size === 0 && state.searchConfigResults.size > 0) { + buildTimeAlignData(); + } + } saveBottomPanelState(); } @@ -4713,6 +4734,310 @@ function toggleBottomTab(tabId: string): void { } } +// ─── Time Align ────────────────────────────────────────────────────── + +async function buildTimeAlignData(): Promise { + const enabledConfigs = state.searchConfigs.filter(c => c.enabled); + if (enabledConfigs.length === 0) { + state.timeAlignTimestamps.clear(); + renderTimeAlignLanes(); + return; + } + + // Collect all unique line numbers from search config results + const allLineNumbers = new Set(); + for (const config of enabledConfigs) { + const results = state.searchConfigResults.get(config.id); + if (results) { + for (const r of results) allLineNumbers.add(r.lineNumber); + } + } + + if (allLineNumbers.size === 0) { + state.timeAlignTimestamps.clear(); + renderTimeAlignLanes(); + return; + } + + // Single batch IPC call for all line timestamps + const timestamps = await window.api.getLineTimestamps(Array.from(allLineNumbers)); + const tsMap = new Map(); + for (const t of timestamps) tsMap.set(t.lineNumber, t.epochMs); + + // Distribute to per-config maps + state.timeAlignTimestamps.clear(); + let globalMin = Infinity; + let globalMax = -Infinity; + + for (const config of enabledConfigs) { + const results = state.searchConfigResults.get(config.id); + if (!results) continue; + const entries: Array<{ lineNumber: number; epochMs: number }> = []; + for (const r of results) { + const ep = tsMap.get(r.lineNumber); + if (ep !== undefined) { + entries.push({ lineNumber: r.lineNumber, epochMs: ep }); + if (ep < globalMin) globalMin = ep; + if (ep > globalMax) globalMax = ep; + } + } + if (entries.length > 0) { + state.timeAlignTimestamps.set(config.id, entries); + } + } + + state.timeAlignMinTime = globalMin === Infinity ? 0 : globalMin; + state.timeAlignMaxTime = globalMax === -Infinity ? 0 : globalMax; + + renderTimeAlignLanes(); +} + +function formatOffsetMs(ms: number): string { + const abs = Math.abs(ms); + const sign = ms >= 0 ? '+' : '-'; + if (abs < 1000) return `${sign}${abs}ms`; + if (abs < 60000) return `${sign}${(abs / 1000).toFixed(1)}s`; + return `${sign}${(abs / 60000).toFixed(1)}m`; +} + +function formatTimestamp(epochMs: number): string { + const d = new Date(epochMs); + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + const s = String(d.getSeconds()).padStart(2, '0'); + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return `${h}:${m}:${s}.${ms}`; +} + +function renderTimeAlignAxis(): void { + const axis = elements.taAxis; + axis.innerHTML = ''; + + const range = state.timeAlignMaxTime - state.timeAlignMinTime; + if (range <= 0) return; + + // Adaptive tick count based on width + const axisWidth = axis.offsetWidth - 100; // subtract label area + if (axisWidth <= 0) return; + const tickCount = Math.min(10, Math.max(3, Math.floor(axisWidth / 80))); + + for (let i = 0; i <= tickCount; i++) { + const frac = i / tickCount; + const pct = frac * 100; + const epochMs = state.timeAlignMinTime + frac * range; + + const tick = document.createElement('div'); + tick.className = 'ta-axis-tick'; + tick.style.left = `calc(${pct}%)`; + axis.appendChild(tick); + + const label = document.createElement('div'); + label.className = 'ta-axis-label'; + label.style.left = `calc(${pct}%)`; + label.textContent = formatTimestamp(epochMs); + axis.appendChild(label); + } +} + +function renderTimeAlignLanes(): void { + const lanesEl = elements.taLanes; + lanesEl.innerHTML = ''; + + const enabledConfigs = state.searchConfigs.filter(c => c.enabled); + const hasData = enabledConfigs.some(c => state.timeAlignTimestamps.has(c.id)); + + elements.taPlaceholder.style.display = hasData ? 'none' : ''; + if (!hasData) { + elements.taAxis.innerHTML = ''; + elements.taSummary.textContent = ''; + return; + } + + const range = state.timeAlignMaxTime - state.timeAlignMinTime; + const safeRange = range > 0 ? range : 1; + + let totalMarks = 0; + + for (const config of enabledConfigs) { + const entries = state.timeAlignTimestamps.get(config.id); + if (!entries || entries.length === 0) continue; + + const offsetMs = state.timeAlignOffsets.get(config.id) || 0; + + const lane = document.createElement('div'); + lane.className = 'ta-lane'; + lane.dataset.configId = config.id; + + // Label + const label = document.createElement('div'); + label.className = 'ta-lane-label'; + const swatch = document.createElement('span'); + swatch.className = 'ta-lane-color'; + swatch.style.background = config.color; + swatch.style.color = config.color; + label.appendChild(swatch); + label.appendChild(document.createTextNode(config.pattern)); + label.title = config.pattern; + + // Offset badge + if (offsetMs !== 0) { + const badge = document.createElement('span'); + badge.className = 'ta-offset-badge'; + badge.textContent = formatOffsetMs(offsetMs); + label.appendChild(badge); + } + + lane.appendChild(label); + + // Track + const track = document.createElement('div'); + track.className = 'ta-lane-track'; + track.style.background = `linear-gradient(to right, ${config.color}15, ${config.color}08)`; + + for (const entry of entries) { + const adjustedMs = entry.epochMs + offsetMs; + const pct = ((adjustedMs - state.timeAlignMinTime) / safeRange) * 100; + if (pct < -5 || pct > 105) continue; // skip far-out marks + + const mark = document.createElement('div'); + mark.className = 'ta-lane-mark'; + mark.style.left = `${Math.max(0, Math.min(100, pct))}%`; + mark.style.background = config.color; + mark.style.color = config.color; + mark.dataset.lineNumber = String(entry.lineNumber); + mark.dataset.epochMs = String(entry.epochMs); + + // Click to navigate + mark.addEventListener('click', (e) => { + e.stopPropagation(); + goToLine(entry.lineNumber); + }); + + // Hover tooltip + mark.addEventListener('mouseenter', (e) => { + const origStr = formatTimestamp(entry.epochMs); + let tipText = `${origStr} | L${entry.lineNumber + 1}`; + if (offsetMs !== 0) { + const adjStr = formatTimestamp(adjustedMs); + tipText = `Original: ${origStr} → Adjusted: ${adjStr} | L${entry.lineNumber + 1}`; + } + showTimeAlignTooltip(e as MouseEvent, tipText); + }); + mark.addEventListener('mouseleave', hideTimeAlignTooltip); + + track.appendChild(mark); + totalMarks++; + } + + lane.appendChild(track); + lanesEl.appendChild(lane); + + // Setup drag interaction for this lane + setupTimeAlignDrag(lane, track, config.id, safeRange); + } + + elements.taSummary.textContent = `${enabledConfigs.length} lane${enabledConfigs.length !== 1 ? 's' : ''}, ${totalMarks} mark${totalMarks !== 1 ? 's' : ''}`; + renderTimeAlignAxis(); +} + +let taTooltipEl: HTMLDivElement | null = null; + +function showTimeAlignTooltip(e: MouseEvent, text: string): void { + if (!taTooltipEl) { + taTooltipEl = document.createElement('div'); + taTooltipEl.className = 'ta-mark-tooltip'; + document.body.appendChild(taTooltipEl); + } + taTooltipEl.textContent = text; + taTooltipEl.style.display = 'block'; + taTooltipEl.style.left = `${e.clientX + 10}px`; + taTooltipEl.style.top = `${e.clientY - 30}px`; +} + +function hideTimeAlignTooltip(): void { + if (taTooltipEl) taTooltipEl.style.display = 'none'; +} + +function setupTimeAlignDrag(lane: HTMLElement, track: HTMLElement, configId: string, rangeMs: number): void { + let isDragging = false; + let startX = 0; + let startOffset = 0; + + track.addEventListener('mousedown', (e: MouseEvent) => { + // Don't start drag if clicking on a mark + if ((e.target as HTMLElement).classList.contains('ta-lane-mark')) return; + e.preventDefault(); + isDragging = true; + startX = e.clientX; + startOffset = state.timeAlignOffsets.get(configId) || 0; + track.classList.add('dragging'); + document.body.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e: MouseEvent): void { + if (!isDragging) return; + const dx = e.clientX - startX; + const trackWidth = track.offsetWidth; + if (trackWidth <= 0) return; + const dMs = (dx / trackWidth) * rangeMs; + const newOffset = startOffset + dMs; + state.timeAlignOffsets.set(configId, newOffset); + + // Update mark positions without full re-render + const marks = track.querySelectorAll('.ta-lane-mark'); + const safeRange = rangeMs > 0 ? rangeMs : 1; + marks.forEach(m => { + const mark = m as HTMLElement; + const epochMs = Number(mark.dataset.epochMs); + const adjusted = epochMs + newOffset; + const pct = ((adjusted - state.timeAlignMinTime) / safeRange) * 100; + mark.style.left = `${Math.max(0, Math.min(100, pct))}%`; + }); + + // Update offset badge + updateLaneOffsetBadge(lane, configId); + } + + function onMouseUp(): void { + isDragging = false; + track.classList.remove('dragging'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } +} + +function updateLaneOffsetBadge(lane: HTMLElement, configId: string): void { + const label = lane.querySelector('.ta-lane-label'); + if (!label) return; + let badge = label.querySelector('.ta-offset-badge') as HTMLElement | null; + const offset = state.timeAlignOffsets.get(configId) || 0; + if (Math.abs(offset) < 1) { + if (badge) badge.remove(); + return; + } + if (!badge) { + badge = document.createElement('span'); + badge.className = 'ta-offset-badge'; + label.appendChild(badge); + } + badge.textContent = formatOffsetMs(offset); +} + +function invalidateTimeAlignTimestamps(): void { + state.timeAlignTimestamps.clear(); + state.timeAlignMinTime = 0; + state.timeAlignMaxTime = 0; + // If Time Align tab is currently active, auto-rebuild + if (state.activeBottomTab === 'time-align') { + buildTimeAlignData(); + } +} + // ─── Traceback ─────────────────────────────────────────────────────── async function runTraceback(targetLine: number): Promise { @@ -6425,6 +6750,7 @@ async function runSearchConfigsBatch(): Promise { await yieldToUI(); renderVisibleLines(); renderMinimapMarkers(); + invalidateTimeAlignTimestamps(); } function renderSearchConfigsChips(): void { @@ -11580,6 +11906,13 @@ function init(): void { elements.ctxGroupSeparate.addEventListener('click', () => toggleContextGroupMode('separate')); elements.ctxGroupCombined.addEventListener('click', () => toggleContextGroupMode('combined')); + // Time Align events (inside bottom panel) + elements.taRefreshBtn.addEventListener('click', () => buildTimeAlignData()); + elements.taResetBtn.addEventListener('click', () => { + state.timeAlignOffsets.clear(); + renderTimeAlignLanes(); + }); + // Video player events (inside bottom panel) elements.btnVideoOpen.addEventListener('click', openVideoFile); elements.btnVideoSyncFromLine.addEventListener('click', setVideoSyncFromCurrentLine); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 9863301..d85fb6e 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -5975,6 +5975,31 @@ kbd { :root.theme-paper .ctx-lane-label { color: #5a7070; } :root.theme-paper .ctx-lane-track { background: #b5c0ba; border-color: #98a5a0; } +/* === Time Align === */ +.ta-panel-body { display: flex; flex-direction: column; height: 100%; } +.ta-toolbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; border-bottom: 1px solid #2e3a4a; flex-shrink: 0; } +.ta-axis { display: flex; align-items: flex-end; height: 22px; padding: 0 10px 0 100px; position: relative; flex-shrink: 0; border-bottom: 1px solid #2e3a4a; } +.ta-axis-tick { position: absolute; bottom: 0; width: 1px; height: 8px; background: #4a5568; } +.ta-axis-label { position: absolute; bottom: 10px; font-size: 9px; color: #7a8a9a; transform: translateX(-50%); white-space: nowrap; font-family: var(--font-mono); } +.ta-lanes { display: flex; flex-direction: column; gap: 3px; padding: 6px 10px; overflow-y: auto; flex: 1; } +.ta-lane { display: flex; align-items: center; height: 28px; gap: 6px; } +.ta-lane-label { font-size: 11px; color: #c0b8a0; width: 90px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 5px; font-weight: 500; } +.ta-lane-color { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 4px currentColor; } +.ta-lane-track { flex: 1; position: relative; height: 24px; border-radius: 3px; border: 1px solid #2e3a4a; overflow: hidden; cursor: grab; } +.ta-lane-track.dragging { cursor: grabbing; } +.ta-lane-mark { position: absolute; top: 2px; bottom: 2px; width: 3px; border-radius: 1px; cursor: pointer; transition: opacity 0.15s; box-shadow: 0 0 3px currentColor; } +.ta-lane-mark:hover { opacity: 0.8; box-shadow: 0 0 8px currentColor; z-index: 2; } +.ta-offset-badge { font-size: 9px; color: #61afef; margin-left: 4px; font-family: var(--font-mono); white-space: nowrap; } +.ta-mark-tooltip { position: fixed; background: #1e2a3a; border: 1px solid #3e4a5a; border-radius: 4px; padding: 4px 8px; font-size: 10px; color: #c8d8e8; font-family: var(--font-mono); z-index: 9999; pointer-events: none; white-space: nowrap; box-shadow: 0 2px 8px rgba(0,0,0,0.4); } + +:root.theme-paper .ta-toolbar { background: #bbc5c0; border-color: #a0aca7; } +:root.theme-paper .ta-axis { border-color: #a0aca7; } +:root.theme-paper .ta-axis-tick { background: #98a5a0; } +:root.theme-paper .ta-axis-label { color: #5a7070; } +:root.theme-paper .ta-lane-label { color: #5a7070; } +:root.theme-paper .ta-lane-track { border-color: #98a5a0; } +:root.theme-paper .ta-offset-badge { color: #3d7a8a; } + /* === Traceback === */ .traceback-panel-body { display: flex; flex-direction: column; height: 100%; } .traceback-toolbar { display: flex; align-items: center; gap: 8px; padding: 4px 0; flex-wrap: wrap; } diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index d03a1d8..568813c 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -428,6 +428,9 @@ interface Api { // Video player getLineTimestamp: (lineNumber: number) => Promise<{ epochMs: number | null; timestampStr: string | null }>; + // Time Align (batch) + getLineTimestamps: (lineNumbers: number[]) => Promise>; + // MCP navigation onNavigateToLine: (callback: (lineNumber: number) => void) => () => void; diff --git a/src/shared/types.ts b/src/shared/types.ts index 5c34937..ea2474d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -356,4 +356,6 @@ export const IPC = { CONTEXT_SEARCH_PROGRESS: 'context-search-progress', // Traceback TRACEBACK: 'traceback', + // Time Align (batch timestamp fetch) + GET_LINE_TIMESTAMPS: 'get-line-timestamps', } as const;