From 8c2b0f7809ebee110e24d8d4aac36bafeb6b56bd Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 10 Feb 2026 17:22:10 +0100 Subject: [PATCH] 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 --- tab_timeline.py | 92 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/tab_timeline.py b/tab_timeline.py index 25ad384..3049584 100644 --- a/tab_timeline.py +++ b/tab_timeline.py @@ -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