11import React , { useState , useMemo , useRef , useEffect , useCallback } from 'react' ;
22import { useLogger } from '../hooks/useLogger' ;
33import { 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.
66import { storageService } from '../services/storageService' ;
77import 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