Skip to content

Commit e5a5901

Browse files
ozgesolidkeyclaude
andcommitted
Feature: diagram viewer for PlantUML and Draw.io files
Add a new "Diagrams" tab in the bottom panel that renders design documents inline. Double-click a .puml or .drawio file in the folder panel to view it. PlantUML: encodes diagram text (deflate + custom base64), fetches SVG from plantuml.com server, displays inline with zoom controls. Draw.io: loads .drawio XML into a sandboxed iframe with the draw.io viewer library for full-fidelity rendering. Architecture: extends the existing FileDisplayMode pattern with diagram file detection, IPC handlers for server-side rendering, and a dedicated bottom panel tab with zoom/refresh controls. Supported extensions: .puml, .plantuml, .pu, .iuml, .drawio, .dio Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 19b47fe commit e5a5901

6 files changed

Lines changed: 363 additions & 1 deletion

File tree

src/main/index.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5865,6 +5865,83 @@ ipcMain.handle('filter-presets-delete', (_, id: string) => {
58655865
return { success: true };
58665866
});
58675867

5868+
// ── PlantUML encoding ────────────────────────────────────────────────
5869+
function encodePlantUML(text: string): string {
5870+
const zlib = require('zlib');
5871+
const data = zlib.deflateRawSync(Buffer.from(text, 'utf-8'));
5872+
const encode6bit = (b: number): string => {
5873+
if (b < 10) return String.fromCharCode(48 + b);
5874+
b -= 10;
5875+
if (b < 26) return String.fromCharCode(65 + b);
5876+
b -= 26;
5877+
if (b < 26) return String.fromCharCode(97 + b);
5878+
b -= 26;
5879+
if (b === 0) return '-';
5880+
if (b === 1) return '_';
5881+
return '?';
5882+
};
5883+
let result = '';
5884+
for (let i = 0; i < data.length; i += 3) {
5885+
const b1 = data[i];
5886+
const b2 = i + 1 < data.length ? data[i + 1] : 0;
5887+
const b3 = i + 2 < data.length ? data[i + 2] : 0;
5888+
result += encode6bit(b1 >> 2);
5889+
result += encode6bit(((b1 & 0x3) << 4) | (b2 >> 4));
5890+
result += encode6bit(((b2 & 0xF) << 2) | (b3 >> 6));
5891+
result += encode6bit(b3 & 0x3F);
5892+
}
5893+
return result;
5894+
}
5895+
5896+
// ── Diagram rendering IPC ────────────────────────────────────────────
5897+
ipcMain.handle(IPC.RENDER_DIAGRAM, async (_event, filePath: string) => {
5898+
try {
5899+
const ext = path.extname(filePath).toLowerCase();
5900+
const content = fs.readFileSync(filePath, 'utf-8');
5901+
5902+
if (ext === '.puml' || ext === '.plantuml' || ext === '.pu' || ext === '.iuml') {
5903+
// PlantUML: encode and fetch SVG from server
5904+
const encoded = encodePlantUML(content);
5905+
const url = `https://www.plantuml.com/plantuml/svg/${encoded}`;
5906+
5907+
// Use Node's https to fetch
5908+
const https = require('https');
5909+
const svg = await new Promise<string>((resolve, reject) => {
5910+
https.get(url, (res: any) => {
5911+
if (res.statusCode !== 200) {
5912+
reject(new Error(`PlantUML server returned ${res.statusCode}`));
5913+
return;
5914+
}
5915+
let data = '';
5916+
res.on('data', (chunk: string) => { data += chunk; });
5917+
res.on('end', () => resolve(data));
5918+
}).on('error', reject);
5919+
});
5920+
5921+
return { success: true, type: 'svg', content: svg, fileName: path.basename(filePath) };
5922+
}
5923+
5924+
if (ext === '.drawio' || ext === '.dio') {
5925+
// Draw.io: return XML content + viewer HTML
5926+
return { success: true, type: 'drawio', content, fileName: path.basename(filePath) };
5927+
}
5928+
5929+
return { success: false, error: `Unsupported diagram format: ${ext}` };
5930+
} catch (err: any) {
5931+
return { success: false, error: err.message || String(err) };
5932+
}
5933+
});
5934+
5935+
// ── Read file text IPC ───────────────────────────────────────────────
5936+
ipcMain.handle(IPC.READ_FILE_TEXT, async (_event, filePath: string) => {
5937+
try {
5938+
const content = fs.readFileSync(filePath, 'utf-8');
5939+
return { success: true, content, fileName: path.basename(filePath) };
5940+
} catch (err: any) {
5941+
return { success: false, error: err.message || String(err) };
5942+
}
5943+
});
5944+
58685945
ipcMain.handle('agent-browse-script', async () => {
58695946
const result = await dialog.showOpenDialog({
58705947
title: 'Select Agent Script',

src/preload/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ const IPC = {
6767
TRACEBACK: 'traceback',
6868
// Time Align
6969
GET_LINE_TIMESTAMPS: 'get-line-timestamps',
70+
// Diagram rendering
71+
RENDER_DIAGRAM: 'render-diagram',
72+
READ_FILE_TEXT: 'read-file-text',
7073
// Tabbed terminal
7174
TERMINAL_CREATE_LOCAL: 'terminal-create-local',
7275
TERMINAL_CREATE_SSH: 'terminal-create-ssh',
@@ -407,6 +410,12 @@ const api = {
407410
readFileContent: (filePath: string): Promise<{ success: boolean; content?: string; sizeMB?: number; error?: string }> =>
408411
ipcRenderer.invoke('read-file-content', filePath),
409412

413+
// Diagram rendering
414+
renderDiagram: (filePath: string): Promise<{ success: boolean; type?: string; content?: string; fileName?: string; error?: string }> =>
415+
ipcRenderer.invoke(IPC.RENDER_DIAGRAM, filePath),
416+
readFileText: (filePath: string): Promise<{ success: boolean; content?: string; fileName?: string; error?: string }> =>
417+
ipcRenderer.invoke(IPC.READ_FILE_TEXT, filePath),
418+
410419
// Search configs
411420
searchConfigSave: (config: any): Promise<{ success: boolean }> =>
412421
ipcRenderer.invoke(IPC.SEARCH_CONFIG_SAVE, config),

src/renderer/index.html

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; media-src 'self' file:;">
6+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; media-src 'self' file:; frame-src blob:;">
77
<title>LOGAN - Log Analyzer</title>
88
<link rel="stylesheet" href="styles.css">
99
<link rel="stylesheet" href="lib/xterm.css">
@@ -387,6 +387,7 @@ <h2>LOGAN</h2>
387387
<button class="bottom-tab-btn" data-bottom-tab="contexts" data-help="Define must+clue patterns to find correlated log events.&#10;Example: must:&quot;ERROR&quot; + clue:&quot;timeout&quot; within 10 lines.&#10;Lanes view shows each context as a colored row for comparison.">Contexts</button>
388388
<button class="bottom-tab-btn" data-bottom-tab="video" data-help="Sync a video recording with log timestamps.&#10;Set a sync point to correlate video playback with log lines.">Video</button>
389389
<button class="bottom-tab-btn" data-bottom-tab="image" data-help="View images referenced in log files.&#10;Drag and drop or open image files for side-by-side analysis.">Image</button>
390+
<button class="bottom-tab-btn" data-bottom-tab="diagram" data-help="View PlantUML and Draw.io diagrams.&#10;Double-click a .puml or .drawio file in the folder panel.">Diagrams</button>
390391
<button class="bottom-tab-btn" data-bottom-tab="live" data-help="Connect to live log sources: Serial ports, ADB logcat, or SSH.&#10;Up to 4 parallel connections with real-time streaming.">Live</button>
391392
<button class="bottom-tab-btn" data-bottom-tab="time-align" data-help="Visually align search config results by timestamp.&#10;Each enabled config becomes a draggable timeline lane.&#10;Drag lanes left/right to apply time offsets and correlate events.">Time Align</button>
392393
<button class="bottom-tab-btn" data-bottom-tab="traceback" data-help="Reverse timeline from any error line.&#10;Right-click a line → Traceback from here.&#10;Shows what led to the error: same component, warnings, escalations.">Traceback</button>
@@ -545,6 +546,34 @@ <h2>LOGAN</h2>
545546
</div>
546547
</div>
547548
</div>
549+
<!-- Diagram tab -->
550+
<div class="bottom-tab-view" data-bottom-tab="diagram">
551+
<div class="diagram-panel-body">
552+
<div class="diagram-viewer-toolbar">
553+
<span id="diagram-file-name" class="image-file-name"></span>
554+
<span id="diagram-type-badge" class="diagram-type-badge"></span>
555+
<div style="flex:1"></div>
556+
<button id="btn-diagram-zoom-in" class="image-viewer-btn" title="Zoom in" aria-label="Zoom in">+</button>
557+
<span id="diagram-zoom-level" class="image-zoom-level">100%</span>
558+
<button id="btn-diagram-zoom-out" class="image-viewer-btn" title="Zoom out" aria-label="Zoom out">&minus;</button>
559+
<button id="btn-diagram-zoom-fit" class="image-viewer-btn" title="Fit to window" aria-label="Fit to window">Fit</button>
560+
<button id="btn-diagram-refresh" class="image-viewer-btn" title="Re-render diagram" aria-label="Refresh diagram">&#8635;</button>
561+
</div>
562+
<div id="diagram-content" class="diagram-content">
563+
<div class="video-drop-zone">
564+
<div class="video-drop-zone-content">
565+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M12 18v-6"/><path d="M9 15l3-3 3 3"/></svg>
566+
<p>Open a .puml or .drawio file from the folder panel</p>
567+
</div>
568+
</div>
569+
</div>
570+
<div id="diagram-loading" class="diagram-loading hidden">
571+
<div class="diagram-spinner"></div>
572+
<span>Rendering diagram...</span>
573+
</div>
574+
<div id="diagram-error" class="diagram-error hidden"></div>
575+
</div>
576+
</div>
548577
<!-- Live tab -->
549578
<div class="bottom-tab-view" data-bottom-tab="live">
550579
<div class="live-panel-body">

src/renderer/renderer.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4810,6 +4810,8 @@ function renderFolderTree(): void {
48104810
toggleBottomTab('video');
48114811
} else if (fileType === 'image') {
48124812
openImageInPanel(filePath);
4813+
} else if (isDiagramFile(filePath)) {
4814+
openDiagramInPanel(filePath);
48134815
} else {
48144816
await loadFile(filePath);
48154817
}
@@ -9844,6 +9846,12 @@ async function loadFile(filePath: string, createNewTab: boolean = true): Promise
98449846
return;
98459847
}
98469848

9849+
// Handle diagram files in bottom panel
9850+
if (isDiagramFile(filePath)) {
9851+
openDiagramInPanel(filePath);
9852+
return;
9853+
}
9854+
98479855
showProgress('Indexing file...');
98489856

98499857
const unsubscribe = window.api.onIndexingProgress((progress) => {
@@ -13521,6 +13529,13 @@ function isImageFile(filePath: string): boolean {
1352113529
return IMAGE_EXTENSIONS.has(ext);
1352213530
}
1352313531

13532+
const DIAGRAM_EXTENSIONS = new Set(['puml', 'plantuml', 'pu', 'iuml', 'drawio', 'dio']);
13533+
13534+
function isDiagramFile(filePath: string): boolean {
13535+
const ext = filePath.toLowerCase().split('.').pop() || '';
13536+
return DIAGRAM_EXTENSIONS.has(ext);
13537+
}
13538+
1352413539
// Image viewer state (bottom panel)
1352513540
let imageZoom = 1;
1352613541
const imageViewerContainer = document.getElementById('image-viewer-container') as HTMLDivElement;
@@ -13580,6 +13595,138 @@ imageViewerContainer?.addEventListener('wheel', (e) => {
1358013595
setImageZoom(imageZoom * delta);
1358113596
}, { passive: false });
1358213597

13598+
// ── Diagram Viewer ───────────────────────────────────────────────────────────
13599+
let diagramZoom = 1;
13600+
let currentDiagramPath = '';
13601+
13602+
function openDiagramInPanel(filePath: string): void {
13603+
currentDiagramPath = filePath;
13604+
const ext = filePath.toLowerCase().split('.').pop() || '';
13605+
const fileName = filePath.split('/').pop() || filePath;
13606+
13607+
const contentEl = document.getElementById('diagram-content') as HTMLDivElement;
13608+
const loadingEl = document.getElementById('diagram-loading') as HTMLDivElement;
13609+
const errorEl = document.getElementById('diagram-error') as HTMLDivElement;
13610+
const fileNameEl = document.getElementById('diagram-file-name') as HTMLSpanElement;
13611+
const badgeEl = document.getElementById('diagram-type-badge') as HTMLSpanElement;
13612+
const zoomEl = document.getElementById('diagram-zoom-level') as HTMLSpanElement;
13613+
13614+
if (!contentEl) return;
13615+
13616+
// Reset
13617+
diagramZoom = 1;
13618+
if (zoomEl) zoomEl.textContent = '100%';
13619+
if (fileNameEl) fileNameEl.textContent = fileName;
13620+
13621+
// Set badge
13622+
if (badgeEl) {
13623+
if (ext === 'drawio' || ext === 'dio') {
13624+
badgeEl.textContent = 'Draw.io';
13625+
badgeEl.style.background = '#f08705';
13626+
} else {
13627+
badgeEl.textContent = 'PlantUML';
13628+
badgeEl.style.background = '#7b5ea7';
13629+
}
13630+
}
13631+
13632+
// Show loading
13633+
contentEl.innerHTML = '';
13634+
loadingEl?.classList.remove('hidden');
13635+
errorEl?.classList.add('hidden');
13636+
13637+
// Switch to diagram tab
13638+
toggleBottomTab('diagram');
13639+
13640+
// Render
13641+
(window as any).api.renderDiagram(filePath).then((result: any) => {
13642+
loadingEl?.classList.add('hidden');
13643+
13644+
if (!result.success) {
13645+
if (errorEl) {
13646+
errorEl.textContent = result.error || 'Failed to render diagram';
13647+
errorEl.classList.remove('hidden');
13648+
}
13649+
return;
13650+
}
13651+
13652+
if (result.type === 'svg') {
13653+
// PlantUML: inject SVG directly
13654+
const wrapper = document.createElement('div');
13655+
wrapper.className = 'diagram-svg-wrapper';
13656+
wrapper.innerHTML = result.content;
13657+
contentEl.innerHTML = '';
13658+
contentEl.appendChild(wrapper);
13659+
} else if (result.type === 'drawio') {
13660+
// Draw.io: create iframe with viewer
13661+
renderDrawioInIframe(contentEl, result.content);
13662+
}
13663+
}).catch((err: any) => {
13664+
loadingEl?.classList.add('hidden');
13665+
if (errorEl) {
13666+
errorEl.textContent = `Error: ${err.message || err}`;
13667+
errorEl.classList.remove('hidden');
13668+
}
13669+
});
13670+
}
13671+
13672+
function renderDrawioInIframe(container: HTMLDivElement, xmlContent: string): void {
13673+
// Create self-contained HTML with draw.io viewer
13674+
const escapedXml = xmlContent.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
13675+
13676+
const html = `<!DOCTYPE html>
13677+
<html><head>
13678+
<meta charset="UTF-8">
13679+
<style>
13680+
body { margin: 0; padding: 0; overflow: hidden; background: #fff; }
13681+
.mxgraph { width: 100%; height: 100vh; }
13682+
</style>
13683+
</head><body>
13684+
<div class="mxgraph" style="max-width:100%;border:none;" data-mxgraph='{"highlight":"#0000ff","nav":true,"resize":true,"xml":"${escapedXml}"}'></div>
13685+
<script src="https://viewer.diagrams.net/js/viewer-static.min.js"><\/script>
13686+
</body></html>`;
13687+
13688+
const blob = new Blob([html], { type: 'text/html' });
13689+
const blobUrl = URL.createObjectURL(blob);
13690+
13691+
const iframe = document.createElement('iframe');
13692+
iframe.src = blobUrl;
13693+
iframe.style.width = '100%';
13694+
iframe.style.height = '100%';
13695+
iframe.style.border = 'none';
13696+
iframe.style.background = '#fff';
13697+
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
13698+
iframe.onload = () => URL.revokeObjectURL(blobUrl);
13699+
13700+
container.innerHTML = '';
13701+
container.appendChild(iframe);
13702+
}
13703+
13704+
// Diagram zoom controls
13705+
function setupDiagramControls(): void {
13706+
const zoomIn = document.getElementById('btn-diagram-zoom-in');
13707+
const zoomOut = document.getElementById('btn-diagram-zoom-out');
13708+
const zoomFit = document.getElementById('btn-diagram-zoom-fit');
13709+
const refresh = document.getElementById('btn-diagram-refresh');
13710+
const zoomEl = document.getElementById('diagram-zoom-level');
13711+
13712+
function updateDiagramZoom(): void {
13713+
const wrapper = document.querySelector('.diagram-svg-wrapper') as HTMLElement;
13714+
if (wrapper && zoomEl) {
13715+
wrapper.style.transform = `scale(${diagramZoom})`;
13716+
zoomEl.textContent = `${Math.round(diagramZoom * 100)}%`;
13717+
const contentEl = document.getElementById('diagram-content');
13718+
if (contentEl) {
13719+
contentEl.classList.toggle('zoomed', diagramZoom > 1);
13720+
}
13721+
}
13722+
}
13723+
13724+
zoomIn?.addEventListener('click', () => { diagramZoom = Math.min(diagramZoom * 1.25, 5); updateDiagramZoom(); });
13725+
zoomOut?.addEventListener('click', () => { diagramZoom = Math.max(diagramZoom / 1.25, 0.1); updateDiagramZoom(); });
13726+
zoomFit?.addEventListener('click', () => { diagramZoom = 1; updateDiagramZoom(); });
13727+
refresh?.addEventListener('click', () => { if (currentDiagramPath) openDiagramInPanel(currentDiagramPath); });
13728+
}
13729+
1358313730
// ─── File Display Mode System ────────────────────────────────────────────────
1358413731
//
1358513732
// Each mode owns its own activate() and deactivate() lifecycle.
@@ -15613,6 +15760,7 @@ function init(): void {
1561315760
setupSectionToggles();
1561415761
setupModalCloseHandlers();
1561515762
setupKeyboardShortcuts();
15763+
setupDiagramControls();
1561615764

1561715765
// Load initial data
1561815766
loadBookmarks();

0 commit comments

Comments
 (0)