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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user