Add comprehensive menu consistency improvements

Based on UX analysis, added three major improvements:

1. Context-sensitive filtering everywhere ('/' key):
   - evidence_detail: Filter notes by content
   - tags_list: Filter tags by name
   - tag_notes_list: Filter notes by content
   - ioc_list: Filter IOCs by value or type
   - ioc_notes_list: Filter notes by content
   - All views show active filter in footer

2. Extended delete support ('d' key):
   - note_detail: Delete current note and return to previous view
   - tag_notes_list: Delete selected note from filtered view
   - ioc_notes_list: Delete selected note from filtered view
   - Finds and removes note from parent case/evidence

3. Markdown export for case/evidence ('e' key):
   - case_detail: Export entire case + all evidence to markdown
   - evidence_detail: Export single evidence item to markdown
   - Files saved to ~/.trace/exports/ with timestamps
   - Complements existing IOC export functionality

All changes maintain consistent UX patterns across views and
provide clear feedback through updated footers showing available
actions in each context.
This commit is contained in:
Claude
2025-12-13 15:43:32 +00:00
parent ac7e442970
commit ec5a3d9f31

View File

@@ -781,7 +781,7 @@ class TUI:
is_selected = (item_idx == self.selected_index) is_selected = (item_idx == self.selected_index)
self._display_line_with_highlights(y, 4, display_str, is_selected) self._display_line_with_highlights(y, 4, display_str, is_selected)
self.stdscr.addstr(self.height - 3, 2, "[N] New Evidence [n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete [?] Help", curses.color_pair(3)) self.stdscr.addstr(self.height - 3, 2, "[N] New Evidence [n] Add Note [t] Tags [i] IOCs [v] View [e] Export [a] Active [d] Delete [?] Help", curses.color_pair(3))
def draw_evidence_detail(self): def draw_evidence_detail(self):
if not self.active_evidence: return if not self.active_evidence: return
@@ -812,14 +812,17 @@ class TUI:
current_y += 1 current_y += 1
current_y += 1 # Blank line before notes current_y += 1 # Blank line before notes
self.stdscr.addstr(current_y, 2, f"Notes ({len(self.active_evidence.notes)}):", curses.A_UNDERLINE)
# Apply filter if active
notes = self._get_filtered_list(self.active_evidence.notes, "content") if self.filter_query else self.active_evidence.notes
self.stdscr.addstr(current_y, 2, f"Notes ({len(notes)}):", curses.A_UNDERLINE)
current_y += 1 current_y += 1
# Just show last N notes that fit # Just show last N notes that fit
list_h = self.content_h - (current_y - 2) # Adjust for dynamic header list_h = self.content_h - (current_y - 2) # Adjust for dynamic header
start_y = current_y start_y = current_y
notes = self.active_evidence.notes
display_notes = notes[-list_h:] if len(notes) > list_h else notes display_notes = notes[-list_h:] if len(notes) > list_h else notes
# Update scroll for note selection # Update scroll for note selection
@@ -841,7 +844,10 @@ class TUI:
is_selected = (idx == self.selected_index) is_selected = (idx == self.selected_index)
self._display_line_with_highlights(start_y + i, 4, display_str, is_selected) self._display_line_with_highlights(start_y + i, 4, display_str, is_selected)
self.stdscr.addstr(self.height - 3, 2, "[n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete Note [?] Help", curses.color_pair(3)) footer = "[n] Add Note [t] Tags [i] IOCs [v] View [e] Export [a] Active [d] Delete [/] Filter [?] Help"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
def draw_tags_list(self): def draw_tags_list(self):
"""Draw the tags list view showing all tags sorted by occurrence count""" """Draw the tags list view showing all tags sorted by occurrence count"""
@@ -851,19 +857,29 @@ class TUI:
self.stdscr.addstr(2, 2, f"Tags for {context}: {context_name}", curses.A_BOLD) self.stdscr.addstr(2, 2, f"Tags for {context}: {context_name}", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4)) self.stdscr.addstr(3, 2, "" * (self.width - 4))
if not self.current_tags: # Apply filter if active (filter by tag name)
self.stdscr.addstr(5, 4, "No tags found.", curses.color_pair(3)) tags_to_show = self.current_tags
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3)) if self.filter_query:
q = self.filter_query.lower()
tags_to_show = [(tag, count) for tag, count in self.current_tags if q in tag.lower()]
if not tags_to_show:
msg = "No tags match filter." if self.filter_query else "No tags found."
self.stdscr.addstr(5, 4, msg, curses.color_pair(3))
footer = "[b] Back [/] Filter"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
return return
list_h = self._update_scroll(len(self.current_tags)) list_h = self._update_scroll(len(tags_to_show))
for i in range(list_h): for i in range(list_h):
idx = self.scroll_offset + i idx = self.scroll_offset + i
if idx >= len(self.current_tags): if idx >= len(tags_to_show):
break break
tag, count = self.current_tags[idx] tag, count = tags_to_show[idx]
y = 5 + i y = 5 + i
display_str = f"#{tag}".ljust(30) + f"({count} notes)" display_str = f"#{tag}".ljust(30) + f"({count} notes)"
@@ -876,26 +892,36 @@ class TUI:
else: else:
self.stdscr.addstr(y, 4, display_str) self.stdscr.addstr(y, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[Enter] View Notes [b] Back", curses.color_pair(3)) footer = "[Enter] View Notes [b] Back [/] Filter"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
def draw_tag_notes_list(self): def draw_tag_notes_list(self):
"""Draw compact list of notes containing the selected tag""" """Draw compact list of notes containing the selected tag"""
self.stdscr.addstr(2, 2, f"Notes tagged with #{self.current_tag} ({len(self.tag_notes)})", curses.A_BOLD) # Apply filter if active
notes_to_show = self._get_filtered_list(self.tag_notes, "content") if self.filter_query else self.tag_notes
self.stdscr.addstr(2, 2, f"Notes tagged with #{self.current_tag} ({len(notes_to_show)})", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4)) self.stdscr.addstr(3, 2, "" * (self.width - 4))
if not self.tag_notes: if not notes_to_show:
self.stdscr.addstr(5, 4, "No notes found.", curses.color_pair(3)) msg = "No notes match filter." if self.filter_query else "No notes found."
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3)) self.stdscr.addstr(5, 4, msg, curses.color_pair(3))
footer = "[b] Back [/] Filter"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
return return
list_h = self._update_scroll(len(self.tag_notes)) list_h = self._update_scroll(len(notes_to_show))
for i in range(list_h): for i in range(list_h):
idx = self.scroll_offset + i idx = self.scroll_offset + i
if idx >= len(self.tag_notes): if idx >= len(notes_to_show):
break break
note = self.tag_notes[idx] note = notes_to_show[idx]
y = 5 + i y = 5 + i
timestamp_str = time.ctime(note.timestamp) timestamp_str = time.ctime(note.timestamp)
@@ -914,7 +940,10 @@ class TUI:
else: else:
self.stdscr.addstr(y, 4, display_str) self.stdscr.addstr(y, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[Enter] Expand [b] Back", curses.color_pair(3)) footer = "[Enter] Expand [d] Delete [b] Back [/] Filter"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
def draw_ioc_list(self): def draw_ioc_list(self):
"""Draw the IOC list view showing all IOCs sorted by occurrence count""" """Draw the IOC list view showing all IOCs sorted by occurrence count"""
@@ -924,19 +953,30 @@ class TUI:
self.stdscr.addstr(2, 2, f"IOCs for {context}: {context_name}", curses.A_BOLD) self.stdscr.addstr(2, 2, f"IOCs for {context}: {context_name}", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4)) self.stdscr.addstr(3, 2, "" * (self.width - 4))
if not self.current_iocs: # Apply filter if active (filter by IOC value or type)
self.stdscr.addstr(5, 4, "No IOCs found.", curses.color_pair(3)) iocs_to_show = self.current_iocs
self.stdscr.addstr(self.height - 3, 2, "[b] Back [e] Export", curses.color_pair(3)) if self.filter_query:
q = self.filter_query.lower()
iocs_to_show = [(ioc, count, ioc_type) for ioc, count, ioc_type in self.current_iocs
if q in ioc.lower() or q in ioc_type.lower()]
if not iocs_to_show:
msg = "No IOCs match filter." if self.filter_query else "No IOCs found."
self.stdscr.addstr(5, 4, msg, curses.color_pair(3))
footer = "[b] Back [e] Export [/] Filter"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
return return
list_h = self._update_scroll(len(self.current_iocs)) list_h = self._update_scroll(len(iocs_to_show))
for i in range(list_h): for i in range(list_h):
idx = self.scroll_offset + i idx = self.scroll_offset + i
if idx >= len(self.current_iocs): if idx >= len(iocs_to_show):
break break
ioc, count, ioc_type = self.current_iocs[idx] ioc, count, ioc_type = iocs_to_show[idx]
y = 5 + i y = 5 + i
# Show IOC with type indicator and count in red # Show IOC with type indicator and count in red
@@ -953,26 +993,36 @@ class TUI:
self.stdscr.addstr(y, 4, display_str) self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(4)) self.stdscr.attroff(curses.color_pair(4))
self.stdscr.addstr(self.height - 3, 2, "[Enter] View Notes [e] Export [b] Back", curses.color_pair(3)) footer = "[Enter] View Notes [e] Export [b] Back [/] Filter"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
def draw_ioc_notes_list(self): def draw_ioc_notes_list(self):
"""Draw compact list of notes containing the selected IOC""" """Draw compact list of notes containing the selected IOC"""
self.stdscr.addstr(2, 2, f"Notes with IOC: {self.current_ioc} ({len(self.ioc_notes)})", curses.A_BOLD) # Apply filter if active
notes_to_show = self._get_filtered_list(self.ioc_notes, "content") if self.filter_query else self.ioc_notes
self.stdscr.addstr(2, 2, f"Notes with IOC: {self.current_ioc} ({len(notes_to_show)})", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4)) self.stdscr.addstr(3, 2, "" * (self.width - 4))
if not self.ioc_notes: if not notes_to_show:
self.stdscr.addstr(5, 4, "No notes found.", curses.color_pair(3)) msg = "No notes match filter." if self.filter_query else "No notes found."
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3)) self.stdscr.addstr(5, 4, msg, curses.color_pair(3))
footer = "[b] Back [e] Export [/] Filter"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
return return
list_h = self._update_scroll(len(self.ioc_notes)) list_h = self._update_scroll(len(notes_to_show))
for i in range(list_h): for i in range(list_h):
idx = self.scroll_offset + i idx = self.scroll_offset + i
if idx >= len(self.ioc_notes): if idx >= len(notes_to_show):
break break
note = self.ioc_notes[idx] note = notes_to_show[idx]
y = 5 + i y = 5 + i
timestamp_str = time.ctime(note.timestamp) timestamp_str = time.ctime(note.timestamp)
@@ -988,7 +1038,10 @@ class TUI:
else: else:
self.stdscr.addstr(y, 4, display_str) self.stdscr.addstr(y, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[Enter] Expand [b] Back", curses.color_pair(3)) footer = "[Enter] Expand [d] Delete [e] Export [b] Back [/] Filter"
if self.filter_query:
footer += f" Filter: {self.filter_query}"
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
def draw_note_detail(self): def draw_note_detail(self):
"""Draw expanded view of a single note with all details""" """Draw expanded view of a single note with all details"""
@@ -1050,7 +1103,7 @@ class TUI:
self.stdscr.addstr(current_y, 2, "Signature: [GPG signed]", curses.color_pair(2)) self.stdscr.addstr(current_y, 2, "Signature: [GPG signed]", curses.color_pair(2))
current_y += 1 current_y += 1
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3)) self.stdscr.addstr(self.height - 3, 2, "[d] Delete [b] Back", curses.color_pair(3))
def draw_help(self): def draw_help(self):
"""Draw the help screen with keyboard shortcuts and features""" """Draw the help screen with keyboard shortcuts and features"""
@@ -1234,8 +1287,8 @@ class TUI:
# Filter toggle # Filter toggle
if key == ord('/'): if key == ord('/'):
# Filter works on list views: case_list and case_detail (evidence list) # Filter works on all list views (context-sensitive)
if self.current_view in ["case_list", "case_detail"]: if self.current_view in ["case_list", "case_detail", "evidence_detail", "tags_list", "tag_notes_list", "ioc_list", "ioc_notes_list"]:
self.filter_mode = True self.filter_mode = True
return True return True
@@ -1426,10 +1479,14 @@ class TUI:
self.scroll_offset = 0 self.scroll_offset = 0
self.filter_query = "" self.filter_query = ""
# Export IOCs # Export
elif key == ord('e'): elif key == ord('e'):
if self.current_view in ["ioc_list", "ioc_notes_list"]: if self.current_view in ["ioc_list", "ioc_notes_list"]:
self.export_iocs() self.export_iocs()
elif self.current_view == "case_detail":
self.export_case_markdown()
elif self.current_view == "evidence_detail":
self.export_evidence_markdown()
# Set Active # Set Active
elif key == ord('a'): elif key == ord('a'):
@@ -2406,6 +2463,106 @@ class TUI:
self.scroll_offset = 0 self.scroll_offset = 0
self.show_message("Note deleted.") self.show_message("Note deleted.")
elif self.current_view == "note_detail":
# Delete the currently viewed note
if not self.current_note:
return
preview = self.current_note.content[:50] + "..." if len(self.current_note.content) > 50 else self.current_note.content
if self.dialog_confirm(f"Delete note: '{preview}'?"):
# Find and delete the note from its parent (case or evidence)
deleted = False
# Check all cases and their evidence for this note
for case in self.cases:
if self.current_note in case.notes:
case.notes.remove(self.current_note)
deleted = True
break
for ev in case.evidence:
if self.current_note in ev.notes:
ev.notes.remove(self.current_note)
deleted = True
break
if deleted:
break
if deleted:
self.storage.save_data()
self.show_message("Note deleted.")
# Return to previous view
self.current_view = getattr(self, 'previous_view', 'case_detail')
self.current_note = None
self.selected_index = 0
self.scroll_offset = 0
else:
self.show_message("Error: Note not found.")
elif self.current_view == "tag_notes_list":
# Delete selected note from tag notes list
if not self.tag_notes or self.selected_index >= len(self.tag_notes):
return
note_to_del = self.tag_notes[self.selected_index]
preview = note_to_del.content[:50] + "..." if len(note_to_del.content) > 50 else note_to_del.content
if self.dialog_confirm(f"Delete note: '{preview}'?"):
# Find and delete the note from its parent
deleted = False
for case in self.cases:
if note_to_del in case.notes:
case.notes.remove(note_to_del)
deleted = True
break
for ev in case.evidence:
if note_to_del in ev.notes:
ev.notes.remove(note_to_del)
deleted = True
break
if deleted:
break
if deleted:
self.storage.save_data()
# Remove from tag_notes list as well
self.tag_notes.remove(note_to_del)
self.selected_index = min(self.selected_index, len(self.tag_notes) - 1) if self.tag_notes else 0
self.scroll_offset = 0
self.show_message("Note deleted.")
else:
self.show_message("Error: Note not found.")
elif self.current_view == "ioc_notes_list":
# Delete selected note from IOC notes list
if not self.ioc_notes or self.selected_index >= len(self.ioc_notes):
return
note_to_del = self.ioc_notes[self.selected_index]
preview = note_to_del.content[:50] + "..." if len(note_to_del.content) > 50 else note_to_del.content
if self.dialog_confirm(f"Delete note: '{preview}'?"):
# Find and delete the note from its parent
deleted = False
for case in self.cases:
if note_to_del in case.notes:
case.notes.remove(note_to_del)
deleted = True
break
for ev in case.evidence:
if note_to_del in ev.notes:
ev.notes.remove(note_to_del)
deleted = True
break
if deleted:
break
if deleted:
self.storage.save_data()
# Remove from ioc_notes list as well
self.ioc_notes.remove(note_to_del)
self.selected_index = min(self.selected_index, len(self.ioc_notes) - 1) if self.ioc_notes else 0
self.scroll_offset = 0
self.show_message("Note deleted.")
else:
self.show_message("Error: Note not found.")
def view_case_notes(self, highlight_note_index=None): def view_case_notes(self, highlight_note_index=None):
if not self.active_case: return if not self.active_case: return
@@ -2709,6 +2866,138 @@ class TUI:
except Exception as e: except Exception as e:
self.show_message(f"Export failed: {str(e)}") self.show_message(f"Export failed: {str(e)}")
def export_case_markdown(self):
"""Export current case (and all its evidence) to markdown"""
if not self.active_case:
self.show_message("No active case to export.")
return
import os
import datetime
from pathlib import Path
# Create exports directory if it doesn't exist
export_dir = Path.home() / ".trace" / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
case_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_case.case_number)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"case_{case_name}_{timestamp}.md"
filepath = export_dir / filename
try:
with open(filepath, 'w', encoding='utf-8') as f:
f.write("# Forensic Notes Export\n\n")
f.write(f"Generated on: {time.ctime()}\n\n")
# Write case info
f.write(f"## Case: {self.active_case.case_number}\n")
if self.active_case.name:
f.write(f"**Name:** {self.active_case.name}\n")
if self.active_case.investigator:
f.write(f"**Investigator:** {self.active_case.investigator}\n")
f.write(f"**Case ID:** {self.active_case.case_id}\n\n")
# Case notes
f.write("### Case Notes\n")
if not self.active_case.notes:
f.write("_No notes._\n")
for note in self.active_case.notes:
self._write_note_markdown(f, note)
f.write("\n")
# Evidence
f.write("### Evidence\n")
if not self.active_case.evidence:
f.write("_No evidence items._\n")
for ev in self.active_case.evidence:
f.write(f"#### {ev.name}\n")
if ev.description:
f.write(f"**Description:** {ev.description}\n")
if ev.metadata.get("source_hash"):
f.write(f"**Source Hash:** `{ev.metadata['source_hash']}`\n")
f.write(f"**Evidence ID:** {ev.evidence_id}\n\n")
f.write("**Notes:**\n")
if not ev.notes:
f.write("_No notes._\n")
for note in ev.notes:
self._write_note_markdown(f, note)
f.write("\n")
self.show_message(f"Case exported to: {filepath}")
except Exception as e:
self.show_message(f"Export failed: {str(e)}")
def export_evidence_markdown(self):
"""Export current evidence to markdown"""
if not self.active_evidence:
self.show_message("No active evidence to export.")
return
import os
import datetime
from pathlib import Path
# Create exports directory if it doesn't exist
export_dir = Path.home() / ".trace" / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
case_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_case.case_number) if self.active_case else "unknown"
ev_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_evidence.name)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"evidence_{case_name}_{ev_name}_{timestamp}.md"
filepath = export_dir / filename
try:
with open(filepath, 'w', encoding='utf-8') as f:
f.write("# Forensic Evidence Export\n\n")
f.write(f"Generated on: {time.ctime()}\n\n")
# Case context
if self.active_case:
f.write(f"**Case:** {self.active_case.case_number}\n")
if self.active_case.name:
f.write(f"**Case Name:** {self.active_case.name}\n")
f.write("\n")
# Evidence info
f.write(f"## Evidence: {self.active_evidence.name}\n")
if self.active_evidence.description:
f.write(f"**Description:** {self.active_evidence.description}\n")
if self.active_evidence.metadata.get("source_hash"):
f.write(f"**Source Hash:** `{self.active_evidence.metadata['source_hash']}`\n")
f.write(f"**Evidence ID:** {self.active_evidence.evidence_id}\n\n")
# Notes
f.write("### Notes\n")
if not self.active_evidence.notes:
f.write("_No notes._\n")
for note in self.active_evidence.notes:
self._write_note_markdown(f, note)
self.show_message(f"Evidence exported to: {filepath}")
except Exception as e:
self.show_message(f"Export failed: {str(e)}")
def _write_note_markdown(self, f, note):
"""Helper to write a note in markdown format"""
f.write(f"- **{time.ctime(note.timestamp)}**\n")
f.write(f" - Content: {note.content}\n")
if note.tags:
tags_str = " ".join([f"#{tag}" for tag in note.tags])
f.write(f" - Tags: {tags_str}\n")
f.write(f" - Hash: `{note.content_hash}`\n")
if note.signature:
f.write(" - **Signature Verified:**\n")
f.write(" ```\n")
for line in note.signature.splitlines():
f.write(f" {line}\n")
f.write(" ```\n")
f.write("\n")
def run_tui(open_active=False): def run_tui(open_active=False):
""" """
Run the TUI application. Run the TUI application.