Branch-grouped navigation for timeline node manager

Replace flat dropdown with branch selector showing node counts,
scrollable node list with HEAD/tip badges, and inline actions panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 22:15:56 +01:00
parent af5eafaf4d
commit 8911323832

View File

@@ -145,70 +145,178 @@ def _render_batch_delete(htree, data, file_path, state, refresh_fn):
).props('color=negative') ).props('color=negative')
def _walk_branch_nodes(htree, tip_id):
"""Walk parent pointers from tip, returning nodes newest-first."""
nodes = []
current = tip_id
while current and current in htree.nodes:
nodes.append(htree.nodes[current])
current = htree.nodes[current].get('parent')
return nodes
def _find_active_branch(htree):
"""Return branch name whose tip == head_id, or None if detached."""
if not htree.head_id:
return None
for b_name, tip_id in htree.branches.items():
if tip_id == htree.head_id:
return b_name
return None
def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn): def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn):
"""Render node selector with restore, rename, delete, and preview.""" """Render branch-grouped node manager with restore, rename, delete, and preview."""
ui.label('Manage Version').classes('section-header') ui.label('Manage Version').classes('section-header')
def fmt_node(n): # --- State that survives @ui.refreshable ---
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) active_branch = _find_active_branch(htree)
return f'{n.get("note", "Step")} - {ts} ({n["id"][:6]})'
node_options = {n['id']: fmt_node(n) for n in all_nodes} # Default branch: active branch, or branch whose ancestry contains HEAD
current_id = htree.head_id if htree.head_id in node_options else ( default_branch = active_branch
all_nodes[0]['id'] if all_nodes else None) 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 = ui.select( selected = {'node_id': htree.head_id, 'branch': default_branch}
node_options,
value=current_id, # --- (a) Branch selector ---
label='Select Version to Manage:', def fmt_branch(b_name):
count = len(_walk_branch_nodes(htree, htree.branches.get(b_name)))
suffix = ' (active)' if b_name == active_branch else ''
return f'{b_name} ({count} nodes){suffix}'
branch_options = {b: fmt_branch(b) for b in htree.branches}
def on_branch_change(e):
selected['branch'] = e.value
tip = htree.branches.get(e.value)
if tip:
selected['node_id'] = tip
render_branch_nodes.refresh()
ui.select(
branch_options,
value=selected['branch'],
label='Branch:',
on_change=on_branch_change,
).classes('w-full') ).classes('w-full')
with ui.row().classes('w-full items-end q-gutter-md'): # --- (b) Node list + (c) Actions panel ---
def restore_selected(): @ui.refreshable
nid = selected_node_id.value def render_branch_nodes():
if nid and nid in htree.nodes: branch_name = selected['branch']
restore_fn(htree.nodes[nid]) tip_id = htree.branches.get(branch_name)
nodes = _walk_branch_nodes(htree, tip_id) if tip_id else []
ui.button('Restore Version', icon='restore', if not nodes:
on_click=restore_selected).props('color=primary') ui.label('No nodes on this branch.').classes('text-caption q-pa-sm')
return
# Rename with ui.scroll_area().classes('w-full').style('max-height: 350px'):
with ui.row().classes('w-full items-end q-gutter-md'): for n in nodes:
rename_input = ui.input('Rename Label').classes('col') nid = n['id']
is_head = nid == htree.head_id
is_tip = nid == tip_id
is_selected = nid == selected['node_id']
def rename_node(): card_style = ''
nid = selected_node_id.value if is_selected:
if nid and nid in htree.nodes and rename_input.value: card_style = 'border-left: 3px solid var(--primary);'
htree.nodes[nid]['note'] = rename_input.value elif is_head:
data[KEY_HISTORY_TREE] = htree.to_dict() card_style = 'border-left: 3px solid var(--accent);'
save_json(file_path, data)
ui.notify('Label updated', type='positive')
refresh_fn()
ui.button('Update Label', on_click=rename_node).props('flat') with ui.card().classes('w-full q-mb-xs q-pa-xs').style(card_style):
with ui.row().classes('w-full items-center no-wrap'):
icon = 'location_on' if is_head else 'circle'
icon_size = 'sm' if is_head else 'xs'
ui.icon(icon, size=icon_size).classes(
'text-primary' if is_head else 'text-grey')
# Danger zone with ui.column().classes('col q-ml-xs').style('min-width: 0'):
with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md').style('border-left: 3px solid var(--negative)'): note = n.get('note', 'Step')
ui.label('Deleting a node cannot be undone.').classes('text-warning') ts = time.strftime('%b %d %H:%M',
time.localtime(n['timestamp']))
label_text = note
lbl = ui.label(label_text).classes('text-body2 ellipsis')
if is_head:
lbl.classes('text-bold')
ui.label(f'{ts} \u2022 {nid[:6]}').classes(
'text-caption text-grey')
def delete_selected(): if is_head:
nid = selected_node_id.value ui.badge('HEAD', color='amber').props('dense')
if nid and nid in htree.nodes: if is_tip and not is_head:
_delete_nodes(htree, data, file_path, {nid}) ui.badge('tip', color='green', outline=True).props('dense')
ui.notify('Node Deleted', type='positive')
refresh_fn()
ui.button('Delete This Node', icon='delete', def select_node(node_id=nid):
on_click=delete_selected).props('color=negative') selected['node_id'] = node_id
render_branch_nodes.refresh()
# Data preview ui.button(icon='check_circle', on_click=select_node).props(
ui.separator() 'flat dense round size=sm'
with ui.expansion('Data Preview', icon='preview').classes('w-full'): ).tooltip('Select this node')
@ui.refreshable
def render_preview(): # --- (c) Actions panel ---
_render_data_preview(selected_node_id, htree) sel_id = selected['node_id']
selected_node_id.on_value_change(lambda _: render_preview.refresh()) if not sel_id or sel_id not in htree.nodes:
render_preview() return
sel_node = htree.nodes[sel_id]
sel_note = sel_node.get('note', 'Step')
is_head = sel_id == htree.head_id
ui.separator().classes('q-my-sm')
ui.label(f'Selected: {sel_note} ({sel_id[:6]})').classes(
'text-caption text-bold')
with ui.row().classes('w-full items-end q-gutter-sm'):
if not is_head:
def restore_selected():
if sel_id in htree.nodes:
restore_fn(htree.nodes[sel_id])
ui.button('Restore', icon='restore',
on_click=restore_selected).props('color=primary dense')
# Rename
rename_input = ui.input('Rename Label').classes('col').props('dense')
def rename_node():
if sel_id in htree.nodes and rename_input.value:
htree.nodes[sel_id]['note'] = rename_input.value
data[KEY_HISTORY_TREE] = htree.to_dict()
save_json(file_path, data)
ui.notify('Label updated', type='positive')
refresh_fn()
ui.button('Update Label', on_click=rename_node).props('flat dense')
# Danger zone
with ui.expansion('Danger Zone', icon='warning').classes(
'w-full q-mt-sm').style('border-left: 3px solid var(--negative)'):
ui.label('Deleting a node cannot be undone.').classes('text-warning')
def delete_selected():
if sel_id in htree.nodes:
_delete_nodes(htree, data, file_path, {sel_id})
ui.notify('Node Deleted', type='positive')
refresh_fn()
ui.button('Delete This Node', icon='delete',
on_click=delete_selected).props('color=negative dense')
# Data preview
with ui.expansion('Data Preview', icon='preview').classes('w-full q-mt-sm'):
_render_data_preview(sel_id, htree)
render_branch_nodes()
def render_timeline_tab(state: AppState): def render_timeline_tab(state: AppState):
@@ -301,9 +409,8 @@ def _restore_node(data, node, htree, file_path, state: AppState):
ui.notify('Restored!', type='positive') ui.notify('Restored!', type='positive')
def _render_data_preview(selected_node_id, htree): def _render_data_preview(nid, htree):
"""Render a read-only preview of the selected node's data.""" """Render a read-only preview of the selected node's data."""
nid = selected_node_id.value
if not nid or nid not in htree.nodes: if not nid or nid not in htree.nodes:
ui.label('No node selected.').classes('text-caption') ui.label('No node selected.').classes('text-caption')
return return