Fix remaining blocking I/O calls and N+1 project query

- tab_batch_ng.py: async create_batch with to_thread save/sync
- tab_raw_ng.py: async do_save with to_thread, replace deepcopy
  with dict comprehension for display data
- main.py: async create_new with to_thread save
- tab_projects_ng.py: replace per-project count_data_files with
  single list_projects_with_file_counts JOIN query
- db.py: add list_projects_with_file_counts method

Zero blocking I/O calls remain in UI callbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 23:40:32 +01:00
parent 37e9e1001e
commit 589c84fd95
5 changed files with 26 additions and 18 deletions
+10
View File
@@ -93,6 +93,16 @@ class ProjectDB:
).fetchall()
return [dict(r) for r in rows]
def list_projects_with_file_counts(self) -> list[dict]:
"""List projects with data file counts in a single query."""
rows = self.conn.execute(
"SELECT p.id, p.name, p.folder_path, p.description, p.created_at, p.updated_at, "
"COUNT(df.id) AS file_count "
"FROM projects p LEFT JOIN data_files df ON df.project_id = p.id "
"GROUP BY p.id ORDER BY p.name"
).fetchall()
return [dict(r) for r in rows]
def get_project(self, name: str) -> dict | None:
row = self.conn.execute(
"SELECT id, name, folder_path, description, created_at, updated_at "
+2 -2
View File
@@ -476,7 +476,7 @@ def render_sidebar(state: AppState, dual_pane: dict):
with ui.expansion('Create New JSON'):
new_fn_input = ui.input('Filename', placeholder='my_prompt_vace').classes('w-full')
def create_new():
async def create_new():
fn = new_fn_input.value
if not fn:
return
@@ -485,7 +485,7 @@ def render_sidebar(state: AppState, dual_pane: dict):
path = state.current_dir / fn
first_item = copy.deepcopy(DEFAULTS)
first_item[KEY_SEQUENCE_NUMBER] = 1
save_json(path, {KEY_BATCH_DATA: [first_item]})
await asyncio.to_thread(save_json, path, {KEY_BATCH_DATA: [first_item]})
new_fn_input.set_value('')
render_file_list.refresh()
+3 -3
View File
@@ -199,7 +199,7 @@ def render_batch_processor(state: AppState):
ui.label('This is a Single file. To use Batch mode, create a copy.').classes(
'text-warning')
def create_batch():
async def create_batch():
new_name = f'batch_{file_path.name}'
new_path = file_path.parent / new_name
if new_path.exists():
@@ -211,9 +211,9 @@ def render_batch_processor(state: AppState):
first_item[KEY_SEQUENCE_NUMBER] = 1
new_data = {KEY_BATCH_DATA: [first_item], KEY_HISTORY_TREE: {},
KEY_PROMPT_HISTORY: []}
save_json(new_path, new_data)
await asyncio.to_thread(save_json, new_path, new_data)
if state.db_enabled and state.current_project and state.db:
sync_to_db(state.db, state.current_project, new_path, new_data)
await asyncio.to_thread(sync_to_db, state.db, state.current_project, new_path, new_data)
ui.notify(f'Created {new_name}', type='positive')
ui.button('Create Batch Copy', icon='content_copy', on_click=create_batch)
+4 -5
View File
@@ -60,8 +60,8 @@ def render_projects_tab(state: AppState):
ui.button('Create Project', icon='add', on_click=create_project).classes('w-full')
# --- Active project indicator ---
# Fetch once and reuse in render_project_list
_cached_projects = state.db.list_projects()
# Fetch once with file counts and reuse in render_project_list
_cached_projects = state.db.list_projects_with_file_counts()
if state.current_project:
# Check if active project actually exists in the database
@@ -102,7 +102,7 @@ def render_projects_tab(state: AppState):
@ui.refreshable
def render_project_list():
nonlocal _cached_projects
projects = state.db.list_projects()
projects = state.db.list_projects_with_file_counts()
_cached_projects = projects
if not projects:
ui.label('No projects yet. Create one above.').classes('text-caption q-pa-md')
@@ -119,8 +119,7 @@ def render_projects_tab(state: AppState):
if proj['description']:
ui.label(proj['description']).classes('text-caption')
ui.label(f'Path: {proj["folder_path"]}').classes('text-caption')
file_count = state.db.count_data_files(proj['id'])
ui.label(f'{file_count} data file(s)').classes('text-caption')
ui.label(f'{proj["file_count"]} data file(s)').classes('text-caption')
with ui.row().classes('q-gutter-xs'):
if not is_active:
+7 -8
View File
@@ -1,4 +1,4 @@
import copy
import asyncio
import json
from nicegui import ui
@@ -21,11 +21,10 @@ def render_raw_editor(state: AppState):
@ui.refreshable
def render_editor():
# Prepare display data
# Prepare display data — shallow copy, just pop keys
if hide_history.value:
display_data = copy.deepcopy(data)
display_data.pop(KEY_HISTORY_TREE, None)
display_data.pop(KEY_PROMPT_HISTORY, None)
display_data = {k: v for k, v in data.items()
if k not in (KEY_HISTORY_TREE, KEY_PROMPT_HISTORY)}
else:
display_data = data
@@ -40,7 +39,7 @@ def render_raw_editor(state: AppState):
value=json_str,
).classes('w-full font-mono').props('outlined rows=30')
def do_save():
async def do_save():
try:
input_data = json.loads(text_area.value)
@@ -51,9 +50,9 @@ def render_raw_editor(state: AppState):
if KEY_PROMPT_HISTORY in data:
input_data[KEY_PROMPT_HISTORY] = data[KEY_PROMPT_HISTORY]
save_json(file_path, input_data)
await asyncio.to_thread(save_json, file_path, input_data)
if state.db_enabled and state.current_project and state.db:
sync_to_db(state.db, state.current_project, file_path, input_data)
await asyncio.to_thread(sync_to_db, state.db, state.current_project, file_path, input_data)
data.clear()
data.update(input_data)