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:
@@ -19,11 +19,15 @@ def render_timeline_tab(data, file_path):
|
|||||||
|
|
||||||
htree = HistoryTree(tree_data)
|
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:
|
if 'restored_indicator' in st.session_state and st.session_state.restored_indicator:
|
||||||
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
|
st.info(f"📍 Editing Restored Version: **{st.session_state.restored_indicator}**")
|
||||||
|
|
||||||
# --- VIEW SWITCHER ---
|
# --- VIEW SWITCHER + SELECTION MODE ---
|
||||||
c_title, c_view = st.columns([2, 1])
|
c_title, c_view, c_toggle = st.columns([2, 1, 0.6])
|
||||||
c_title.subheader("🕰️ Version History")
|
c_title.subheader("🕰️ Version History")
|
||||||
|
|
||||||
view_mode = c_view.radio(
|
view_mode = c_view.radio(
|
||||||
@@ -33,6 +37,10 @@ def render_timeline_tab(data, file_path):
|
|||||||
label_visibility="collapsed"
|
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) ---
|
# --- Build sorted node list (shared by all views) ---
|
||||||
all_nodes = list(htree.nodes.values())
|
all_nodes = list(htree.nodes.values())
|
||||||
all_nodes.sort(key=lambda x: x["timestamp"], reverse=True)
|
all_nodes.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||||
@@ -43,8 +51,17 @@ def render_timeline_tab(data, file_path):
|
|||||||
|
|
||||||
if AGRAPH_AVAILABLE:
|
if AGRAPH_AVAILABLE:
|
||||||
# Interactive graph with streamlit-agraph
|
# 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:
|
if clicked_node and clicked_node in htree.nodes:
|
||||||
|
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]
|
node = htree.nodes[clicked_node]
|
||||||
if clicked_node != htree.head_id:
|
if clicked_node != htree.head_id:
|
||||||
_restore_node(data, node, htree, file_path)
|
_restore_node(data, node, htree, file_path)
|
||||||
@@ -69,6 +86,15 @@ def render_timeline_tab(data, file_path):
|
|||||||
for n in all_nodes:
|
for n in all_nodes:
|
||||||
is_head = (n["id"] == htree.head_id)
|
is_head = (n["id"] == htree.head_id)
|
||||||
with st.container():
|
with st.container():
|
||||||
|
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])
|
c1, c2, c3 = st.columns([0.5, 4, 1])
|
||||||
with c1:
|
with c1:
|
||||||
st.markdown("### 📍" if is_head else "### ⚫")
|
st.markdown("### 📍" if is_head else "### ⚫")
|
||||||
@@ -81,11 +107,46 @@ def render_timeline_tab(data, file_path):
|
|||||||
st.write(f"**{note_txt}**")
|
st.write(f"**{note_txt}**")
|
||||||
st.caption(f"ID: {n['id'][:6]} • {ts}")
|
st.caption(f"ID: {n['id'][:6]} • {ts}")
|
||||||
with c3:
|
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"):
|
if st.button("⏪", key=f"log_rst_{n['id']}", help="Restore this version"):
|
||||||
_restore_node(data, n, htree, file_path)
|
_restore_node(data, n, htree, file_path)
|
||||||
st.divider()
|
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("---")
|
st.markdown("---")
|
||||||
|
|
||||||
# --- NODE SELECTOR ---
|
# --- NODE SELECTOR ---
|
||||||
@@ -170,20 +231,23 @@ def render_timeline_tab(data, file_path):
|
|||||||
_render_preview_fields(node_data, prefix)
|
_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."""
|
"""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)
|
# Build reverse lookup: branch tip -> branch name(s)
|
||||||
tip_to_branches = {}
|
tip_to_branches = {}
|
||||||
for b_name, tip_id in htree.branches.items():
|
for b_name, tip_id in htree.branches.items():
|
||||||
if tip_id:
|
if tip_id:
|
||||||
tip_to_branches.setdefault(tip_id, []).append(b_name)
|
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 = []
|
nodes = []
|
||||||
edges = []
|
edges = []
|
||||||
|
|
||||||
for n in sorted_nodes:
|
for n in sorted_nodes_list:
|
||||||
nid = n["id"]
|
nid = n["id"]
|
||||||
full_note = n.get('note', 'Step')
|
full_note = n.get('note', 'Step')
|
||||||
display_note = (full_note[:20] + '..') if len(full_note) > 20 else full_note
|
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}"
|
label = f"{display_note}\n{ts}{branch_label}"
|
||||||
|
|
||||||
# Colors - bright for dark backgrounds
|
# Colors - selected nodes override to red
|
||||||
if nid == htree.head_id:
|
if nid in selected_nodes:
|
||||||
|
color = "#ff5555" # Selected for deletion - red
|
||||||
|
elif nid == htree.head_id:
|
||||||
color = "#ffdd44" # Current head - bright yellow
|
color = "#ffdd44" # Current head - bright yellow
|
||||||
elif nid in htree.branches.values():
|
elif nid in htree.branches.values():
|
||||||
color = "#66dd66" # Branch tip - bright green
|
color = "#66dd66" # Branch tip - bright green
|
||||||
|
|||||||
Reference in New Issue
Block a user