dwipe 2.0.2__py3-none-any.whl → 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dwipe/DiskWipe.py CHANGED
@@ -15,16 +15,18 @@ import threading
15
15
  import json
16
16
  import curses as cs
17
17
  from types import SimpleNamespace
18
+ from datetime import datetime
18
19
  from console_window import (ConsoleWindow, ConsoleWindowOpts, OptionSpinner,
19
20
  IncrementalSearchBar, InlineConfirmation, Theme,
20
21
  Screen, ScreenStack, Context)
21
22
 
22
23
  from .WipeJob import WipeJob
23
24
  from .DeviceInfo import DeviceInfo
25
+ from .DeviceWorker import ProbeState
24
26
  from .Utils import Utils
25
27
  from .PersistentState import PersistentState
26
28
  from .StructuredLogger import StructuredLogger
27
- from .LsblkMonitor import LsblkMonitor
29
+ from .DeviceChangeMonitor import DeviceChangeMonitor
28
30
 
29
31
  # Screen constants
30
32
  MAIN_ST = 0
@@ -38,9 +40,26 @@ class DiskWipe:
38
40
  """Main application controller and UI manager"""
39
41
  singleton = None
40
42
 
41
- def __init__(self, opts=None):
43
+ def __init__(self, opts=None, persistent_state=None):
42
44
  DiskWipe.singleton = self
43
45
  self.opts = opts if opts else SimpleNamespace(debug=0)
46
+ # Use provided persistent_state or create a new one
47
+ self.persistent_state = persistent_state if persistent_state else PersistentState()
48
+ # Set defaults for command-line options (only if not provided)
49
+ if not hasattr(self.opts, 'wipe_mode'):
50
+ self.opts.wipe_mode = '+V'
51
+ if not hasattr(self.opts, 'passes'):
52
+ self.opts.passes = 1
53
+ if not hasattr(self.opts, 'verify_pct'):
54
+ self.opts.verify_pct = 1
55
+ if not hasattr(self.opts, 'port_serial'):
56
+ self.opts.port_serial = 'Auto'
57
+ if not hasattr(self.opts, 'slowdown_stop'):
58
+ self.opts.slowdown_stop = 64
59
+ if not hasattr(self.opts, 'stall_timeout'):
60
+ self.opts.stall_timeout = 60
61
+ if not hasattr(self.opts, 'dense'):
62
+ self.opts.dense = False
44
63
  self.mounts_lines = None
45
64
  self.partitions = {} # a dict of namespaces keyed by name
46
65
  self.wids = None
@@ -69,9 +88,6 @@ class DiskWipe:
69
88
  on_cancel=self._on_filter_cancel
70
89
  )
71
90
 
72
- # Initialize persistent state
73
- self.persistent_state = PersistentState()
74
-
75
91
  def _start_wipe(self):
76
92
  """Start the wipe job after confirmation"""
77
93
  if self.confirmation.identity and self.confirmation.identity in self.partitions:
@@ -81,7 +97,8 @@ class DiskWipe:
81
97
  delattr(part, 'verify_failed_msg')
82
98
 
83
99
  # Get the wipe type from user's choice
84
- wipe_type = self.confirmation.input_buffer.strip()
100
+ # ConsoleWindow already canonicalizes case, just strip the '*' recommendation marker
101
+ wipe_type = self.confirmation.input_buffer.strip().rstrip('*')
85
102
 
86
103
  # Check if it's a firmware wipe
87
104
  if wipe_type not in ('Zero', 'Rand'):
@@ -92,11 +109,14 @@ class DiskWipe:
92
109
  self.win.passthrough_mode = False
93
110
  return
94
111
 
95
- # Get command args from hw_caps
96
- command_args = part.hw_caps[wipe_type]
112
+ # Get command args for this wipe type
113
+ from .DrivePreChecker import DrivePreChecker
114
+ command_args = DrivePreChecker.get_wipe_command_args(wipe_type)
97
115
 
98
116
  # Import firmware task classes
99
- from .FirmwareWipeTask import NvmeWipeTask, SataWipeTask
117
+ from .FirmwareWipeTask import (NvmeWipeTask, SataWipeTask,
118
+ StandardPrecheckTask,
119
+ FirmwarePreVerifyTask, FirmwarePostVerifyTask)
100
120
 
101
121
  # Determine task type based on device name
102
122
  if part.name.startswith('nvme'):
@@ -105,7 +125,7 @@ class DiskWipe:
105
125
  task_class = SataWipeTask
106
126
 
107
127
  # Create firmware task
108
- task = task_class(
128
+ fw_task = task_class(
109
129
  device_path=f'/dev/{part.name}',
110
130
  total_size=part.size_bytes,
111
131
  opts=self.opts,
@@ -116,13 +136,39 @@ class DiskWipe:
116
136
  # Store wipe type for logging
117
137
  part.wipe_type = wipe_type
118
138
 
119
- # Create WipeJob with single firmware task
139
+ # Build task list with precheck, pre/post firmware verification
140
+ # (Software verify is not supported for firmware wipes)
141
+ precheck = StandardPrecheckTask(
142
+ device_path=f'/dev/{part.name}',
143
+ total_size=part.size_bytes,
144
+ opts=self.opts,
145
+ selected_wipe_type=wipe_type,
146
+ command_method=command_args
147
+ )
148
+
149
+ pre_verify = FirmwarePreVerifyTask(
150
+ device_path=f'/dev/{part.name}',
151
+ total_size=part.size_bytes,
152
+ opts=self.opts
153
+ )
154
+
155
+ post_verify = FirmwarePostVerifyTask(
156
+ device_path=f'/dev/{part.name}',
157
+ total_size=part.size_bytes,
158
+ opts=self.opts
159
+ )
160
+
161
+ tasks = [precheck, pre_verify, fw_task, post_verify]
162
+
163
+ # Create WipeJob with firmware task (and optional verify task)
120
164
  part.job = WipeJob(
121
165
  device_path=f'/dev/{part.name}',
122
166
  total_size=part.size_bytes,
123
167
  opts=self.opts,
124
- tasks=[task]
168
+ tasks=tasks
125
169
  )
170
+ # Firmware wipes (SATA secure erase, NVMe sanitize) write zeros
171
+ part.job.expected_pattern = "zeroed"
126
172
  part.job.thread = threading.Thread(target=part.job.run_tasks)
127
173
  part.job.thread.start()
128
174
 
@@ -152,6 +198,7 @@ class DiskWipe:
152
198
  part.size_bytes, opts=self.opts)
153
199
  self.job_cnt += 1
154
200
  self.set_state(part, to='0%')
201
+ part.hw_nopes, part.hw_caps = {}, {}
155
202
  finally:
156
203
  # Restore original wipe_mode
157
204
  self.opts.wipe_mode = old_wipe_mode
@@ -164,6 +211,14 @@ class DiskWipe:
164
211
  """Start the verify job after confirmation"""
165
212
  if self.confirmation.identity and self.confirmation.identity in self.partitions:
166
213
  part = self.partitions[self.confirmation.identity]
214
+
215
+ # Safety check: Firmware wipes have built-in verification - prevent manual verify
216
+ if self._is_firmware_wipe_marker(part):
217
+ part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
218
+ self.confirmation.cancel()
219
+ self.win.passthrough_mode = False
220
+ return
221
+
167
222
  # Clear any previous verify failure message when starting verify
168
223
  if hasattr(part, 'verify_failed_msg'):
169
224
  delattr(part, 'verify_failed_msg')
@@ -188,6 +243,38 @@ class DiskWipe:
188
243
 
189
244
  return result
190
245
 
246
+ def _is_firmware_wipe(self, part):
247
+ """Check if partition has an active firmware wipe job (unstoppable)."""
248
+ if not part.job or part.job.done:
249
+ return False
250
+ from .FirmwareWipeTask import FirmwareWipeTask
251
+ current_task = getattr(part.job, 'current_task', None)
252
+ return current_task and isinstance(current_task, FirmwareWipeTask)
253
+
254
+ def _is_firmware_wipe_marker(self, part):
255
+ """Check if partition's wipe marker indicates a firmware wipe.
256
+
257
+ Firmware wipes have modes like 'Crypto', 'Enhanced', 'Sanitize-Crypto', etc.
258
+ Software wipes have modes like 'Zero' or 'Rand'.
259
+ """
260
+ if part.state not in ('s', 'W'):
261
+ return False
262
+
263
+ marker = WipeJob.read_marker_buffer(part.name)
264
+ if not marker:
265
+ return False
266
+
267
+ # Check if mode is a firmware wipe (not 'Zero' or 'Rand')
268
+ mode = getattr(marker, 'mode', None)
269
+ return mode and mode not in ('Zero', 'Rand')
270
+
271
+ def _has_any_firmware_wipes(self):
272
+ """Check if any firmware wipes are currently running."""
273
+ for part in self.partitions.values():
274
+ if self._is_firmware_wipe(part):
275
+ return True
276
+ return False
277
+
191
278
  def do_key(self, key):
192
279
  """Handle keyboard input"""
193
280
  if self.exit_when_no_jobs:
@@ -253,15 +340,7 @@ class DiskWipe:
253
340
  line += ' [S]top' if self.job_cnt > 0 else ''
254
341
  line = f'{line:<20} '
255
342
  line += self.filter_bar.get_display_string(prefix=' /') or ' /'
256
- # Show mode spinner with key
257
- line += f' [m]ode={self.opts.wipe_mode}'
258
- # Show passes spinner with key
259
- line += f' [P]ass={self.opts.passes}'
260
- # Show verification percentage spinner with key
261
- line += f' [V]pct={self.opts.verify_pct}%'
262
- line += f' [p]ort={self.opts.port_serial}'
263
- # line += ' !:scan [h]ist [t]heme ?:help [q]uit'
264
- line += ' [h]ist [t]heme ?:help [q]uit'
343
+ line += ' [r]escan [h]ist [t]heme ?:help [q]uit'
265
344
  return line[1:]
266
345
 
267
346
  def get_actions(self, part):
@@ -272,22 +351,27 @@ class DiskWipe:
272
351
  part = ctx.partition
273
352
  name = part.name
274
353
  self.pick_is_running = bool(part.job)
275
- if self.test_state(part, to='STOP'):
354
+ # Show stop action only for non-firmware wipes
355
+ if self.test_state(part, to='STOP') and not self._is_firmware_wipe(part):
276
356
  actions['s'] = 'stop'
277
357
  elif self.test_state(part, to='0%'):
278
358
  actions['w'] = 'wipe'
279
- if part.parent is None:
359
+ # DEL only for whole disks that are SATA or NVMe
360
+ if part.parent is None and part.name[:2] in ('sd', 'hd', 'nv'):
280
361
  actions['DEL'] = 'DEL'
281
362
  # Can verify:
282
- # 1. Anything with wipe markers (states 's' or 'W')
363
+ # 1. Anything with wipe markers (states 's' or 'W') - EXCEPT firmware wipes
283
364
  # 2. Unmarked whole disks (no parent, state '-' or '^') WITHOUT partitions that have filesystems
284
365
  # 3. Unmarked partitions without filesystems (has parent, state '-' or '^', no fstype)
285
366
  # 4. Only if verify_pct > 0
286
367
  # This prevents verifying filesystems which is nonsensical
368
+ # NOTE: Firmware wipes have built-in pre/post verification, so manual verify is disallowed
287
369
  verify_pct = getattr(self.opts, 'verify_pct', 0)
288
370
  if not part.job and verify_pct > 0:
289
371
  if part.state in ('s', 'W'):
290
- actions['v'] = 'verify'
372
+ # Do NOT allow verify on firmware wipes - they have built-in verification
373
+ if not self._is_firmware_wipe_marker(part):
374
+ actions['v'] = 'verify'
291
375
  elif part.state in ('-', '^'):
292
376
  # For whole disks (no parent), only allow verify if no partitions have filesystems
293
377
  # For partitions, only allow if no filesystem
@@ -344,19 +428,47 @@ class DiskWipe:
344
428
  """ Look for wipeable disks w/o hardware info """
345
429
  if not self.dev_info:
346
430
  return
431
+ from .DeviceWorker import ProbeState
347
432
  for ns in self.partitions.values():
348
433
  if ns.parent:
349
434
  continue
350
- if ns.port.startswith('USB'):
351
- continue
435
+ # if ns.port.startswith('USB'):
436
+ # continue
352
437
  if ns.name[:2] not in ('nv', 'sd', 'hd'):
353
438
  continue
354
- if ns.hw_nopes or ns.hw_caps: # already done
439
+ # Skip if already successfully probed (READY state) with actual results
440
+ # But re-probe if results are empty (unknown after wipe) or if probe FAILED
441
+ if ns.hw_caps_state == ProbeState.READY and (ns.hw_caps or ns.hw_nopes):
442
+ continue # Already have results
443
+ # Device must not be actively wiping (to avoid blocking)
444
+ if ns.job:
355
445
  continue
356
- if self.test_state(ns, to='0%'):
446
+ # Don't probe mounted/blocked devices
447
+ if ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
448
+ continue
449
+ # Re-probe after wipe: worker state is still READY, need force
450
+ if not (ns.hw_caps or ns.hw_nopes):
451
+ ns.hw_caps_state = ProbeState.PENDING
452
+ self.worker_manager.request_hw_caps(ns.name, force=True)
453
+ self.dev_info.get_hw_capabilities(ns)
454
+
455
+ def _poll_hw_caps_updates(self):
456
+ """Poll for hw_caps probe completion without full device refresh.
457
+
458
+ Called every 0.25 seconds in main loop to quickly show hw_caps
459
+ results as soon as worker threads complete probes.
460
+ """
461
+ if not self.dev_info:
462
+ return
463
+ from .DeviceWorker import ProbeState
464
+ # Only update devices that are still probing
465
+ for ns in self.partitions.values():
466
+ if ns.parent:
467
+ continue
468
+ # Update any device that's still PENDING or PROBING
469
+ if ns.hw_caps_state in (ProbeState.PENDING, ProbeState.PROBING):
357
470
  self.dev_info.get_hw_capabilities(ns)
358
471
 
359
-
360
472
  def main_loop(self):
361
473
  """Main event loop"""
362
474
 
@@ -379,22 +491,25 @@ class DiskWipe:
379
491
  pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
380
492
  ctrl_c_terminates=False,
381
493
  )
382
- lsblk_monitor = LsblkMonitor(check_interval=0.2)
383
- lsblk_monitor.start()
384
- print("Starting first lsblk...")
494
+ device_monitor = DeviceChangeMonitor(check_interval=1.0)
495
+ device_monitor.start()
496
+ print("Discovering devices...")
497
+ # Create persistent worker manager for hw_caps probing
498
+ # This is reused across device refreshes to allow probes to complete
499
+ from .DeviceWorker import DeviceWorkerManager
500
+ from .DrivePreChecker import DrivePreChecker
501
+ worker_manager = DeviceWorkerManager(DrivePreChecker())
385
502
  # Initialize device info and pick range before first draw
386
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
387
- lsblk_output = None
388
- while not lsblk_output:
389
- lsblk_output = lsblk_monitor.get_and_clear()
390
- self.partitions = info.assemble_partitions(self.partitions, lsblk_output)
391
- if lsblk_output:
392
- # print(lsblk_output, '\n\n')
393
- print('got ... got lsblk result')
394
- if self.opts.dump_lsblk:
395
- DeviceInfo.dump(self.partitions, title="after assemble_partitions")
396
- exit(1)
397
- time.sleep(0.2)
503
+ info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
504
+ worker_manager=worker_manager)
505
+ self.partitions = info.assemble_partitions(self.partitions)
506
+ # Start probing hw_caps immediately instead of waiting for first 3s refresh
507
+ self.dev_info = info
508
+ self.worker_manager = worker_manager
509
+ self.get_hw_caps_when_needed()
510
+ if self.opts.dump_lsblk:
511
+ DeviceInfo.dump(self.partitions, title="after assemble_partitions")
512
+ exit(1)
398
513
 
399
514
  self.win = ConsoleWindow(opts=win_opts)
400
515
  # Initialize screen stack
@@ -403,12 +518,8 @@ class DiskWipe:
403
518
  spin = self.spin = OptionSpinner(stack=self.stack)
404
519
  spin.default_obj = self.opts
405
520
  spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
406
- spin.add_key('port_serial', 'p - disk port info', vals=['Auto', 'On', 'Off'])
407
- spin.add_key('slowdown_stop', 'W - stop if disk slows Nx', vals=[64, 256, 0, 4, 16])
408
- spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
409
- spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
410
- spin.add_key('passes', 'P - wipe pass count', vals=[1, 2, 4])
411
- spin.add_key('wipe_mode', 'm - wipe mode', vals=['-V', '+V'])
521
+ spin.add_key('hist_time_format', 'a - time format',
522
+ vals=['ago+time', 'ago', 'time'], scope=LOG_ST)
412
523
 
413
524
  spin.add_key('quit', 'q,x - quit program', keys='qx', genre='action')
414
525
  spin.add_key('screen_escape', 'ESC- back one screen',
@@ -419,9 +530,10 @@ class DiskWipe:
419
530
  spin.add_key('verify', 'v - verify device', genre='action')
420
531
  spin.add_key('stop', 's - stop wipe', genre='action')
421
532
  spin.add_key('block', 'b - block/unblock disk', genre='action')
422
- spin.add_key('delete_device', 'DEL - remove disk from lsblk',
533
+ spin.add_key('delete_device', 'DEL - remove/unbind disk from system',
423
534
  genre='action', keys=(cs.KEY_DC))
424
- spin.add_key('scan_all_devices', '! - rescan all devices', genre='action')
535
+ spin.add_key('scan_all_devices', 'r - rescan devices and recheck capabilities',
536
+ genre='action', scope=MAIN_ST)
425
537
  spin.add_key('stop_all', 'S - stop ALL wipes', genre='action')
426
538
  spin.add_key('help', '? - show help screen', genre='action')
427
539
  spin.add_key('history', 'h - show wipe history', genre='action')
@@ -430,21 +542,21 @@ class DiskWipe:
430
542
  spin.add_key('spin_theme', 't - theme', genre='action', scope=THEME_ST)
431
543
  spin.add_key('header_mode', '_ - header style', vals=['Underline', 'Reverse', 'Off'])
432
544
  spin.add_key('expand', 'e - expand history entry', genre='action', scope=LOG_ST)
545
+ spin.add_key('copy', 'c - copy entry to clipboard', genre='action', scope=LOG_ST)
433
546
  spin.add_key('show_keys', 'K - show keys (demo mode)', genre='action')
434
- self.opts.theme = ''
435
- self.persistent_state.restore_updated_opts(self.opts)
547
+ # Load theme from persistent state
548
+ self.opts.theme = self.persistent_state.state.get('theme', '')
436
549
  Theme.set(self.opts.theme)
437
550
  self.win.set_handled_keys(self.spin.keys)
438
551
 
439
- # Start background lsblk monitor
552
+ # Background device change monitor started above
440
553
 
441
- self.get_hw_caps_when_needed()
442
- self.dev_info = info
443
554
  pick_range = info.get_pick_range()
444
555
  self.win.set_pick_range(pick_range[0], pick_range[1])
445
556
 
446
557
 
447
558
  check_devices_mono = time.monotonic()
559
+ cached_worker_state = {} # Track marker/hw_caps state to detect updates
448
560
 
449
561
  try:
450
562
  while True:
@@ -459,20 +571,55 @@ class DiskWipe:
459
571
  # Handle actions using perform_actions
460
572
  self.stack.perform_actions(spin)
461
573
 
462
- # Check for new lsblk data from background monitor
463
- lsblk_output = lsblk_monitor.get_and_clear()
464
- time_since_refresh = time.monotonic() - check_devices_mono
465
-
466
- if lsblk_output or time_since_refresh > 3.0:
467
- # Refresh if: device changes detected OR periodic refresh (3s default)
468
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
469
- self.partitions = info.assemble_partitions(self.partitions, lsblk_output=lsblk_output)
470
- self.get_hw_caps_when_needed()
574
+ # Poll for hw_caps completion without full device refresh (every 0.25s)
575
+ # This lets us show results quickly even though commands take 1-3 seconds
576
+ self._poll_hw_caps_updates()
577
+
578
+ # Detect suspend/resume: loop runs every ~0.25s, so a large
579
+ # gap in monotonic time means the system was likely suspended
580
+ now_mono = time.monotonic()
581
+ loop_gap = now_mono - check_devices_mono
582
+ if loop_gap > 5.0 and hasattr(self, '_last_loop_mono'):
583
+ suspend_gap = now_mono - self._last_loop_mono
584
+ if suspend_gap > 5.0:
585
+ self.win.flash(f'Suspend detected ({suspend_gap:.0f}s gap), rescanning...', duration=1.5)
586
+ self.screens[MAIN_ST].scan_all_devices_ACTION()
587
+ self._last_loop_mono = now_mono
588
+
589
+ # Check for device changes from background monitor
590
+ devices_changed = device_monitor.get_and_clear()
591
+ time_since_refresh = now_mono - check_devices_mono
592
+
593
+ # Build current worker state for comparison
594
+ current_worker_state = {}
595
+ if self.worker_manager:
596
+ for device_name, part in self.partitions.items():
597
+ current_worker_state[device_name] = {
598
+ 'marker': part.marker,
599
+ 'hw_caps_state': part.hw_caps_state
600
+ }
601
+ # Check if worker has any updates (markers or hw_caps changes)
602
+ worker_has_updates = (
603
+ self.worker_manager.has_updates(cached_worker_state)
604
+ )
605
+ else:
606
+ worker_has_updates = False
607
+
608
+ if (devices_changed or worker_has_updates or
609
+ time_since_refresh > 3.0):
610
+ # Refresh if: device changes, worker updates, OR periodic (3s default)
611
+ info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
612
+ worker_manager=self.worker_manager)
613
+ self.partitions = info.assemble_partitions(self.partitions)
471
614
  self.dev_info = info
472
615
  # Update pick range to highlight NAME through SIZE fields
473
616
  pick_range = info.get_pick_range()
474
617
  self.win.set_pick_range(pick_range[0], pick_range[1])
618
+ # Probe hw_caps for devices that need it (only once per refresh, not every draw)
619
+ self.get_hw_caps_when_needed()
475
620
  check_devices_mono = time.monotonic()
621
+ # Update cached state after refresh
622
+ cached_worker_state = current_worker_state.copy()
476
623
 
477
624
  # Save any persistent state changes
478
625
  self.persistent_state.save_updated_opts(self.opts)
@@ -481,7 +628,10 @@ class DiskWipe:
481
628
  self.win.clear()
482
629
  finally:
483
630
  # Clean up monitor thread on exit
484
- lsblk_monitor.stop()
631
+ device_monitor.stop()
632
+ # Clean up persistent worker manager
633
+ if hasattr(self, 'worker_manager') and self.worker_manager:
634
+ self.worker_manager.stop_all()
485
635
 
486
636
  class DiskWipeScreen(Screen):
487
637
  """ TBD """
@@ -505,35 +655,30 @@ class MainScreen(DiskWipeScreen):
505
655
  self.persist_port_serial = set()
506
656
 
507
657
 
508
- def _port_serial_line(self, partition):
658
+ def _port_serial_line(self, partition, has_children=True):
509
659
  wids = self.app.wids
510
- wid = wids.state if wids else 5
511
- sep, key_str = ' ', ''
512
- port, serial = partition.port, partition.serial
513
- if partition.hw_caps or partition.hw_nopes:
514
- lead = 'CAPS' if partition.hw_caps else 'ERRS'
515
- infos = partition.hw_caps if partition.hw_caps else partition.hw_nopes
516
- key_str = f' Fw{lead}: ' + ','.join(list(infos.keys()))
517
- return f'{"":>{wid}}{sep}│ └────── {port:<12} {serial}{key_str}'
660
+ sep = ' '
661
+ # Sanitize port/serial - some USB bridges return strings with embedded nulls
662
+ port = partition.port.replace('\x00', '') if partition.port else ''
663
+ serial = partition.serial.replace('\x00', '') if partition.serial else ''
518
664
 
519
- def draw_screen(self):
520
- """Draw the main device list"""
521
- app = self.app
665
+ # Get column widths (with defaults if wids not yet initialized)
666
+ wid_state = wids.state if wids else 5
522
667
 
523
- def wanted(name):
524
- return not app.filter or app.filter.search(name)
668
+ # Use corner └ if no children below, or vertical │ if there are children to connect to
669
+ connector = '│' if has_children else ' '
525
670
 
526
- app.win.set_pick_mode(True)
527
- if app.opts.port_serial != 'Auto':
528
- self.persist_port_serial = set() # name of disks
529
- else: # if the disk goes away, clear persistence
530
- for name in list(self.persist_port_serial):
531
- if name not in app.partitions:
532
- self.persist_port_serial.discard(name)
671
+ # Build base line: state padding + connector + port/serial
672
+ base = f'{"":>{wid_state}}{sep}{connector} └────── {port:<12} {serial}'
533
673
 
534
- # First pass: process jobs and collect visible partitions
535
- visible_partitions = []
536
- for name, partition in app.partitions.items():
674
+ return base
675
+
676
+ def do_job_maintenance(self):
677
+ """ Check all the jobs in progress and advance their state
678
+ appropriately.
679
+ """
680
+ app = self.app
681
+ for _, partition in app.partitions.items():
537
682
  partition.line = None
538
683
  if partition.job:
539
684
  if partition.job.done:
@@ -629,6 +774,7 @@ class MainScreen(DiskWipeScreen):
629
774
  partition.job = None
630
775
  partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
631
776
  partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
777
+ partition.monitor_marker = False # Stop monitoring verify job
632
778
  else:
633
779
  # Wipe job completed (with or without auto-verify)
634
780
  # Check if stopped during verify phase (after successful write)
@@ -716,8 +862,9 @@ class MainScreen(DiskWipeScreen):
716
862
  partition.job = None
717
863
  partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
718
864
  partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
865
+ partition.monitor_marker = True # Start monitoring the new marker
719
866
  if partition.job:
720
- elapsed, pct, rate, until = partition.job.get_status()
867
+ elapsed, pct, rate, until, more_state = partition.job.get_status()
721
868
 
722
869
  # Get task display name (Zero, Rand, Crypto, Verify, etc.)
723
870
  task_name = ""
@@ -731,26 +878,76 @@ class MainScreen(DiskWipeScreen):
731
878
  partition.mounts = [f'{task_name} {pct} {elapsed} -{until} {rate}']
732
879
  else:
733
880
  partition.mounts = [f'{task_name} {pct} {elapsed}']
881
+ if more_state:
882
+ partition.mounts[0] += f' {more_state}'
734
883
  else:
735
884
  partition.state = pct
736
885
  # Build progress line with task name
737
886
  progress_parts = [task_name, elapsed, f'-{until}', rate]
738
887
 
739
- # Only show slowdown/stall if job tracks these metrics
740
- # (WriteTask does, VerifyTask and FirmwareWipeTask don't)
741
- if hasattr(partition.job, 'max_slowdown_ratio') and hasattr(partition.job, 'max_stall_secs'):
888
+ # Only show slowdown/stall for WriteTask (not VerifyTask or FirmwareWipeTask)
889
+ from .FirmwareWipeTask import FirmwareWipeTask
890
+ from .WriteTask import WriteTask
891
+ current_task = partition.job.current_task
892
+ if current_task and isinstance(current_task, WriteTask) and not isinstance(current_task, FirmwareWipeTask):
742
893
  slowdown = partition.job.max_slowdown_ratio
743
894
  stall = partition.job.max_stall_secs
744
895
  progress_parts.extend([f'÷{slowdown}', f'𝚫{Utils.ago_str(stall)}'])
745
896
 
897
+ if more_state:
898
+ progress_parts.append(more_state)
899
+
746
900
  partition.mounts = [' '.join(progress_parts)]
747
901
 
748
902
  if partition.parent and partition.parent in app.partitions and (
749
- app.partitions[partition.parent].state == 'Blk'):
903
+ app.partitions[partition.parent].state in ('Blk', 'iBlk')):
750
904
  continue
751
905
 
752
- if wanted(name) or partition.job:
753
- visible_partitions.append(partition)
906
+
907
+ def draw_screen(self):
908
+ """Draw the main device list"""
909
+ app = self.app
910
+
911
+ def wanted(name):
912
+ return not app.filter or app.filter.search(name)
913
+
914
+ self.do_job_maintenance()
915
+
916
+ app.win.set_pick_mode(True)
917
+ if app.opts.port_serial != 'Auto':
918
+ self.persist_port_serial = set() # name of disks
919
+ else: # if the disk goes away, clear persistence
920
+ for name in list(self.persist_port_serial):
921
+ if name not in app.partitions:
922
+ self.persist_port_serial.discard(name)
923
+
924
+ # process jobs and collect visible partitions, sorted by disk then partition
925
+ visible_partitions = []
926
+ # Get disks sorted alphabetically
927
+ disks = sorted([p for p in app.partitions.values() if p.parent is None],
928
+ key=lambda p: p.name)
929
+ for disk in disks:
930
+ if wanted(disk.name) or disk.job:
931
+ visible_partitions.append(disk)
932
+ # Add partitions for this disk, sorted alphabetically
933
+ parts = sorted([p for p in app.partitions.values() if p.parent == disk.name],
934
+ key=lambda p: p.name)
935
+ # If disk is directly blocked, hide children and aggregate their mounts
936
+ if disk.state == 'Blk':
937
+ # Collect all mounts from children, sort with "/" first (by name/length)
938
+ all_mounts = []
939
+ for part in parts:
940
+ all_mounts.extend(part.mounts)
941
+ # Sort: "/" first, then by name (implicitly by length since "/" is shortest)
942
+ all_mounts.sort(key=lambda m: (m != '/', m))
943
+ disk.aggregated_mounts = all_mounts
944
+ # Skip adding children to visible list
945
+ continue
946
+ else:
947
+ disk.aggregated_mounts = None
948
+ for part in parts:
949
+ if wanted(part.name) or part.job:
950
+ visible_partitions.append(part)
754
951
 
755
952
  # Re-infer parent states (like 'Busy') after updating child job states
756
953
  DeviceInfo.set_all_states(app.partitions)
@@ -781,7 +978,16 @@ class MainScreen(DiskWipeScreen):
781
978
  # Create context with partition reference
782
979
  ctx = Context(genre='disk' if partition.parent is None else 'partition',
783
980
  partition=partition)
784
- app.win.add_body(partition.line, attr=attr, context=ctx)
981
+ # For disks, underline just the alphanumeric part of fw capability text
982
+ fw_ul = getattr(partition, '_fw_underline', None)
983
+ if fw_ul:
984
+ ul_start, ul_end = fw_ul
985
+ app.win.add_body(partition.line[:ul_start], attr=attr, context=ctx)
986
+ ul_attr = (attr or cs.A_NORMAL) | cs.A_UNDERLINE
987
+ app.win.add_body(partition.line[ul_start:ul_end], attr=ul_attr, resume=True)
988
+ app.win.add_body(partition.line[ul_end:], attr=attr, resume=True)
989
+ else:
990
+ app.win.add_body(partition.line, attr=attr, context=ctx)
785
991
  if partition.parent is None and app.opts.port_serial != 'Off':
786
992
  doit = bool(app.opts.port_serial == 'On')
787
993
  if not doit:
@@ -790,16 +996,19 @@ class MainScreen(DiskWipeScreen):
790
996
  doit = True
791
997
  self.persist_port_serial.add(partition.name)
792
998
  if doit:
793
- line = self._port_serial_line(partition)
794
- app.win.add_body(line, attr=attr, context=Context(genre='DECOR'))
999
+ # Check if this disk has any visible child partitions
1000
+ has_children = partition.name in parent_last_child
1001
+ line = self._port_serial_line(partition, has_children)
1002
+ port_attr = (attr or cs.A_NORMAL) & ~cs.A_BOLD
1003
+ app.win.add_body(line, attr=port_attr, context=Context(genre='DECOR'))
795
1004
 
796
1005
  # Show inline confirmation prompt if this is the partition being confirmed
797
1006
  if app.confirmation.active and app.confirmation.identity == partition.name:
798
1007
  # Build confirmation message
799
1008
  if app.confirmation.action_type == 'wipe':
800
- msg = f'⚠️ WIPE {partition.name}'
1009
+ msg = f'⚠️ WIPE'
801
1010
  else: # verify
802
- msg = f'⚠️ VERIFY {partition.name} [writes marker]'
1011
+ msg = f'⚠️ VERIFY [writes marker]'
803
1012
 
804
1013
  # Add mode-specific prompt (base message without input)
805
1014
  if app.confirmation.mode == 'yes':
@@ -807,11 +1016,11 @@ class MainScreen(DiskWipeScreen):
807
1016
  elif app.confirmation.mode == 'identity':
808
1017
  msg += f" - Type '{partition.name}': "
809
1018
  elif app.confirmation.mode == 'choices':
810
- choices_str = ', '.join(app.confirmation.choices)
811
- msg += f" - Choose ({choices_str}): "
1019
+ choices_str = ','.join(app.confirmation.choices)
1020
+ msg += f" Choice ({choices_str}): "
812
1021
 
813
1022
  # Position message at fixed column (reduced from 28 to 20)
814
- msg = ' ' * 20 + msg
1023
+ msg = ' ' * 5 + msg
815
1024
 
816
1025
  # Add confirmation message base as DECOR (non-pickable)
817
1026
  app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
@@ -863,9 +1072,18 @@ class MainScreen(DiskWipeScreen):
863
1072
  """Handle quit action (q or x key pressed)"""
864
1073
  app = self.app
865
1074
 
1075
+ # Check for firmware wipes - cannot quit while they're running
1076
+ if app._has_any_firmware_wipes():
1077
+ # Show alert - cannot quit during firmware wipe
1078
+ app.filter_bar._text = 'Cannot quit: firmware wipe running'
1079
+ return
1080
+
866
1081
  def stop_if_idle(part):
867
1082
  if part.state[-1] == '%':
868
1083
  if part.job and not part.job.done:
1084
+ # Skip firmware wipes - they cannot be stopped
1085
+ if app._is_firmware_wipe(part):
1086
+ return 1 # Count as running but don't stop
869
1087
  part.job.do_abort = True
870
1088
  return 1 if part.job else 0
871
1089
 
@@ -897,9 +1115,23 @@ class MainScreen(DiskWipeScreen):
897
1115
  if app.test_state(part, to='0%'):
898
1116
  self.clear_hotswap_marker(part)
899
1117
  # Build choices: Zero, Rand, and any firmware wipe types
1118
+ # Sort all by rank (worst to best) with '*' on recommended (last) one
1119
+ from .DrivePreChecker import DrivePreChecker
900
1120
  choices = ['Zero', 'Rand']
1121
+ fw_modes = []
901
1122
  if part.hw_caps:
902
- choices.extend(list(part.hw_caps.keys()))
1123
+ # Strip '*' from hw_caps modes (already has it from display string)
1124
+ fw_modes = [m.strip().rstrip('*') for m in part.hw_caps.split(',')]
1125
+ choices.extend(fw_modes)
1126
+ # Use HDD rankings (prefer software wipes) ONLY if:
1127
+ # 1. Device reports as rotational, AND
1128
+ # 2. Device has no crypto-capable firmware wipes
1129
+ # Note: 'Enhanced' is available on HDDs too (slow overwrite, not crypto)
1130
+ # Only SCrypto/Crypto/FCrypto definitively indicate SSD with crypto
1131
+ is_rotational = getattr(part, 'is_rotational', False)
1132
+ has_crypto_fw = any(m in fw_modes for m in ('SCrypto', 'Crypto', 'FCrypto'))
1133
+ use_hdd_ranking = is_rotational and not has_crypto_fw
1134
+ choices = DrivePreChecker.sort_modes_by_rank(choices, is_rotational=use_hdd_ranking)
903
1135
  app.confirmation.start(action_type='wipe',
904
1136
  identity=part.name, mode='choices', choices=choices)
905
1137
  app.win.passthrough_mode = True
@@ -913,6 +1145,12 @@ class MainScreen(DiskWipeScreen):
913
1145
  # Use get_actions() to ensure we use the same logic as the header display
914
1146
  _, actions = app.get_actions(part)
915
1147
  if 'v' in actions:
1148
+ # Safety check: Prevent verification on firmware wipes
1149
+ # (Firmware wipes have built-in pre/post verification)
1150
+ if self._is_firmware_wipe_marker(part):
1151
+ part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
1152
+ return
1153
+
916
1154
  self.clear_hotswap_marker(part)
917
1155
  # Check if this is an unmarked disk/partition (potential data loss risk)
918
1156
  # Whole disks (no parent) or partitions without filesystems need confirmation
@@ -934,38 +1172,119 @@ class MainScreen(DiskWipeScreen):
934
1172
  def scan_all_devices_ACTION(self):
935
1173
  """ Trigger a re-scan of all devices to make the appear
936
1174
  quicker in the list"""
1175
+ # Show temporary feedback
1176
+ self.app.win.flash('Scanning devices and rechecking firmware capabilities...', duration=0.75)
1177
+
1178
+ # SCSI host rescan (for SATA devices)
937
1179
  base_path = '/sys/class/scsi_host'
938
- if not os.path.exists(base_path):
939
- return
940
- for host in os.listdir(base_path):
941
- scan_file = os.path.join(base_path, host, 'scan')
942
- if os.path.exists(scan_file):
943
- try:
944
- with open(scan_file, 'w', encoding='utf-8') as f:
945
- f.write("- - -")
946
- except Exception:
947
- pass
1180
+ if os.path.exists(base_path):
1181
+ for host in os.listdir(base_path):
1182
+ scan_file = os.path.join(base_path, host, 'scan')
1183
+ if os.path.exists(scan_file):
1184
+ try:
1185
+ with open(scan_file, 'w', encoding='utf-8') as f:
1186
+ f.write("- - -")
1187
+ except Exception:
1188
+ pass
1189
+
1190
+ # Rebind any unbound NVMe devices
1191
+ self._rebind_nvme_devices()
1192
+
1193
+ # Reset hw_caps stickiness for all devices so they'll be re-probed
1194
+ # This allows detecting hardware state changes after sleep/wake cycles
1195
+ if self.app.worker_manager:
1196
+ for partition in self.app.partitions.values():
1197
+ # Queue worker to re-probe this device's capabilities
1198
+ if partition.parent: # only need to do whole disks
1199
+ continue
1200
+ self.app.worker_manager.request_hw_caps(partition.name, force=True)
1201
+ # Clear cached values so UI refreshes with new probing state
1202
+ partition.hw_caps = ''
1203
+ partition.hw_nopes = ''
1204
+ partition.hw_caps_state = ProbeState.PENDING
948
1205
 
949
1206
  def delete_device_ACTION(self):
950
- """ DEL key -- Cause the OS to drop a sata device so it
951
- can be replaced sooner """
1207
+ """ DEL key -- Cause the OS to drop a SATA device or unbind an NVMe device
1208
+ so it can be replaced sooner """
952
1209
  app = self.app
953
1210
  ctx = app.win.get_picked_context()
954
1211
  if ctx and hasattr(ctx, 'partition'):
955
1212
  part = ctx.partition
956
1213
  if not part or part.parent or not app.test_state(part, to='0%'):
957
1214
  return
958
- path = f"/sys/block/{part.name}/device/delete"
959
- if os.path.exists(path):
960
- try:
961
- with open(path, 'w', encoding='utf-8') as f:
962
- f.write("1")
963
- return True
964
- except Exception:
965
- pass
1215
+ # NVMe unbind - write PCI address to driver unbind file
1216
+ if part.name.startswith('nvme'):
1217
+ pci_addr = self._get_nvme_pci_address(part.name)
1218
+ if pci_addr:
1219
+ unbind_path = "/sys/bus/pci/drivers/nvme/unbind"
1220
+ if os.path.exists(unbind_path):
1221
+ try:
1222
+ with open(unbind_path, 'w', encoding='utf-8') as f:
1223
+ f.write(pci_addr)
1224
+ return True
1225
+ except Exception:
1226
+ pass
1227
+ # SATA/IDE delete - write 1 to device delete file
1228
+ else:
1229
+ path = f"/sys/block/{part.name}/device/delete"
1230
+ if os.path.exists(path):
1231
+ try:
1232
+ with open(path, 'w', encoding='utf-8') as f:
1233
+ f.write("1")
1234
+ return True
1235
+ except Exception:
1236
+ pass
1237
+
1238
+ def _get_nvme_pci_address(self, device_name):
1239
+ """Get the full PCI address for an NVMe device (e.g., '0000:01:00.0')
1240
+
1241
+ The sysfs path may contain multiple PCI addresses (bridges), so we need
1242
+ the last one before /nvme/ which is the actual NVMe controller.
1243
+ """
1244
+ try:
1245
+ sysfs_path = f'/sys/class/block/{device_name}'
1246
+ if os.path.exists(sysfs_path):
1247
+ real_path = os.path.realpath(sysfs_path)
1248
+ # Find all PCI addresses and take the last one (the NVMe controller)
1249
+ pci_matches = re.findall(r'(0000:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])', real_path, re.I)
1250
+ if pci_matches:
1251
+ return pci_matches[-1]
1252
+ except Exception:
1253
+ pass
1254
+ return None
1255
+
1256
+ def _rebind_nvme_devices(self):
1257
+ """Find and rebind any unbound NVMe devices.
1258
+
1259
+ Scans PCI devices for NVMe controllers (class 0x010802) that have no
1260
+ driver bound, and attempts to bind them to the nvme driver.
1261
+ """
1262
+ pci_devices = '/sys/bus/pci/devices'
1263
+ nvme_bind = '/sys/bus/pci/drivers/nvme/bind'
1264
+ if not os.path.exists(pci_devices) or not os.path.exists(nvme_bind):
1265
+ return
1266
+
1267
+ for pci_addr in os.listdir(pci_devices):
1268
+ device_path = os.path.join(pci_devices, pci_addr)
1269
+ # Check if this is an NVMe controller (class 0x010802)
1270
+ class_file = os.path.join(device_path, 'class')
1271
+ try:
1272
+ with open(class_file, 'r', encoding='utf-8') as f:
1273
+ device_class = f.read().strip()
1274
+ if device_class != '0x010802':
1275
+ continue
1276
+ # Check if driver is already bound
1277
+ driver_link = os.path.join(device_path, 'driver')
1278
+ if os.path.exists(driver_link):
1279
+ continue
1280
+ # Try to bind to nvme driver
1281
+ with open(nvme_bind, 'w', encoding='utf-8') as f:
1282
+ f.write(pci_addr)
1283
+ except Exception:
1284
+ pass
966
1285
 
967
1286
  def stop_ACTION(self):
968
- """Handle 's' key"""
1287
+ """Handle 's' key - stop current wipe (but not firmware wipes)"""
969
1288
  app = self.app
970
1289
  if app.pick_is_running:
971
1290
  ctx = app.win.get_picked_context()
@@ -973,15 +1292,21 @@ class MainScreen(DiskWipeScreen):
973
1292
  part = ctx.partition
974
1293
  if part.state[-1] == '%':
975
1294
  if part.job and not part.job.done:
1295
+ # Skip firmware wipes - they cannot be safely stopped
1296
+ if app._is_firmware_wipe(part):
1297
+ return
976
1298
  part.job.do_abort = True
977
1299
 
978
1300
 
979
1301
  def stop_all_ACTION(self):
980
- """Handle 'S' key"""
1302
+ """Handle 'S' key - stop all wipes (but not firmware wipes)"""
981
1303
  app = self.app
982
1304
  for part in app.partitions.values():
983
1305
  if part.state[-1] == '%':
984
1306
  if part.job and not part.job.done:
1307
+ # Skip firmware wipes - they cannot be safely stopped
1308
+ if app._is_firmware_wipe(part):
1309
+ continue
985
1310
  part.job.do_abort = True
986
1311
 
987
1312
  def block_ACTION(self):
@@ -1025,6 +1350,20 @@ class HelpScreen(DiskWipeScreen):
1025
1350
  spinner.show_help_nav_keys(app.win)
1026
1351
  spinner.show_help_body(app.win)
1027
1352
 
1353
+ # Add CLI Options section
1354
+ app.win.add_body('Command Line Arguments:', attr=cs.A_UNDERLINE)
1355
+ opts, wid = app.opts, 8
1356
+ cli_options = [
1357
+ f'--mode: . . . . {opts.mode:<{wid}} Wipe mode',
1358
+ f'--passes: . . . {opts.passes:<{wid}} Passes for software wipes',
1359
+ f'--verify-pct: . {opts.verify_pct:<{wid}} Verification %',
1360
+ f'--port-serial: {opts.port_serial:<{wid}} Show port/serial/FwCAPS',
1361
+ f'--slowdown-stop: {opts.slowdown_stop:<{wid}} Stop if disk slows',
1362
+ f'--stall-timeout: {opts.stall_timeout:<{wid}} Stall timeout in sec',
1363
+ ]
1364
+ for opt in cli_options:
1365
+ app.win.add_body(opt, attr=cs.A_DIM)
1366
+
1028
1367
 
1029
1368
 
1030
1369
  class HistoryScreen(DiskWipeScreen):
@@ -1087,6 +1426,15 @@ class HistoryScreen(DiskWipeScreen):
1087
1426
 
1088
1427
  def draw_screen(self):
1089
1428
  """Draw the history screen with structured log entries"""
1429
+
1430
+ def format_ago(timestamp):
1431
+ nonlocal now_dt
1432
+ ts = datetime.fromisoformat(timestamp)
1433
+ delta = now_dt - ts
1434
+ return Utils.ago_str(int(round(delta.total_seconds())))
1435
+
1436
+
1437
+ now_dt = datetime.now()
1090
1438
  app = self.app
1091
1439
  win = app.win
1092
1440
  win.set_pick_mode(True)
@@ -1126,7 +1474,7 @@ class HistoryScreen(DiskWipeScreen):
1126
1474
 
1127
1475
  # Header
1128
1476
  # header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} ({level_summary}) '
1129
- header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} '
1477
+ header_line = f'ESC:back [e]xpand [c]opy [/]search {len(self.filtered_entries)}/{len(self.entries)} '
1130
1478
  if search_display:
1131
1479
  header_line += f'/ {search_display}'
1132
1480
  else:
@@ -1141,8 +1489,17 @@ class HistoryScreen(DiskWipeScreen):
1141
1489
  # Get display summary from entry
1142
1490
  summary = entry.display_summary
1143
1491
 
1144
- # Format timestamp (just date and time)
1145
- timestamp_display = timestamp[:19] # YYYY-MM-DD HH:MM:SS
1492
+ # Format timestamp based on spinner setting
1493
+ time_format = app.opts.hist_time_format
1494
+
1495
+ if time_format == 'ago':
1496
+ timestamp_display = f"{format_ago(timestamp):>6}"
1497
+ elif time_format == 'ago+time':
1498
+ ago = format_ago(timestamp)
1499
+ time_str = timestamp[:19]
1500
+ timestamp_display = f"{ago:>6} {time_str}"
1501
+ else: # 'time'
1502
+ timestamp_display = timestamp[:19] # Just the date and time part (YYYY-MM-DD HH:MM:SS)
1146
1503
 
1147
1504
  level = entry.level
1148
1505
 
@@ -1193,13 +1550,89 @@ class HistoryScreen(DiskWipeScreen):
1193
1550
  timestamp = ctx.timestamp
1194
1551
  # Toggle between collapsed and expanded
1195
1552
  current = self.expands.get(timestamp, False)
1196
- if current:
1197
- del self.expands[timestamp] # Collapse
1553
+ if current: # Collapsing
1554
+ del self.expands[timestamp]
1555
+ # Search backwards to find first context with matching timestamp
1556
+ # This should be the header line of this entry
1557
+ test_pos = win.pick_pos - 1
1558
+ while test_pos >= 0:
1559
+ test_ctx = win.body.contexts[test_pos]
1560
+ if test_ctx and hasattr(test_ctx, 'timestamp') and test_ctx.timestamp == timestamp:
1561
+ win.pick_pos = test_pos
1562
+ test_pos -= 1
1563
+ else:
1564
+ return
1198
1565
  else:
1199
- self.expands[timestamp] = True # Expand
1566
+ # Expanding - just toggle, cursor stays where it is
1567
+ self.expands[timestamp] = True
1200
1568
 
1201
1569
  def filter_ACTION(self):
1202
1570
  """'/' key - Start incremental search"""
1203
1571
  app = self.app
1204
1572
  self.search_bar.start(self.prev_filter)
1205
1573
  app.win.passthrough_mode = True
1574
+
1575
+ def copy_ACTION(self):
1576
+ """'c' key - Copy current entry to clipboard or print to terminal"""
1577
+ from .Utils import ClipboardHelper
1578
+ app = self.app
1579
+ win = app.win
1580
+ ctx = win.get_picked_context()
1581
+
1582
+ if not ctx or not hasattr(ctx, 'timestamp'):
1583
+ return
1584
+
1585
+ # Find the entry by timestamp
1586
+ timestamp = ctx.timestamp
1587
+ entry = None
1588
+ for e in self.entries:
1589
+ if e.timestamp == timestamp:
1590
+ entry = e
1591
+ break
1592
+
1593
+ if not entry:
1594
+ return
1595
+
1596
+ # Format entry as JSON
1597
+ entry_text = json.dumps(entry.to_dict(), indent=2)
1598
+
1599
+ # Try clipboard first
1600
+ if ClipboardHelper.has_clipboard():
1601
+ success, error = ClipboardHelper.copy(entry_text)
1602
+ if success:
1603
+ # Show brief success indicator - use the header context temporarily
1604
+ # The message will be visible until next refresh
1605
+ win.add_header(f'Copied to clipboard ({ClipboardHelper.get_method_name()})')
1606
+ else:
1607
+ win.add_header(f'Clipboard error: {error}')
1608
+ else:
1609
+ # Terminal fallback: exit curses, print, wait for input
1610
+ self._copy_terminal_fallback(entry_text)
1611
+
1612
+ def _copy_terminal_fallback(self, text):
1613
+ """Print entry to terminal when clipboard unavailable (e.g., SSH sessions)."""
1614
+ from .Utils import ClipboardHelper
1615
+ app = self.app
1616
+
1617
+ # Exit curses to use the terminal
1618
+ ConsoleWindow.stop_curses()
1619
+ os.system('clear; stty sane')
1620
+
1621
+ # Print with border
1622
+ print('=' * 60)
1623
+ print('LOG ENTRY (copy manually from terminal):')
1624
+ print('=' * 60)
1625
+ print(text)
1626
+ print('=' * 60)
1627
+ print(f'\nClipboard: {ClipboardHelper.get_method_name()}')
1628
+ print('\nPress ENTER to return to dwipe...')
1629
+
1630
+ # Wait for user input
1631
+ try:
1632
+ input()
1633
+ except EOFError:
1634
+ pass
1635
+
1636
+ # Restore curses
1637
+ ConsoleWindow.start_curses()
1638
+ app.win.pick_pos = app.win.pick_pos # Force position refresh