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')
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):
"""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')
def fmt_node(n):
ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp']))
return f'{n.get("note", "Step")} - {ts} ({n["id"][:6]})'
# --- State that survives @ui.refreshable ---
active_branch = _find_active_branch(htree)
node_options = {n['id']: fmt_node(n) for n in all_nodes}
current_id = htree.head_id if htree.head_id in node_options else (
all_nodes[0]['id'] if all_nodes else None)
# 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 = ui.select(
node_options,
value=current_id,
label='Select Version to Manage:',
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)))
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')
with ui.row().classes('w-full items-end q-gutter-md'):
def restore_selected():
nid = selected_node_id.value
if nid and nid in htree.nodes:
restore_fn(htree.nodes[nid])
# --- (b) Node list + (c) Actions panel ---
@ui.refreshable
def render_branch_nodes():
branch_name = selected['branch']
tip_id = htree.branches.get(branch_name)
nodes = _walk_branch_nodes(htree, tip_id) if tip_id else []
ui.button('Restore Version', icon='restore',
on_click=restore_selected).props('color=primary')
if not nodes:
ui.label('No nodes on this branch.').classes('text-caption q-pa-sm')
return
with ui.scroll_area().classes('w-full').style('max-height: 350px'):
for n in nodes:
nid = n['id']
is_head = nid == htree.head_id
is_tip = nid == tip_id
is_selected = nid == selected['node_id']
card_style = ''
if is_selected:
card_style = 'border-left: 3px solid var(--primary);'
elif is_head:
card_style = 'border-left: 3px solid var(--accent);'
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')
with ui.column().classes('col q-ml-xs').style('min-width: 0'):
note = n.get('note', 'Step')
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')
if is_head:
ui.badge('HEAD', color='amber').props('dense')
if is_tip and not is_head:
ui.badge('tip', color='green', outline=True).props('dense')
def select_node(node_id=nid):
selected['node_id'] = node_id
render_branch_nodes.refresh()
ui.button(icon='check_circle', on_click=select_node).props(
'flat dense round size=sm'
).tooltip('Select this node')
# --- (c) Actions panel ---
sel_id = selected['node_id']
if not sel_id or sel_id not in htree.nodes:
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
with ui.row().classes('w-full items-end q-gutter-md'):
rename_input = ui.input('Rename Label').classes('col')
rename_input = ui.input('Rename Label').classes('col').props('dense')
def rename_node():
nid = selected_node_id.value
if nid and nid in htree.nodes and rename_input.value:
htree.nodes[nid]['note'] = rename_input.value
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')
ui.button('Update Label', on_click=rename_node).props('flat dense')
# Danger zone
with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md').style('border-left: 3px solid var(--negative)'):
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():
nid = selected_node_id.value
if nid and nid in htree.nodes:
_delete_nodes(htree, data, file_path, {nid})
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')
on_click=delete_selected).props('color=negative dense')
# Data preview
ui.separator()
with ui.expansion('Data Preview', icon='preview').classes('w-full'):
@ui.refreshable
def render_preview():
_render_data_preview(selected_node_id, htree)
selected_node_id.on_value_change(lambda _: render_preview.refresh())
render_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):
@@ -301,9 +409,8 @@ def _restore_node(data, node, htree, file_path, state: AppState):
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."""
nid = selected_node_id.value
if not nid or nid not in htree.nodes:
ui.label('No node selected.').classes('text-caption')
return