Add multi-select node deletion to timeline

Adds a "Select to Delete" toggle that enables batch deletion mode.
When active, clicking graph nodes or checking linear log checkboxes
selects them (highlighted in red), and a "Delete N Nodes" button
performs batch deletion with backup, branch tip cleanup, and HEAD
reassignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 17:22:10 +01:00
parent 58345dc7c0
commit 8c2b0f7809

View File

@@ -19,11 +19,15 @@ def render_timeline_tab(data, file_path):
htree = HistoryTree(tree_data)
# --- Initialize selection state ---
if "timeline_selected_nodes" not in st.session_state:
st.session_state.timeline_selected_nodes = set()
if 'restored_indicator' in st.session_state and st.session_state.restored_indicator:
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
# --- VIEW SWITCHER ---
c_title, c_view = st.columns([2, 1])
# --- VIEW SWITCHER + SELECTION MODE ---
c_title, c_view, c_toggle = st.columns([2, 1, 0.6])
c_title.subheader("🕰️ Version History")
view_mode = c_view.radio(
@@ -33,6 +37,10 @@ def render_timeline_tab(data, file_path):
label_visibility="collapsed"
)
selection_mode = c_toggle.toggle("Select to Delete", key="timeline_selection_mode")
if not selection_mode:
st.session_state.timeline_selected_nodes = set()
# --- Build sorted node list (shared by all views) ---
all_nodes = list(htree.nodes.values())
all_nodes.sort(key=lambda x: x["timestamp"], reverse=True)
@@ -43,11 +51,20 @@ def render_timeline_tab(data, file_path):
if AGRAPH_AVAILABLE:
# Interactive graph with streamlit-agraph
clicked_node = _render_interactive_graph(htree, direction)
selected_set = st.session_state.timeline_selected_nodes if selection_mode else set()
clicked_node = _render_interactive_graph(htree, direction, selected_set)
if clicked_node and clicked_node in htree.nodes:
node = htree.nodes[clicked_node]
if clicked_node != htree.head_id:
_restore_node(data, node, htree, file_path)
if selection_mode:
# Toggle node in selection set
if clicked_node in st.session_state.timeline_selected_nodes:
st.session_state.timeline_selected_nodes.discard(clicked_node)
else:
st.session_state.timeline_selected_nodes.add(clicked_node)
st.rerun()
else:
node = htree.nodes[clicked_node]
if clicked_node != htree.head_id:
_restore_node(data, node, htree, file_path)
else:
# Fallback to static graphviz
try:
@@ -69,7 +86,16 @@ def render_timeline_tab(data, file_path):
for n in all_nodes:
is_head = (n["id"] == htree.head_id)
with st.container():
c1, c2, c3 = st.columns([0.5, 4, 1])
if selection_mode:
c0, c1, c2, c3 = st.columns([0.3, 0.5, 4, 1])
with c0:
is_selected = n["id"] in st.session_state.timeline_selected_nodes
if st.checkbox("", value=is_selected, key=f"log_sel_{n['id']}", label_visibility="collapsed"):
st.session_state.timeline_selected_nodes.add(n["id"])
else:
st.session_state.timeline_selected_nodes.discard(n["id"])
else:
c1, c2, c3 = st.columns([0.5, 4, 1])
with c1:
st.markdown("### 📍" if is_head else "### ⚫")
with c2:
@@ -81,11 +107,46 @@ def render_timeline_tab(data, file_path):
st.write(f"**{note_txt}**")
st.caption(f"ID: {n['id'][:6]}{ts}")
with c3:
if not is_head:
if not is_head and not selection_mode:
if st.button("", key=f"log_rst_{n['id']}", help="Restore this version"):
_restore_node(data, n, htree, file_path)
st.divider()
# --- BATCH DELETE UI ---
if selection_mode and st.session_state.timeline_selected_nodes:
# Prune any selected IDs that no longer exist in the tree
valid_selected = st.session_state.timeline_selected_nodes & set(htree.nodes.keys())
st.session_state.timeline_selected_nodes = valid_selected
count = len(valid_selected)
if count > 0:
st.warning(f"**{count}** node{'s' if count != 1 else ''} selected for deletion.")
if st.button(f"🗑️ Delete {count} Node{'s' if count != 1 else ''}", type="primary"):
# Backup
if "history_tree_backup" not in data:
data["history_tree_backup"] = []
data["history_tree_backup"].append(copy.deepcopy(htree.to_dict()))
# Delete all selected nodes
for nid in valid_selected:
if nid in htree.nodes:
del htree.nodes[nid]
# Clean up branch tips
for b, tip in list(htree.branches.items()):
if tip in valid_selected:
del htree.branches[b]
# Reassign HEAD if deleted
if htree.head_id in valid_selected:
if htree.nodes:
fallback = sorted(htree.nodes.values(), key=lambda x: x["timestamp"])[-1]
htree.head_id = fallback["id"]
else:
htree.head_id = None
# Save and reset
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
st.session_state.timeline_selected_nodes = set()
st.toast(f"Deleted {count} node{'s' if count != 1 else ''}!", icon="🗑️")
st.rerun()
st.markdown("---")
# --- NODE SELECTOR ---
@@ -170,20 +231,23 @@ def render_timeline_tab(data, file_path):
_render_preview_fields(node_data, prefix)
def _render_interactive_graph(htree, direction):
def _render_interactive_graph(htree, direction, selected_nodes=None):
"""Render an interactive graph using streamlit-agraph. Returns clicked node id."""
if selected_nodes is None:
selected_nodes = set()
# Build reverse lookup: branch tip -> branch name(s)
tip_to_branches = {}
for b_name, tip_id in htree.branches.items():
if tip_id:
tip_to_branches.setdefault(tip_id, []).append(b_name)
sorted_nodes = sorted(htree.nodes.values(), key=lambda x: x["timestamp"])
sorted_nodes_list = sorted(htree.nodes.values(), key=lambda x: x["timestamp"])
nodes = []
edges = []
for n in sorted_nodes:
for n in sorted_nodes_list:
nid = n["id"]
full_note = n.get('note', 'Step')
display_note = (full_note[:20] + '..') if len(full_note) > 20 else full_note
@@ -196,8 +260,10 @@ def _render_interactive_graph(htree, direction):
label = f"{display_note}\n{ts}{branch_label}"
# Colors - bright for dark backgrounds
if nid == htree.head_id:
# Colors - selected nodes override to red
if nid in selected_nodes:
color = "#ff5555" # Selected for deletion - red
elif nid == htree.head_id:
color = "#ffdd44" # Current head - bright yellow
elif nid in htree.branches.values():
color = "#66dd66" # Branch tip - bright green