diff --git a/src/gui_2.py b/src/gui_2.py index 4c17ec1f..c1b03a46 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -1671,71 +1671,71 @@ def render_log_management(app: App) -> None: if app.perf_profiling_enabled: app.perf_monitor.start_component("_render_log_management") with imscope.window("Log Management", app.show_windows["Log Management"]) as (exp, opened): app.show_windows["Log Management"] = bool(opened) - if exp: - if app._log_registry is None: app._log_registry = log_registry.LogRegistry(str(paths.get_logs_dir() / "log_registry.toml")) - else: - if imgui.button("Refresh Registry"): - if app._log_registry is not None: app._log_registry.load_registry() - imgui.same_line() - if imgui.button("Load Log"): app.cb_load_prior_log() - imgui.same_line() - if imgui.button("Force Prune Logs"): app.controller.event_queue.put("gui_task", {"action": "click", "item": "btn_prune_logs"}) + if app._log_registry is None: + app._log_registry = log_registry.LogRegistry(str(paths.get_logs_dir() / "log_registry.toml")) + app._log_registry.load_registry() + if imgui.button("Refresh Registry"): + if app._log_registry is not None: app._log_registry.load_registry() + imgui.same_line() + if imgui.button("Load Log"): app.cb_load_prior_log() + imgui.same_line() + if imgui.button("Force Prune Logs"): app.controller.event_queue.put("gui_task", {"action": "click", "item": "btn_prune_logs"}) - registry = app._log_registry - sessions = registry.data - if imgui.begin_table("sessions_table", 7, imgui.TableFlags_.borders | imgui.TableFlags_.row_bg | imgui.TableFlags_.resizable): - imgui.table_setup_column("Session ID") - imgui.table_setup_column("Start Time") - imgui.table_setup_column("Star") - imgui.table_setup_column("Reason") - imgui.table_setup_column("Size (KB)") - imgui.table_setup_column("Msgs") - imgui.table_setup_column("Actions") - imgui.table_headers_row() - for session_id, s_data in sessions.items(): - imgui.table_next_row() - imgui.table_next_column() - imgui.text(session_id) - imgui.table_next_column() - imgui.text(s_data.get("start_time", "")) - imgui.table_next_column() - whitelisted = s_data.get("whitelisted", False) - if whitelisted: - imgui.text_colored(vec4(255, 215, 0), "YES") - else: - imgui.text("NO") - metadata = s_data.get("metadata") or {} - imgui.table_next_column() - imgui.text(metadata.get("reason", "")) - imgui.table_next_column() - imgui.text(str(metadata.get("size_kb", ""))) - imgui.table_next_column() - imgui.text(str(metadata.get("message_count", ""))) - imgui.table_next_column() - if imgui.button(f"Load##{session_id}"): - app.cb_load_prior_log(s_data.get("path")) - imgui.same_line() - if whitelisted: - if imgui.button(f"Unstar##{session_id}"): - registry.update_session_metadata( - session_id, - message_count=int(metadata.get("message_count") or 0), - errors=int(metadata.get("errors") or 0), - size_kb=int(metadata.get("size_kb") or 0), - whitelisted=False, - reason=str(metadata.get("reason") or "") - ) - else: - if imgui.button(f"Star##{session_id}"): - registry.update_session_metadata( - session_id, - message_count=int(metadata.get("message_count") or 0), - errors=int(metadata.get("errors") or 0), - size_kb=int(metadata.get("size_kb") or 0), - whitelisted=True, - reason="Manually whitelisted" - ) - imgui.end_table() + registry = app._log_registry + sessions = registry.data + if imgui.begin_table("sessions_table", 7, imgui.TableFlags_.borders | imgui.TableFlags_.row_bg | imgui.TableFlags_.resizable): + imgui.table_setup_column("Session ID") + imgui.table_setup_column("Start Time") + imgui.table_setup_column("Star") + imgui.table_setup_column("Reason") + imgui.table_setup_column("Size (KB)") + imgui.table_setup_column("Msgs") + imgui.table_setup_column("Actions") + imgui.table_headers_row() + for session_id, s_data in sessions.items(): + imgui.table_next_row() + imgui.table_next_column() + imgui.text(session_id) + imgui.table_next_column() + imgui.text(s_data.get("start_time", "")) + imgui.table_next_column() + whitelisted = s_data.get("whitelisted", False) + if whitelisted: + imgui.text_colored(vec4(255, 215, 0), "YES") + else: + imgui.text("NO") + metadata = s_data.get("metadata") or {} + imgui.table_next_column() + imgui.text(metadata.get("reason", "")) + imgui.table_next_column() + imgui.text(str(metadata.get("size_kb", ""))) + imgui.table_next_column() + imgui.text(str(metadata.get("message_count", ""))) + imgui.table_next_column() + if imgui.button(f"Load##{session_id}"): + app.cb_load_prior_log(s_data.get("path")) + imgui.same_line() + if whitelisted: + if imgui.button(f"Unstar##{session_id}"): + registry.update_session_metadata( + session_id, + message_count=int(metadata.get("message_count") or 0), + errors=int(metadata.get("errors") or 0), + size_kb=int(metadata.get("size_kb") or 0), + whitelisted=False, + reason=str(metadata.get("reason") or "") + ) + else: + if imgui.button(f"Star##{session_id}"): + registry.update_session_metadata( + session_id, + message_count=int(metadata.get("message_count") or 0), + errors=int(metadata.get("errors") or 0), + size_kb=int(metadata.get("size_kb") or 0), + whitelisted=True, + reason="Manually whitelisted" + ) + imgui.end_table() if app.perf_profiling_enabled: app.perf_monitor.end_component("_render_log_management") @@ -3463,6 +3463,7 @@ def render_discussion_entry(app: App, entry: dict, index: int) -> None: imgui.same_line() if imgui.button("Del"): if entry in app.disc_entries: app.disc_entries.remove(entry) + imgui.end_group() draw_list.channels_merge() return imgui.same_line() diff --git a/src/markdown_helper.py b/src/markdown_helper.py index c37c3284..75ca5aee 100644 --- a/src/markdown_helper.py +++ b/src/markdown_helper.py @@ -117,30 +117,71 @@ class MarkdownRenderer: from src.markdown_table import parse_tables, render_table blocks = parse_tables(text) - sentinel = "\x00TBL{}\x00" - masked = text - for idx, block in enumerate(blocks): - start, end = block.span - original_block = "\n".join(masked.splitlines()[start:end]) - masked = masked.replace(original_block, sentinel.format(idx), 1) + lines = text.splitlines(keepends=True) + if not lines: + return - parts = re.split(r"(```[\s\S]*?```)", masked) + table_at_line: dict[int, int] = {b.span[0]: i for i, b in enumerate(blocks)} + table_end: dict[int, int] = {b.span[0]: b.span[1] for i, b in enumerate(blocks)} + md_buf: list[str] = [] + code_buf: list[str] = [] block_idx = 0 - for part in parts: - if part.startswith("```") and part.endswith("```"): - self._render_code_block(part, context_id, block_idx) - block_idx += 1 - elif part.strip(): - sub_parts = re.split(r"(\x00TBL\d+\x00)", part) - for sp in sub_parts: - if sp.startswith("\x00TBL") and sp.endswith("\x00"): - tbl_idx = int(sp[4:-1]) - try: render_table(blocks[tbl_idx]) - except Exception: imgui.text(sp) - else: - if sp.strip(): imgui_md.render(sp) + in_fence = False + fence_marker = "" + def flush_md() -> None: + if md_buf: + chunk = "".join(md_buf) + if chunk.strip(): + imgui_md.render(chunk) + md_buf.clear() + + def flush_code() -> None: + nonlocal block_idx + if code_buf: + chunk = "".join(code_buf) + self._render_code_block(chunk, context_id, block_idx) + block_idx += 1 + code_buf.clear() + + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.lstrip().rstrip("\r\n") + if stripped.startswith("```"): + if not in_fence: + in_fence = True + fence_marker = stripped[:3] + flush_md() + code_buf.append(line) + i += 1 + continue + if fence_marker and stripped.startswith(fence_marker): + in_fence = False + code_buf.append(line) + flush_code() + fence_marker = "" + i += 1 + continue + code_buf.append(line) + i += 1 + continue + if in_fence: + code_buf.append(line) + i += 1 + continue + if i in table_at_line: + flush_md() + try: render_table(blocks[table_at_line[i]]) + except Exception: pass + i = table_end[i] + continue + md_buf.append(line) + i += 1 + + flush_md() + flush_code() def render_unindented(self, text: str) -> None: """Render Markdown text with automatic unindentation.""" imgui_md.render_unindented(text) diff --git a/tests/test_log_management_first_open.py b/tests/test_log_management_first_open.py new file mode 100644 index 00000000..8c07d9c9 --- /dev/null +++ b/tests/test_log_management_first_open.py @@ -0,0 +1,32 @@ +import os, tempfile, tomli_w +from unittest.mock import patch, MagicMock +from src.gui_2 import render_log_management + +def test_log_management_populates_registry_on_first_open(app_instance): + with tempfile.TemporaryDirectory() as tmp: + reg_path = os.path.join(tmp, "log_registry.toml") + tomli_w.dump({"s1": {"start_time": "2026-06-03T10:00:00", "path": "C:/x/s1", "whitelisted": False}}, open(reg_path, "wb")) + app_instance._log_registry = None + app_instance.show_windows = {"Log Management": True} + app_instance.perf_profiling_enabled = False + with patch("src.gui_2.paths") as mock_paths, \ + patch("src.gui_2.imgui") as mock_imgui, \ + patch("src.gui_2.imscope") as mock_imscope: + mock_paths.get_logs_dir.return_value = MagicMock() + mock_paths.get_logs_dir.return_value.__truediv__ = lambda self, x: reg_path + mock_imgui.collapsing_header = MagicMock(return_value=True) + mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})() + mock_imgui.TableColumnFlags_ = type("C", (), {"width_fixed": 1, "width_stretch": 2})() + mock_imgui.begin_table.return_value = True + mock_imgui.button.return_value = False + mock_imgui.table_next_row = lambda: None + mock_imgui.table_next_column = lambda: None + mock_imgui.text = lambda *a, **k: None + mock_imgui.text_colored = lambda *a, **k: None + mock_imgui.text_disabled = lambda *a, **k: None + mock_imgui.end_table = lambda: None + mock_imscope.window.return_value.__enter__.return_value = (True, True) + app_instance.cb_load_prior_log = MagicMock() + render_log_management(app_instance) + assert app_instance._log_registry is not None + assert "s1" in app_instance._log_registry.data, f"Registry should be populated on first open. Got: {list(app_instance._log_registry.data.keys())}" diff --git a/tests/test_markdown_render_robust.py b/tests/test_markdown_render_robust.py new file mode 100644 index 00000000..b6df6072 --- /dev/null +++ b/tests/test_markdown_render_robust.py @@ -0,0 +1,56 @@ +from unittest.mock import patch +from src.markdown_helper import MarkdownRenderer +from src.markdown_table import parse_tables + +def _mock_table_calls(mock_imgui): + mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})() + mock_imgui.begin_table.return_value = True + mock_imgui.table_next_column = lambda: None + mock_imgui.table_next_row = lambda: None + mock_imgui.table_headers_row = lambda: None + mock_imgui.text = lambda *a, **k: None + mock_imgui.end_table = lambda: None + +def test_tables_in_crlf_text_all_get_masked(): + text = ( + "# Title\r\n" + "\r\n" + "| A | B |\r\n" + "|---|---|\r\n" + "| 1 | 2 |\r\n" + "\r\n" + "Some prose.\r\n" + "\r\n" + "| X | Y |\r\n" + "|---|---|\r\n" + "| 3 | 4 |\r\n" + ) + blocks = parse_tables(text) + assert len(blocks) == 2 + with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui: + _mock_table_calls(mock_table_imgui) + MarkdownRenderer().render(text, context_id="t") + full = "".join(str(c) for c in mock_md.render.call_args_list) + for needle in ["| A | B |", "|---|", "| 1 | 2 |", "| X | Y |", "| 3 | 4 |"]: + assert needle not in full, f"Raw table text leaked to imgui_md: {needle!r} in calls: {full!r}" + +def test_duplicate_table_content_both_get_replaced(): + text = ( + "| A | B |\r\n" + "|---|---|\r\n" + "| 1 | 2 |\r\n" + "\r\n" + "Middle prose.\r\n" + "\r\n" + "| A | B |\r\n" + "|---|---|\r\n" + "| 1 | 2 |\r\n" + ) + blocks = parse_tables(text) + assert len(blocks) == 2 + with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui: + _mock_table_calls(mock_table_imgui) + MarkdownRenderer().render(text, context_id="dup") + full = "".join(str(c) for c in mock_md.render.call_args_list) + assert "| A | B |" not in full, f"Raw table text leaked on duplicate: {full!r}" +