Skip to content

Commit 3a985c1

Browse files
authored
Merge pull request #215 from beNative/codex/refine-application-logging-panel-selection-and-copy
Enhance logger panel selection and copy tools
2 parents 9ca60be + d288ac6 commit 3a985c1

1 file changed

Lines changed: 326 additions & 9 deletions

File tree

components/LoggerPanel.tsx

Lines changed: 326 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
22
import { useLogger } from '../hooks/useLogger';
33
import { LogLevel } from '../types';
4-
import { DownloadIcon, TrashIcon, ChevronDownIcon, SearchIcon } from './Icons';
4+
import { DownloadIcon, TrashIcon, ChevronDownIcon, SearchIcon, CopyIcon, CheckIcon } from './Icons';
55
// Fix: Use relative path for service import.
66
import { storageService } from '../services/storageService';
77
import IconButton from './IconButton';
@@ -41,6 +41,14 @@ const LoggerPanel: React.FC<LoggerPanelProps> = ({ isVisible, onToggleVisibility
4141
const { logs, clearLogs, addLog } = useLogger();
4242
const [filter, setFilter] = useState<LogLevel | 'ALL'>('ALL');
4343
const [query, setQuery] = useState('');
44+
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
45+
const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null);
46+
const [isDragging, setIsDragging] = useState(false);
47+
const [dragStartIndex, setDragStartIndex] = useState<number | null>(null);
48+
const [copyStatus, setCopyStatus] = useState<'idle' | 'success' | 'error'>('idle');
49+
const [includeTimestamp, setIncludeTimestamp] = useState(true);
50+
const [includeLevel, setIncludeLevel] = useState(true);
51+
const [preserveLineBreaks, setPreserveLineBreaks] = useState(true);
4452
const normalizedQuery = query.trim().toLowerCase();
4553
const scrollRef = useRef<HTMLDivElement>(null);
4654

@@ -57,6 +65,37 @@ const LoggerPanel: React.FC<LoggerPanelProps> = ({ isVisible, onToggleVisibility
5765
});
5866
}, [logs, filter, normalizedQuery]);
5967

68+
const visibleLogIds = useMemo(() => new Set(filteredLogs.map(log => log.id)), [filteredLogs]);
69+
70+
useEffect(() => {
71+
setSelectedIds(prev => {
72+
if (prev.size === 0) {
73+
return prev;
74+
}
75+
76+
let changed = false;
77+
const next: number[] = [];
78+
prev.forEach(id => {
79+
if (visibleLogIds.has(id)) {
80+
next.push(id);
81+
} else {
82+
changed = true;
83+
}
84+
});
85+
86+
return changed ? new Set(next) : prev;
87+
});
88+
}, [visibleLogIds]);
89+
90+
useEffect(() => {
91+
if (selectionAnchor !== null && (selectionAnchor < 0 || selectionAnchor >= filteredLogs.length)) {
92+
setSelectionAnchor(filteredLogs.length ? Math.min(selectionAnchor, filteredLogs.length - 1) : null);
93+
}
94+
}, [filteredLogs.length, selectionAnchor]);
95+
96+
const selectedLogs = useMemo(() => filteredLogs.filter(log => selectedIds.has(log.id)), [filteredLogs, selectedIds]);
97+
const selectedCount = selectedLogs.length;
98+
6099
const renderHighlighted = useCallback((text: string) => {
61100
if (!normalizedQuery) {
62101
return text;
@@ -95,6 +134,208 @@ const LoggerPanel: React.FC<LoggerPanelProps> = ({ isVisible, onToggleVisibility
95134
}
96135
}, [filteredLogs, isVisible]);
97136

137+
useEffect(() => {
138+
if (typeof window === 'undefined') {
139+
return;
140+
}
141+
142+
const handleMouseUp = () => {
143+
setIsDragging(false);
144+
setDragStartIndex(null);
145+
};
146+
147+
window.addEventListener('mouseup', handleMouseUp);
148+
return () => {
149+
window.removeEventListener('mouseup', handleMouseUp);
150+
};
151+
}, []);
152+
153+
const formatLogForCopy = useCallback((log: typeof filteredLogs[number]) => {
154+
const parts: string[] = [];
155+
156+
if (includeTimestamp) {
157+
parts.push(`[${log.timestamp}]`);
158+
}
159+
160+
if (includeLevel) {
161+
parts.push(`[${log.level}]`);
162+
}
163+
164+
let message = log.message;
165+
if (!preserveLineBreaks) {
166+
message = log.message.replace(/\s*\n\s*/g, ' ');
167+
}
168+
169+
parts.push(message);
170+
171+
return parts.join(' ').replace(/\s{2,}/g, ' ').trim();
172+
}, [includeTimestamp, includeLevel, preserveLineBreaks]);
173+
174+
const copySelectedLogs = useCallback(async () => {
175+
if (selectedCount === 0) {
176+
return;
177+
}
178+
179+
const text = selectedLogs.map(formatLogForCopy).join('\n');
180+
181+
const fallbackCopy = (content: string) => {
182+
if (typeof document === 'undefined') {
183+
throw new Error('Clipboard API is not available');
184+
}
185+
186+
const textarea = document.createElement('textarea');
187+
textarea.value = content;
188+
textarea.setAttribute('readonly', '');
189+
textarea.style.position = 'absolute';
190+
textarea.style.left = '-9999px';
191+
document.body.appendChild(textarea);
192+
textarea.select();
193+
document.execCommand('copy');
194+
document.body.removeChild(textarea);
195+
};
196+
197+
try {
198+
if (navigator.clipboard && navigator.clipboard.writeText) {
199+
await navigator.clipboard.writeText(text);
200+
} else {
201+
fallbackCopy(text);
202+
}
203+
setCopyStatus('success');
204+
setTimeout(() => setCopyStatus('idle'), 2000);
205+
} catch (error) {
206+
console.error('Failed to copy logs', error);
207+
try {
208+
fallbackCopy(text);
209+
setCopyStatus('success');
210+
setTimeout(() => setCopyStatus('idle'), 2000);
211+
} catch (fallbackError) {
212+
console.error('Fallback copy failed', fallbackError);
213+
setCopyStatus('error');
214+
setTimeout(() => setCopyStatus('idle'), 3000);
215+
}
216+
}
217+
}, [formatLogForCopy, selectedCount, selectedLogs]);
218+
219+
const isTextSelectionTarget = useCallback((target: EventTarget | null) => {
220+
if (typeof window === 'undefined') {
221+
return false;
222+
}
223+
224+
const element = target as HTMLElement | null;
225+
return Boolean(element?.closest('[data-text-selectable="true"]'));
226+
}, []);
227+
228+
const hasActiveTextSelection = useCallback(() => {
229+
if (typeof window === 'undefined') {
230+
return false;
231+
}
232+
233+
const selection = window.getSelection();
234+
return Boolean(selection && selection.rangeCount > 0 && !selection.getRangeAt(0).collapsed);
235+
}, []);
236+
237+
const handleLogSelection = useCallback((event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>, logId: number, index: number) => {
238+
const { shiftKey, metaKey, ctrlKey } = event;
239+
const isMetaKey = metaKey || ctrlKey;
240+
241+
if ('nativeEvent' in event) {
242+
const target = event.nativeEvent.target as EventTarget | null;
243+
if (isTextSelectionTarget(target) && hasActiveTextSelection()) {
244+
return;
245+
}
246+
247+
if (event.nativeEvent instanceof MouseEvent && (shiftKey || isMetaKey)) {
248+
event.preventDefault();
249+
}
250+
}
251+
252+
let nextSelection: Set<number>;
253+
if (shiftKey && selectionAnchor !== null) {
254+
const anchor = selectionAnchor ?? index;
255+
const start = Math.min(anchor, index);
256+
const end = Math.max(anchor, index);
257+
const rangeIds = filteredLogs.slice(start, end + 1).map(log => log.id);
258+
nextSelection = new Set(rangeIds);
259+
} else if (shiftKey && selectionAnchor === null) {
260+
nextSelection = new Set([logId]);
261+
} else if (isMetaKey) {
262+
nextSelection = new Set(selectedIds);
263+
if (nextSelection.has(logId)) {
264+
nextSelection.delete(logId);
265+
} else {
266+
nextSelection.add(logId);
267+
}
268+
} else {
269+
nextSelection = new Set([logId]);
270+
}
271+
272+
setSelectedIds(nextSelection);
273+
if (!shiftKey || selectionAnchor === null) {
274+
setSelectionAnchor(index);
275+
}
276+
}, [filteredLogs, hasActiveTextSelection, isTextSelectionTarget, selectedIds, selectionAnchor]);
277+
278+
const handleLogKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>, logId: number, index: number) => {
279+
if (event.key === ' ' || event.key === 'Enter') {
280+
event.preventDefault();
281+
handleLogSelection(event, logId, index);
282+
}
283+
}, [handleLogSelection]);
284+
285+
const handleListKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
286+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
287+
event.preventDefault();
288+
const allIds = filteredLogs.map(log => log.id);
289+
setSelectedIds(new Set(allIds));
290+
if (filteredLogs.length > 0) {
291+
setSelectionAnchor(0);
292+
}
293+
}
294+
295+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
296+
event.preventDefault();
297+
void copySelectedLogs();
298+
}
299+
}, [copySelectedLogs, filteredLogs]);
300+
301+
const handleLogMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>, index: number) => {
302+
if (event.button !== 0) {
303+
return;
304+
}
305+
306+
const { shiftKey, metaKey, ctrlKey } = event;
307+
const hasModifier = shiftKey || metaKey || ctrlKey;
308+
309+
if (isTextSelectionTarget(event.target)) {
310+
if (hasModifier) {
311+
event.preventDefault();
312+
}
313+
return;
314+
}
315+
316+
if (hasModifier) {
317+
event.preventDefault();
318+
return;
319+
}
320+
321+
handleLogSelection(event, filteredLogs[index].id, index);
322+
event.preventDefault();
323+
setIsDragging(true);
324+
setDragStartIndex(index);
325+
setSelectionAnchor(index);
326+
}, [filteredLogs, handleLogSelection, isTextSelectionTarget]);
327+
328+
const handleLogMouseEnter = useCallback((index: number) => {
329+
if (!isDragging || dragStartIndex === null) {
330+
return;
331+
}
332+
333+
const start = Math.min(dragStartIndex, index);
334+
const end = Math.max(dragStartIndex, index);
335+
const rangeIds = filteredLogs.slice(start, end + 1).map(log => log.id);
336+
setSelectedIds(new Set(rangeIds));
337+
}, [dragStartIndex, filteredLogs, isDragging]);
338+
98339
const handleSaveLog = async () => {
99340
addLog('INFO', 'User action: Save log to file.');
100341
const logContent = logs.map(log => `[${log.timestamp}] [${log.level}] ${log.message}`).join('\n');
@@ -162,14 +403,90 @@ const LoggerPanel: React.FC<LoggerPanelProps> = ({ isVisible, onToggleVisibility
162403
</div>
163404
</div>
164405
</header>
165-
<div ref={scrollRef} className="flex-1 px-2 py-2 overflow-y-auto font-mono text-[11px] space-y-1.5">
166-
{filteredLogs.map(log => (
167-
<div key={log.id} className="flex items-start gap-2">
168-
<span className={`${logLevelClasses[log.level].text} text-[10px] opacity-80`}>{renderHighlighted(log.timestamp)}</span>
169-
<span className={`px-1 py-0.5 rounded-full text-[10px] font-semibold border ${logLevelClasses[log.level].bg} ${logLevelClasses[log.level].border} ${logLevelClasses[log.level].text}`}>{log.level}</span>
170-
<span className={`flex-1 ${logLevelClasses[log.level].text} whitespace-pre-wrap break-words leading-relaxed`}>{renderHighlighted(log.message)}</span>
171-
</div>
172-
))}
406+
<div className="px-2 py-1.5 border-b border-border-color bg-secondary/70 flex flex-wrap items-center justify-between gap-2 text-[10px]">
407+
<div className="flex items-center gap-2 flex-wrap">
408+
<span className="text-text-secondary">{selectedCount} selected</span>
409+
<button
410+
type="button"
411+
onClick={() => { addLog('INFO', 'User action: Copy logs to clipboard.'); void copySelectedLogs(); }}
412+
disabled={selectedCount === 0}
413+
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-border-color bg-background text-text-main transition-colors hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed"
414+
aria-disabled={selectedCount === 0}
415+
>
416+
{copyStatus === 'success' ? (
417+
<CheckIcon className="w-3.5 h-3.5 text-primary" />
418+
) : (
419+
<CopyIcon className="w-3.5 h-3.5" />
420+
)}
421+
<span>{copyStatus === 'success' ? 'Copied!' : 'Copy Selected'}</span>
422+
</button>
423+
<span role="status" aria-live="polite" className="text-text-secondary">
424+
{copyStatus === 'error' && 'Copy failed. Try again.'}
425+
</span>
426+
</div>
427+
<div className="flex items-center gap-3 flex-wrap">
428+
<label className="inline-flex items-center gap-1 cursor-pointer select-none">
429+
<input
430+
type="checkbox"
431+
checked={includeTimestamp}
432+
onChange={(event) => setIncludeTimestamp(event.target.checked)}
433+
className="h-3 w-3 rounded border-border-color text-primary focus:ring-primary"
434+
/>
435+
<span className="text-text-secondary">Timestamps</span>
436+
</label>
437+
<label className="inline-flex items-center gap-1 cursor-pointer select-none">
438+
<input
439+
type="checkbox"
440+
checked={includeLevel}
441+
onChange={(event) => setIncludeLevel(event.target.checked)}
442+
className="h-3 w-3 rounded border-border-color text-primary focus:ring-primary"
443+
/>
444+
<span className="text-text-secondary">Levels</span>
445+
</label>
446+
<label className="inline-flex items-center gap-1 cursor-pointer select-none">
447+
<input
448+
type="checkbox"
449+
checked={preserveLineBreaks}
450+
onChange={(event) => setPreserveLineBreaks(event.target.checked)}
451+
className="h-3 w-3 rounded border-border-color text-primary focus:ring-primary"
452+
/>
453+
<span className="text-text-secondary">Preserve line breaks</span>
454+
</label>
455+
</div>
456+
</div>
457+
<div
458+
ref={scrollRef}
459+
className="flex-1 px-2 py-2 overflow-y-auto font-mono text-[11px] space-y-1.5"
460+
role="listbox"
461+
aria-multiselectable="true"
462+
tabIndex={0}
463+
onKeyDown={handleListKeyDown}
464+
>
465+
{filteredLogs.map((log, index) => {
466+
const isSelected = selectedIds.has(log.id);
467+
return (
468+
<div
469+
key={log.id}
470+
role="option"
471+
aria-selected={isSelected}
472+
tabIndex={0}
473+
onClick={(event) => handleLogSelection(event, log.id, index)}
474+
onKeyDown={(event) => handleLogKeyDown(event, log.id, index)}
475+
onMouseDown={(event) => handleLogMouseDown(event, index)}
476+
onMouseEnter={() => handleLogMouseEnter(index)}
477+
className={`flex items-start gap-2 rounded-md px-2 py-1.5 transition-colors cursor-pointer border focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary ${isSelected ? 'bg-primary/15 border-primary/60 ring-1 ring-primary/50' : 'border-transparent hover:bg-border-color/40 focus-visible:bg-border-color/40'}`}
478+
>
479+
<span className={`${logLevelClasses[log.level].text} text-[10px] opacity-80`}>{renderHighlighted(log.timestamp)}</span>
480+
<span className={`px-1 py-0.5 rounded-full text-[10px] font-semibold border ${logLevelClasses[log.level].bg} ${logLevelClasses[log.level].border} ${logLevelClasses[log.level].text}`}>{log.level}</span>
481+
<span
482+
data-text-selectable="true"
483+
className={`flex-1 ${logLevelClasses[log.level].text} whitespace-pre-wrap break-words leading-relaxed`}
484+
>
485+
{renderHighlighted(log.message)}
486+
</span>
487+
</div>
488+
);
489+
})}
173490
{filteredLogs.length === 0 && (
174491
<div className="flex items-center justify-center h-full text-text-secondary text-[11px]">
175492
No logs to display.

0 commit comments

Comments
 (0)