diff --git a/state.py b/state.py index 0f3d370..5ce99b6 100644 --- a/state.py +++ b/state.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field from pathlib import Path +from typing import Any, Callable @dataclass @@ -15,3 +16,10 @@ class AppState: timeline_selected_nodes: set = field(default_factory=set) live_toggles: dict = field(default_factory=dict) show_comfy_monitor: bool = True + + # Set at runtime by main.py / tab_comfy_ng.py + _render_main: Any = None + _load_file: Callable | None = None + _main_rendered: bool = False + _live_checkboxes: dict = field(default_factory=dict) + _live_refreshables: dict = field(default_factory=dict) diff --git a/tab_batch_ng.py b/tab_batch_ng.py index 4a23c9d..9120410 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -13,6 +13,7 @@ from history_tree import HistoryTree IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'} SUB_SEGMENT_MULTIPLIER = 1000 +FRAME_TO_SKIP_DEFAULT = DEFAULTS['frame_to_skip'] VACE_MODES = [ 'End Extend', 'Pre Extend', 'Middle Extend', 'Edge Extend', @@ -54,6 +55,15 @@ def next_sub_segment_number(batch_list, parent_seq_num): max_sub = max(max_sub, sub_index_of(sn)) return parent_seq_num * SUB_SEGMENT_MULTIPLIER + max_sub + 1 +def max_main_seq_number(batch_list): + """Highest non-subsegment sequence number in the batch.""" + return max( + (int(x.get(KEY_SEQUENCE_NUMBER, 0)) + for x in batch_list if not is_subsegment(x.get(KEY_SEQUENCE_NUMBER, 0))), + default=0, + ) + + def find_insert_position(batch_list, parent_index, parent_seq_num): parent_seq_num = int(parent_seq_num) pos = parent_index + 1 @@ -78,21 +88,21 @@ def dict_input(element_fn, label, seq, key, **kwargs): return el -def dict_number(label, seq, key, **kwargs): +def dict_number(label, seq, key, default=0, **kwargs): """Number input bound to seq[key] via blur.""" - val = seq.get(key, 0) + val = seq.get(key, default) try: # Try float first to handle "1.5" strings, then check if it's a clean int fval = float(val) val = int(fval) if fval == int(fval) else fval except (ValueError, TypeError): - val = 0 + val = default el = ui.number(label, value=val, **kwargs) - def _on_blur(e, k=key): + def _on_blur(e, k=key, d=default): v = e.sender.value if v is None: - v = 0 + v = d elif isinstance(v, float) and v == int(v): v = int(v) seq[k] = v @@ -183,12 +193,7 @@ def render_batch_processor(state: AppState): ui.label('Add New Sequence').classes('text-subtitle1 q-mt-md') def _add_sequence(new_item): - max_seq = 0 - for s in batch_list: - sn = int(s.get(KEY_SEQUENCE_NUMBER, 0)) - if not is_subsegment(sn): - max_seq = max(max_seq, sn) - new_item[KEY_SEQUENCE_NUMBER] = max_seq + 1 + new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: new_item.pop(k, None) batch_list.append(new_item) @@ -284,6 +289,13 @@ def render_batch_processor(state: AppState): def _render_sequence_card(i, seq, batch_list, data, file_path, state, src_cache, src_seq_select, standard_keys, refresh_list): + def commit(message=None): + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + if message: + ui.notify(message, type='positive') + refresh_list.refresh() + seq_num = seq.get(KEY_SEQUENCE_NUMBER, i + 1) if is_subsegment(seq_num): @@ -307,46 +319,29 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, item.pop(KEY_PROMPT_HISTORY, None) item.pop(KEY_HISTORY_TREE, None) batch_list[idx] = item - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify('Copied!', type='positive') - refresh_list.refresh() + commit('Copied!') ui.button('Copy Src', icon='file_download', on_click=copy_source).props('dense') # Clone Next def clone_next(idx=i, sn=seq_num, s=seq): new_seq = copy.deepcopy(s) - max_sn = max( - (int(x.get(KEY_SEQUENCE_NUMBER, 0)) - for x in batch_list if not is_subsegment(x.get(KEY_SEQUENCE_NUMBER, 0))), - default=0) - new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1 + new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 if not is_subsegment(sn): pos = find_insert_position(batch_list, idx, int(sn)) else: pos = idx + 1 batch_list.insert(pos, new_seq) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify('Cloned to Next!', type='positive') - refresh_list.refresh() + commit('Cloned to Next!') ui.button('Clone Next', icon='content_copy', on_click=clone_next).props('dense') # Clone End def clone_end(s=seq): new_seq = copy.deepcopy(s) - max_sn = max( - (int(x.get(KEY_SEQUENCE_NUMBER, 0)) - for x in batch_list if not is_subsegment(x.get(KEY_SEQUENCE_NUMBER, 0))), - default=0) - new_seq[KEY_SEQUENCE_NUMBER] = max_sn + 1 + new_seq[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 batch_list.append(new_seq) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify('Cloned to End!', type='positive') - refresh_list.refresh() + commit('Cloned to End!') ui.button('Clone End', icon='vertical_align_bottom', on_click=clone_end).props('dense') @@ -363,11 +358,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, new_seq[KEY_SEQUENCE_NUMBER] = next_sub_segment_number(batch_list, p_seq) pos = find_insert_position(batch_list, p_idx, p_seq) batch_list.insert(pos, new_seq) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - ui.notify(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!', - type='positive') - refresh_list.refresh() + commit(f'Created {format_seq_label(new_seq[KEY_SEQUENCE_NUMBER])}!') ui.button('Clone Sub', icon='link', on_click=clone_sub).props('dense') @@ -389,9 +380,7 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # Delete def delete(idx=i): batch_list.pop(idx) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - refresh_list.refresh() + commit() ui.button(icon='delete', on_click=delete).props('dense color=negative') @@ -430,11 +419,8 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, ui.button(icon='casino', on_click=randomize_seed).props('flat') # CFG - cfg_val = float(seq.get('cfg', DEFAULTS['cfg'])) - cfg_input = ui.number('CFG', value=cfg_val, step=0.5, - format='%.1f').props('outlined').classes('w-full') - cfg_input.on('blur', lambda e: seq.__setitem__( - 'cfg', e.sender.value if e.sender.value is not None else DEFAULTS['cfg'])) + dict_number('CFG', seq, 'cfg', default=DEFAULTS['cfg'], + step=0.5, format='%.1f').props('outlined').classes('w-full') dict_input(ui.input, 'Camera', seq, 'camera').props('outlined').classes('w-full') dict_input(ui.input, 'FLF', seq, 'flf').props('outlined').classes('w-full') @@ -496,14 +482,11 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, for k in custom_keys: with ui.row().classes('w-full items-center'): ui.input('Key', value=k).props('readonly outlined dense').classes('w-32') - val_input = ui.input('Value', value=str(seq[k])).props( - 'outlined dense').classes('col') - val_input.on('blur', lambda e, key=k: seq.__setitem__(key, e.sender.value)) + dict_input(ui.input, 'Value', seq, k).props('outlined dense').classes('col') def del_custom(key=k): del seq[key] - save_json(file_path, data) - refresh_list.refresh() + commit() ui.button(icon='delete', on_click=del_custom).props('flat dense color=negative') @@ -516,10 +499,9 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, v = new_v_input.value if k and k not in seq: seq[k] = v - save_json(file_path, data) new_k_input.set_value('') new_v_input.set_value('') - refresh_list.refresh() + commit() ui.button('Add', on_click=add_param).props('flat') @@ -535,7 +517,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): 'outlined') # Capture original at render time; blur updates seq before click fires - _original_fts = int(seq.get('frame_to_skip', 81)) + _original_fts = int(seq.get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) def shift_fts(idx=i, orig=_original_fts): new_fts = int(fts_input.value) if fts_input.value is not None else orig @@ -546,7 +528,7 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): shifted = 0 for j in range(idx + 1, len(batch_list)): batch_list[j]['frame_to_skip'] = int( - batch_list[j].get('frame_to_skip', 81)) + delta + batch_list[j].get('frame_to_skip', FRAME_TO_SKIP_DEFAULT)) + delta shifted += 1 data[KEY_BATCH_DATA] = batch_list save_json(file_path, data) @@ -560,10 +542,8 @@ def _render_vace_settings(i, seq, batch_list, data, file_path, refresh_list): # VACE Schedule sched_val = max(0, min(int(seq.get('vace schedule', 1)), len(VACE_MODES) - 1)) with ui.row().classes('w-full items-center'): - vs_input = ui.number('VACE Schedule', value=sched_val, min=0, - max=len(VACE_MODES) - 1).classes('col').props('outlined') - vs_input.on('blur', lambda e: seq.__setitem__( - 'vace schedule', int(e.sender.value) if e.sender.value is not None else 0)) + vs_input = dict_number('VACE Schedule', seq, 'vace schedule', + min=0, max=len(VACE_MODES) - 1).classes('col').props('outlined') mode_label = ui.label(VACE_MODES[sched_val]).classes('text-caption') def update_mode_label(e): diff --git a/tab_comfy_ng.py b/tab_comfy_ng.py index d431ac8..496d455 100644 --- a/tab_comfy_ng.py +++ b/tab_comfy_ng.py @@ -78,8 +78,8 @@ def render_comfy_monitor(state: AppState): # --- Auto-poll timer (every 300s) --- # Store live_checkbox references so the timer can update them - _live_checkboxes = state._live_checkboxes = getattr(state, '_live_checkboxes', {}) - _live_refreshables = state._live_refreshables = getattr(state, '_live_refreshables', {}) + _live_checkboxes = state._live_checkboxes + _live_refreshables = state._live_refreshables def poll_all(): timeout_val = config.get('monitor_timeout', 0) diff --git a/tab_timeline_ng.py b/tab_timeline_ng.py index b1a9013..9aaee0a 100644 --- a/tab_timeline_ng.py +++ b/tab_timeline_ng.py @@ -8,6 +8,207 @@ from history_tree import HistoryTree from utils import save_json, KEY_BATCH_DATA, KEY_HISTORY_TREE +def _delete_nodes(htree, data, file_path, node_ids): + """Delete nodes with backup, branch cleanup, and head fallback.""" + if 'history_tree_backup' not in data: + data['history_tree_backup'] = [] + data['history_tree_backup'].append(copy.deepcopy(htree.to_dict())) + for nid in node_ids: + htree.nodes.pop(nid, None) + for b, tip in list(htree.branches.items()): + if tip in node_ids: + del htree.branches[b] + if htree.head_id in node_ids: + if htree.nodes: + htree.head_id = sorted(htree.nodes.values(), + key=lambda x: x['timestamp'])[-1]['id'] + else: + htree.head_id = None + data[KEY_HISTORY_TREE] = htree.to_dict() + save_json(file_path, data) + + +def _render_selection_picker(all_nodes, htree, state, refresh_fn): + """Multi-select picker for batch-deleting timeline nodes.""" + all_ids = [n['id'] for n in all_nodes] + + def fmt_option(nid): + n = htree.nodes[nid] + ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) + note = n.get('note', 'Step') + head = ' (HEAD)' if nid == htree.head_id else '' + return f'{note} - {ts} ({nid[:6]}){head}' + + options = {nid: fmt_option(nid) for nid in all_ids} + + def on_selection_change(e): + state.timeline_selected_nodes = set(e.value) if e.value else set() + + ui.select( + options, + value=list(state.timeline_selected_nodes), + multiple=True, + label='Select nodes to delete:', + on_change=on_selection_change, + ).classes('w-full') + + with ui.row(): + def select_all(): + state.timeline_selected_nodes = set(all_ids) + refresh_fn() + def deselect_all(): + state.timeline_selected_nodes = set() + refresh_fn() + ui.button('Select All', on_click=select_all).props('flat dense') + ui.button('Deselect All', on_click=deselect_all).props('flat dense') + + +def _render_graph_or_log(mode, all_nodes, htree, selected_nodes, + selection_mode_on, toggle_select_fn, restore_fn): + """Render graph visualization or linear log view.""" + if mode in ('Horizontal', 'Vertical'): + direction = 'LR' if mode == 'Horizontal' else 'TB' + try: + graph_dot = htree.generate_graph(direction=direction) + _render_graphviz(graph_dot) + except Exception as e: + ui.label(f'Graph Error: {e}').classes('text-negative') + + elif mode == 'Linear Log': + ui.label('Chronological list of all snapshots.').classes('text-caption') + for n in all_nodes: + is_head = n['id'] == htree.head_id + is_selected = n['id'] in selected_nodes + + card_style = '' + if is_selected: + card_style = 'background: #3d1f1f !important;' + elif is_head: + card_style = 'background: #1a2332 !important;' + with ui.card().classes('w-full q-mb-sm').style(card_style): + with ui.row().classes('w-full items-center'): + if selection_mode_on: + ui.checkbox( + '', + value=is_selected, + on_change=lambda e, nid=n['id']: toggle_select_fn( + nid, e.value), + ) + + icon = 'location_on' if is_head else 'circle' + ui.icon(icon).classes( + 'text-primary' if is_head else 'text-grey') + + with ui.column().classes('col'): + note = n.get('note', 'Step') + ts = time.strftime('%b %d %H:%M', + time.localtime(n['timestamp'])) + label = f'{note} (Current)' if is_head else note + ui.label(label).classes('text-bold') + ui.label( + f'ID: {n["id"][:6]} - {ts}').classes('text-caption') + + if not is_head and not selection_mode_on: + ui.button( + 'Restore', + icon='restore', + on_click=lambda node=n: restore_fn(node), + ).props('flat dense color=primary') + + +def _render_batch_delete(htree, data, file_path, state, refresh_fn): + """Render batch delete controls for selected timeline nodes.""" + valid = state.timeline_selected_nodes & set(htree.nodes.keys()) + state.timeline_selected_nodes = valid + count = len(valid) + if count == 0: + return + + ui.label( + f'{count} node{"s" if count != 1 else ""} selected for deletion.' + ).classes('text-warning q-mt-md') + + def do_batch_delete(): + _delete_nodes(htree, data, file_path, valid) + state.timeline_selected_nodes = set() + ui.notify( + f'Deleted {count} node{"s" if count != 1 else ""}!', + type='positive') + refresh_fn() + + ui.button( + f'Delete {count} Node{"s" if count != 1 else ""}', + icon='delete', + on_click=do_batch_delete, + ).props('color=negative') + + +def _render_node_manager(all_nodes, htree, data, file_path, restore_fn, refresh_fn): + """Render node selector with restore, rename, delete, and preview.""" + ui.label('Manage Version').classes('text-subtitle1 q-mt-md') + + 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]})' + + 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) + + selected_node_id = ui.select( + node_options, + value=current_id, + label='Select Version to Manage:', + ).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]) + + ui.button('Restore Version', icon='restore', + on_click=restore_selected).props('color=primary') + + # Rename + with ui.row().classes('w-full items-end q-gutter-md'): + rename_input = ui.input('Rename Label').classes('col') + + 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 + 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') + + # Danger zone + with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md'): + 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}) + ui.notify('Node Deleted', type='positive') + refresh_fn() + + ui.button('Delete This Node', icon='delete', + on_click=delete_selected).props('color=negative') + + # 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() + + def render_timeline_tab(state: AppState): data = state.data_cache file_path = state.file_path @@ -35,218 +236,24 @@ def render_timeline_tab(state: AppState): @ui.refreshable def render_timeline(): - # Rebuild node list inside refreshable so it's current after deletes all_nodes = sorted(htree.nodes.values(), key=lambda x: x['timestamp'], reverse=True) selected_nodes = state.timeline_selected_nodes if selection_mode.value else set() - # --- Selection picker --- if selection_mode.value: - all_ids = [n['id'] for n in all_nodes] + _render_selection_picker(all_nodes, htree, state, render_timeline.refresh) - def fmt_option(nid): - n = htree.nodes[nid] - ts = time.strftime('%b %d %H:%M', time.localtime(n['timestamp'])) - note = n.get('note', 'Step') - head = ' (HEAD)' if nid == htree.head_id else '' - return f'{note} - {ts} ({nid[:6]}){head}' + _render_graph_or_log( + view_mode.value, all_nodes, htree, selected_nodes, + selection_mode.value, _toggle_select, _restore_and_refresh) - options = {nid: fmt_option(nid) for nid in all_ids} - - def on_selection_change(e): - state.timeline_selected_nodes = set(e.value) if e.value else set() - - ui.select( - options, - value=list(state.timeline_selected_nodes), - multiple=True, - label='Select nodes to delete:', - on_change=on_selection_change, - ).classes('w-full') - - with ui.row(): - def select_all(): - state.timeline_selected_nodes = set(all_ids) - render_timeline.refresh() - def deselect_all(): - state.timeline_selected_nodes = set() - render_timeline.refresh() - ui.button('Select All', on_click=select_all).props('flat dense') - ui.button('Deselect All', on_click=deselect_all).props('flat dense') - - # --- Graph views --- - mode = view_mode.value - if mode in ('Horizontal', 'Vertical'): - direction = 'LR' if mode == 'Horizontal' else 'TB' - try: - graph_dot = htree.generate_graph(direction=direction) - _render_graphviz(graph_dot) - except Exception as e: - ui.label(f'Graph Error: {e}').classes('text-negative') - - # --- Linear Log view --- - elif mode == 'Linear Log': - ui.label('Chronological list of all snapshots.').classes('text-caption') - for n in all_nodes: - is_head = n['id'] == htree.head_id - is_selected = n['id'] in selected_nodes - - card_style = '' - if is_selected: - card_style = 'background: #3d1f1f !important;' - elif is_head: - card_style = 'background: #1a2332 !important;' - with ui.card().classes('w-full q-mb-sm').style(card_style): - with ui.row().classes('w-full items-center'): - if selection_mode.value: - ui.checkbox( - '', - value=is_selected, - on_change=lambda e, nid=n['id']: _toggle_select( - nid, e.value), - ) - - icon = 'location_on' if is_head else 'circle' - ui.icon(icon).classes( - 'text-primary' if is_head else 'text-grey') - - with ui.column().classes('col'): - note = n.get('note', 'Step') - ts = time.strftime('%b %d %H:%M', - time.localtime(n['timestamp'])) - label = f'{note} (Current)' if is_head else note - ui.label(label).classes('text-bold') - ui.label( - f'ID: {n["id"][:6]} - {ts}').classes('text-caption') - - if not is_head and not selection_mode.value: - ui.button( - 'Restore', - icon='restore', - on_click=lambda node=n: _restore_and_refresh(node), - ).props('flat dense color=primary') - - # --- Batch Delete --- if selection_mode.value and state.timeline_selected_nodes: - valid = state.timeline_selected_nodes & set(htree.nodes.keys()) - state.timeline_selected_nodes = valid - count = len(valid) - if count > 0: - ui.label( - f'{count} node{"s" if count != 1 else ""} selected for deletion.' - ).classes('text-warning q-mt-md') - - def do_batch_delete(): - if 'history_tree_backup' not in data: - data['history_tree_backup'] = [] - data['history_tree_backup'].append(copy.deepcopy(htree.to_dict())) - for nid in valid: - if nid in htree.nodes: - del htree.nodes[nid] - for b, tip in list(htree.branches.items()): - if tip in valid: - del htree.branches[b] - if htree.head_id in valid: - if htree.nodes: - fallback = sorted(htree.nodes.values(), - key=lambda x: x['timestamp'])[-1] - htree.head_id = fallback['id'] - else: - htree.head_id = None - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - state.timeline_selected_nodes = set() - ui.notify( - f'Deleted {count} node{"s" if count != 1 else ""}!', - type='positive') - render_timeline.refresh() - - ui.button( - f'Delete {count} Node{"s" if count != 1 else ""}', - icon='delete', - on_click=do_batch_delete, - ).props('color=negative') + _render_batch_delete(htree, data, file_path, state, render_timeline.refresh) ui.separator() - # --- Node selector + actions --- - ui.label('Manage Version').classes('text-subtitle1 q-mt-md') - - 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]})' - - 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) - - selected_node_id = ui.select( - node_options, - value=current_id, - label='Select Version to Manage:', - ).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_and_refresh(htree.nodes[nid]) - - ui.button('Restore Version', icon='restore', - on_click=restore_selected).props('color=primary') - - # Rename - with ui.row().classes('w-full items-end q-gutter-md'): - rename_input = ui.input('Rename Label').classes('col') - - 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 - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - ui.notify('Label updated', type='positive') - render_timeline.refresh() - - ui.button('Update Label', on_click=rename_node).props('flat') - - # Danger zone - with ui.expansion('Danger Zone (Delete)', icon='warning').classes('w-full q-mt-md'): - 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: - if 'history_tree_backup' not in data: - data['history_tree_backup'] = [] - data['history_tree_backup'].append( - copy.deepcopy(htree.to_dict())) - del htree.nodes[nid] - for b, tip in list(htree.branches.items()): - if tip == nid: - del htree.branches[b] - if htree.head_id == nid: - if htree.nodes: - fallback = sorted(htree.nodes.values(), - key=lambda x: x['timestamp'])[-1] - htree.head_id = fallback['id'] - else: - htree.head_id = None - data[KEY_HISTORY_TREE] = htree.to_dict() - save_json(file_path, data) - ui.notify('Node Deleted', type='positive') - render_timeline.refresh() - - ui.button('Delete This Node', icon='delete', - on_click=delete_selected).props('color=negative') - - # 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() + _render_node_manager( + all_nodes, htree, data, file_path, + _restore_and_refresh, render_timeline.refresh) def _toggle_select(nid, checked): if checked: