diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index 05c935f..61cf383 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -1,4 +1,5 @@ import copy +import re import time from nicegui import ui @@ -64,14 +65,16 @@ def _render_selection_picker(all_nodes, htree, state, refresh_fn): def _render_graph_or_log(mode, all_nodes, htree, selected_nodes, - selection_mode_on, toggle_select_fn, restore_fn): + selection_mode_on, toggle_select_fn, restore_fn, + selected=None): """Render graph visualization or linear log view.""" if mode in ('Horizontal', 'Vertical'): direction = 'LR' if mode == 'Horizontal' else 'TB' with ui.card().classes('w-full q-pa-md'): try: graph_dot = htree.generate_graph(direction=direction) - _render_graphviz(graph_dot) + sel_id = selected.get('node_id') if selected else None + _render_graphviz(graph_dot, selected_node_id=sel_id) except Exception as e: ui.label(f'Graph Error: {e}').classes('text-negative') @@ -165,28 +168,24 @@ def _find_active_branch(htree): return None -def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn): +def _find_branch_for_node(htree, node_id): + """Return the branch name whose ancestry contains node_id, or None.""" + for b_name, tip_id in htree.branches.items(): + current = tip_id + while current and current in htree.nodes: + if current == node_id: + return b_name + current = htree.nodes[current].get('parent') + return None + + +def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn, + selected): """Render branch-grouped node manager with restore, rename, delete, and preview.""" ui.label('Manage Version').classes('section-header') - # --- State that survives @ui.refreshable --- active_branch = _find_active_branch(htree) - # Default branch: active branch, or branch whose ancestry contains HEAD - default_branch = active_branch - if not default_branch and htree.head_id: - for b_name, tip_id in htree.branches.items(): - for n in _walk_branch_nodes(htree, tip_id): - if n['id'] == htree.head_id: - default_branch = b_name - break - if default_branch: - break - if not default_branch and htree.branches: - default_branch = next(iter(htree.branches)) - - selected = {'node_id': htree.head_id, 'branch': default_branch} - # --- (a) Branch selector --- def fmt_branch(b_name): count = len(_walk_branch_nodes(htree, htree.branches.get(b_name))) @@ -331,6 +330,21 @@ def render_timeline_tab(state: AppState): htree = HistoryTree(tree_data) + # --- Shared selected-node state (survives refreshes, shared by graph + manager) --- + active_branch = _find_active_branch(htree) + default_branch = active_branch + if not default_branch and htree.head_id: + for b_name, tip_id in htree.branches.items(): + for n in _walk_branch_nodes(htree, tip_id): + if n['id'] == htree.head_id: + default_branch = b_name + break + if default_branch: + break + if not default_branch and htree.branches: + default_branch = next(iter(htree.branches)) + selected = {'node_id': htree.head_id, 'branch': default_branch} + if state.restored_indicator: ui.label(f'Editing Restored Version: {state.restored_indicator}').classes( 'text-info q-pa-sm') @@ -354,7 +368,8 @@ def render_timeline_tab(state: AppState): _render_graph_or_log( view_mode.value, all_nodes, htree, selected_nodes, - selection_mode.value, _toggle_select, _restore_and_refresh) + selection_mode.value, _toggle_select, _restore_and_refresh, + selected=selected) if selection_mode.value and state.timeline_selected_nodes: _render_batch_delete(htree, data, file_path, state, render_timeline.refresh) @@ -362,7 +377,8 @@ def render_timeline_tab(state: AppState): with ui.card().classes('w-full q-pa-md q-mt-md'): _render_node_manager( all_nodes, htree, data, file_path, - _restore_and_refresh, render_timeline.refresh) + _restore_and_refresh, render_timeline.refresh, + selected) def _toggle_select(nid, checked): if checked: @@ -380,14 +396,96 @@ def render_timeline_tab(state: AppState): selection_mode.on_value_change(lambda _: render_timeline.refresh()) render_timeline() + # --- Poll for graph node clicks (JS → Python bridge) --- + async def _poll_graph_click(): + if view_mode.value == 'Linear Log': + return + try: + result = await ui.run_javascript( + 'const v = window.graphSelectedNode;' + 'window.graphSelectedNode = null; v;' + ) + except Exception: + return + if not result: + return + node_id = str(result) + if node_id not in htree.nodes: + return + branch = _find_branch_for_node(htree, node_id) + if branch: + selected['branch'] = branch + selected['node_id'] = node_id + render_timeline.refresh() -def _render_graphviz(dot_source: str): - """Render graphviz DOT source as SVG using ui.html.""" + ui.timer(0.2, _poll_graph_click) + + +def _render_graphviz(dot_source: str, selected_node_id: str | None = None): + """Render graphviz DOT source as interactive SVG with click-to-select.""" try: import graphviz src = graphviz.Source(dot_source) svg = src.pipe(format='svg').decode('utf-8') - ui.html(f'