Skip to content

refactor(tui): extract SearchController and ResultsView from FNDApp - #49

Merged
ben-dev-au merged 5 commits into
mainfrom
refactor/tui-search-controller
Jun 11, 2026
Merged

refactor(tui): extract SearchController and ResultsView from FNDApp#49
ben-dev-au merged 5 commits into
mainfrom
refactor/tui-search-controller

Conversation

@ben-dev-au

Copy link
Copy Markdown
Owner

Fourth step of the FNDApp decomposition (stacks on #48).

SearchController (fnd/tui/search_controller.py) owns the searcher handle, active query + match spec, result groups, search trace, synonyms, and ranking profile; run() is the single query entry point (the former _run_query, moved statement-for-statement) and _PrefixingSearcher moves with it. The preview-cache invalidation block inside run() / clear_results() still reaches through the app — it collapses into the preview component when that lands.

ResultsView (fnd/tui/results_view.py) renders and relabels the results tree; stateless.

Surface compatibility: the nine search fields (_groups, _searcher, _current_query, _current_match_spec, _highlights_enabled, _current_intent, _latest_trace, _synonyms, _ranking_profile) become read/write properties on the app; _run_query, _clear_query_results, _current_query_signature, _resolve_profile, _refresh_results_tree, _results_title, _target_for_node, _refit_after_resize stay as one-line delegators; toggle actions remain app actions.

Verification: ruff format/check, pyright strict, full suite (1660 passed, 4 skipped) — test suite byte-unchanged.

Move the pre-FNDApp module region (~1,100 lines) into dedicated modules:

- fnd/tui/widgets/markdown.py: the FNDMarkdown widget family, highlight
  span helpers, and the legacy-block markdown fallback
- fnd/tui/widgets/results_tree.py: ResultsTree
- fnd/tui/widgets/preview_container.py: PreviewContainer, PreviewCache,
  the open-with Hit adapter, and the preview-cache tunables
- fnd/tui/results_labels.py: row-label and score formatting

Pure relocation; every moved name keeps its spelling and app.py
re-exports the externally-imported surface, so sibling modules and the
test suite are unaffected. The lazy-mount/prune tunables that are read
at call time stay defined in app.py.
Move search-scope state (collections / sources / filters), the sidebar
panel rendering and toggle handlers, and UI-state persistence into
ScopeController (fnd/tui/scope_panel.py). The filter presentation
tables move with their only consumer.

FNDApp keeps its existing surface: the seven scope fields are exposed
as read/write properties delegating to the controller, the persistence
and panel-refresh entry points stay as one-line delegators, and the
@on tree handlers remain bound to the app class as thin forwards.
Cross-concern calls from scope code (query rerun, status refresh,
ranking-profile resolution) route back through the app.
Move the background-indexer lifecycle — the async task, cancel event,
event queue, run-generation counter, and the update-all chain
bookkeeping — into IndexerService (fnd/tui/indexer_service.py),
together with the start/resume/reindex entry points.

FNDApp keeps its existing surface: start_indexer remains a real app
method with an identical signature (chain continuations re-enter
through it, so a patched app method keeps intercepting starts), the
17 _indexer_* fields become read/write properties, and the resume /
reindex helpers stay as one-line delegators. indexer_modal.py and the
settings screens are untouched — they keep reading and writing the
app's _indexer_* accessors.
SearchController (fnd/tui/search_controller.py) owns the searcher
handle, active query + match spec, result groups, search trace,
synonyms, and ranking profile; run() is the single query entry point
and _PrefixingSearcher moves with it. The preview-cache invalidation
inside run()/clear_results() still reaches through the app until the
preview subsystem is extracted.

ResultsView (fnd/tui/results_view.py) renders and relabels the results
tree; it owns no state.

FNDApp keeps its existing surface: the nine search fields become
read/write properties, query entry points and helpers used by sibling
modules stay as one-line delegators, and @on handlers / actions remain
bound to the app class.
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2834402f-0c44-423d-81f0-08a01ba9c597

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/tui-search-controller

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the main TUI application (FNDApp) by extracting search orchestration and results-tree rendering into two new dedicated classes: SearchController and ResultsView. This separation of concerns simplifies FNDApp while maintaining compatibility through property delegations for search state and results rendering. I have no feedback to provide as there are no review comments.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@ben-dev-au
ben-dev-au changed the base branch from refactor/tui-indexer-service to main June 11, 2026 10:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@fnd/tui/indexer_service.py`:
- Around line 120-163: The current branch that returns False when self.task is
running and not cancelling drops explicit start requests; change it to serialize
the new request instead of ignoring it: in the if self.task is not None and not
self.task.done() branch, when not (_bump_seq and cancelling) detect the case
where a new start should be queued (e.g. _bump_seq True or explicit start) and
instead of returning False immediately, bump self.run_seq, capture my_seq, and
create a deferred asyncio task (similar to the existing _await_then_start
closure) that awaits the old_task and then calls self._app.start_indexer(...)
with the same parameters and _bump_seq=False only if self.run_seq == my_seq;
keep the existing modal-open behavior
(self._app.push_screen(IndexerScreen(...))) if open_modal and _bump_seq, but do
not short-circuit returning False — schedule deferred_task (assign to
self.deferred_task) and then return False. Ensure you reuse symbols:
self.run_seq, my_seq, old_task, _await_then_start (or the same closure pattern),
self.deferred_task, and self._app.start_indexer to serialize the request rather
than dropping it.
- Around line 318-340: The auto-resume currently calls start_indexer with
default_index_dir(), which ignores the app's configured index path; change the
call in the resume block to pass the app's configured index directory (use
self._app._index_dir or the app's index-dir accessor) so that
self._app.start_indexer(collection="default", index_dir=...) uses the same path
the app uses elsewhere (see self._app._index_dir and on_reindex_complete for the
expected behavior).

In `@fnd/tui/scope_panel.py`:
- Around line 337-350: The bug is that still_claimed only scans self.collections
indiscriminately, so when toggling a collection off you must compute
still_claimed from the other collections that remain selected/active (i.e.,
exclude the collection being toggled) — iterate for other in self.collections if
other is not the collection being toggled (or if other.is_active/selected
depending on your code) and collect self.collection_source_ids(other); then
compute keep and update self.active_sources exactly as before so sources still
claimed by other active/partial collections are preserved in active_sources.
- Around line 489-502: The title source-counting logic in
_refresh_collections_panel_title() is undercounting because it only increments
active_source_count when source_id in active_sources, but
refresh_collections_panel() treats a collection as fully active when name in
self.collections; update the loop (over names/col.sources) so that if the
collection is present in self.collections (or collection_marker(n) == "●") you
add all of that collection's sources to active_source_count (e.g., increment by
len(col.sources)), otherwise fall back to checking each source_id in
active_sources; ensure you still compute total_source_count from cfg.collections
and use the same names/col lookup as present code so counts align with
refresh_collections_panel() and the row-marker logic.

In `@fnd/tui/search_controller.py`:
- Around line 156-162: When clearing the current result set (e.g., in the
QueryError exception handler around QueryPlan.from_user_text, in the other
branches that set self.groups = [], and inside clear_results()), also reset
controller trace state by setting self.latest_trace = None before calling
self._app._refresh_results_tree so stale explain output is not shown; update the
handlers that call self._show_query_notice and any places where groups/preview
state are emptied (including the blocks referenced around lines ~215-219 and
~398-421) to clear latest_trace alongside groups and preview state.

In `@fnd/tui/widgets/markdown.py`:
- Around line 680-689: Before calling the superclass Markdown.update(markdown)
reset per-render state so prior renders don't leak: clear or reset
self.build_done (e.g. call clear() or reinitialize the event) and set
self._first_match_block = None (and any related match/navigation state) before
invoking aw = super().update(markdown); then attach the done callback as before
so the new render's build_done is set only after the current parse+mount
completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2f46a310-fcbd-4e25-b1be-80e014ecb0eb

📥 Commits

Reviewing files that changed from the base of the PR and between 519252f and 97fdb6f.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • fnd/tui/app.py
  • fnd/tui/indexer_service.py
  • fnd/tui/results_labels.py
  • fnd/tui/results_view.py
  • fnd/tui/scope_panel.py
  • fnd/tui/search_controller.py
  • fnd/tui/widgets/markdown.py
  • fnd/tui/widgets/preview_container.py
  • fnd/tui/widgets/results_tree.py

Comment on lines +120 to +163
if self.task is not None and not self.task.done():
cancelling = self.cancel is not None and self.cancel.is_set()
if not (_bump_seq and cancelling):
# Either a chain continuation racing a busy task (defensive)
# or an actively-running run the user re-opened to watch:
# don't start a second, don't disturb its generation. Show
# the running modal so "view progress" still works.
if open_modal and _bump_seq:
self._app.push_screen(IndexerScreen(self.collection or collection))
return False
# In flight but already cancelling (cancel-then-start-again):
# serialise — bump the generation so the dying run's teardown
# knows it's superseded and won't clobber this request's chain,
# then start fresh once it has fully torn down.
self.run_seq += 1
my_seq = self.run_seq
with contextlib.suppress(Exception):
from fnd.extract._worker import request_cancel

request_cancel()
with contextlib.suppress(Exception):
self._app.notify("Finishing the cancelled run before starting…", timeout=3)
old_task = self.task

async def _await_then_start() -> None:
with contextlib.suppress(Exception):
await old_task
# Only proceed if no newer request superseded this one.
if self.run_seq != my_seq:
return
self._app.start_indexer(
collection=collection,
config=config,
index_dir=index_dir,
rebuild=rebuild,
open_modal=open_modal,
texturise_override=texturise_override,
skip_unchanged=skip_unchanged,
force_fresh=force_fresh,
_bump_seq=False,
)

self.deferred_task = asyncio.create_task(_await_then_start())
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t drop new explicit start requests while a run is still active.

Lines 120-129 currently treat every busy, non-cancelling run as “just reopen the modal”, so start(collection=..., rebuild=..., ...) is ignored unless some other path has already set self.cancel. That means the cancel-then-defer logic in Lines 130-163 never runs for a fresh restart request, and a request for a different collection/mode is silently lost instead of being serialised behind the current run. That is a user-visible regression against the behaviour-preserving contract described for this extraction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/indexer_service.py` around lines 120 - 163, The current branch that
returns False when self.task is running and not cancelling drops explicit start
requests; change it to serialize the new request instead of ignoring it: in the
if self.task is not None and not self.task.done() branch, when not (_bump_seq
and cancelling) detect the case where a new start should be queued (e.g.
_bump_seq True or explicit start) and instead of returning False immediately,
bump self.run_seq, capture my_seq, and create a deferred asyncio task (similar
to the existing _await_then_start closure) that awaits the old_task and then
calls self._app.start_indexer(...) with the same parameters and _bump_seq=False
only if self.run_seq == my_seq; keep the existing modal-open behavior
(self._app.push_screen(IndexerScreen(...))) if open_modal and _bump_seq, but do
not short-circuit returning False — schedule deferred_task (assign to
self.deferred_task) and then return False. Ensure you reuse symbols:
self.run_seq, my_seq, old_task, _await_then_start (or the same closure pattern),
self.deferred_task, and self._app.start_indexer to serialize the request rather
than dropping it.

Comment on lines +318 to +340
from fnd.config import default_index_dir
from fnd.index_runner import is_state_resumable, load_state, state_file_for

try:
from fnd.config import load as _load_config

cfg = _load_config()
except Exception:
return
if not cfg.defaults.indexer_auto_resume:
return
# Resume the default collection only for now — extending to named
# collections requires walking the reindex dir for *.state.toml.
state = load_state(state_file_for("default"))
if not is_state_resumable(
state, known_collections=set(cfg.collections), now=_dt.datetime.now(tz=_dt.UTC)
):
return
assert state is not None # narrowed by is_state_resumable
try:
self._app.start_indexer(
collection="default", index_dir=default_index_dir(), open_modal=False
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the app’s configured index directory for auto-resume as well.

Lines 338-339 hardcode default_index_dir(), but normal starts already honour self._app._index_dir and on_reindex_complete() reloads the searcher from that same app-owned path. For any app instance created with a custom index dir, auto-resume will rebuild one directory and then reopen/search another.

Suggested fix
-        from fnd.config import default_index_dir
         from fnd.index_runner import is_state_resumable, load_state, state_file_for
@@
             self._app.start_indexer(
-                collection="default", index_dir=default_index_dir(), open_modal=False
+                collection="default", index_dir=self._app._index_dir, open_modal=False
             )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from fnd.config import default_index_dir
from fnd.index_runner import is_state_resumable, load_state, state_file_for
try:
from fnd.config import load as _load_config
cfg = _load_config()
except Exception:
return
if not cfg.defaults.indexer_auto_resume:
return
# Resume the default collection only for now — extending to named
# collections requires walking the reindex dir for *.state.toml.
state = load_state(state_file_for("default"))
if not is_state_resumable(
state, known_collections=set(cfg.collections), now=_dt.datetime.now(tz=_dt.UTC)
):
return
assert state is not None # narrowed by is_state_resumable
try:
self._app.start_indexer(
collection="default", index_dir=default_index_dir(), open_modal=False
)
from fnd.index_runner import is_state_resumable, load_state, state_file_for
try:
from fnd.config import load as _load_config
cfg = _load_config()
except Exception:
return
if not cfg.defaults.indexer_auto_resume:
return
# Resume the default collection only for now — extending to named
# collections requires walking the reindex dir for *.state.toml.
state = load_state(state_file_for("default"))
if not is_state_resumable(
state, known_collections=set(cfg.collections), now=_dt.datetime.now(tz=_dt.UTC)
):
return
assert state is not None # narrowed by is_state_resumable
try:
self._app.start_indexer(
collection="default", index_dir=self._app._index_dir, open_modal=False
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/indexer_service.py` around lines 318 - 340, The auto-resume currently
calls start_indexer with default_index_dir(), which ignores the app's configured
index path; change the call in the resume block to pass the app's configured
index directory (use self._app._index_dir or the app's index-dir accessor) so
that self._app.start_indexer(collection="default", index_dir=...) uses the same
path the app uses elsewhere (see self._app._index_dir and on_reindex_complete
for the expected behavior).

Comment thread fnd/tui/scope_panel.py
Comment on lines +337 to +350
if source_ids:
# A source shared with a still-active collection stays
# on — only drop ids no remaining collection claims.
still_claimed = {
sid
for other in self.collections
for sid in self.collection_source_ids(other)
}
keep = (set(self.active_sources) - set(source_ids)) | (
set(self.active_sources) & still_claimed
)
# Preserve the user's relative ordering of the kept
# sources (set difference loses it).
self.active_sources = [s for s in self.active_sources if s in keep]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve shared sources that are still selected via another partial collection.

The still_claimed set only looks at self.collections. If the same source_id is still active through another collection that is currently partial, toggling this collection off drops that shared source from active_sources anyway. That silently narrows the remaining collection’s scope and flips its marker unexpectedly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/scope_panel.py` around lines 337 - 350, The bug is that still_claimed
only scans self.collections indiscriminately, so when toggling a collection off
you must compute still_claimed from the other collections that remain
selected/active (i.e., exclude the collection being toggled) — iterate for other
in self.collections if other is not the collection being toggled (or if
other.is_active/selected depending on your code) and collect
self.collection_source_ids(other); then compute keep and update
self.active_sources exactly as before so sources still claimed by other
active/partial collections are preserved in active_sources.

Comment thread fnd/tui/scope_panel.py
Comment on lines +489 to +502
active_sources = set(self.active_sources)
total_source_count = sum(len(cfg.collections[n].sources) for n in names if cfg)
active_source_count = 0
n_full_collections = 0
for n in names:
if self.collection_marker(n) == "●":
n_full_collections += 1
col = cfg.collections[n] if cfg else None
if not col:
continue
for s in col.sources:
source_id = str(Path(str(s.path)).expanduser().resolve())
if source_id in active_sources:
active_source_count += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the source count logic aligned with the row-marker logic.

refresh_collections_panel() treats name in self.collections as “all child sources active”, but _refresh_collections_panel_title() only counts source_id in active_sources. From the CLI / legacy collections-only state, the border title under-reports active sources after the first in-place toggle even though the row glyphs still show those collections as fully on.

Suggested fix
         for n in names:
             if self.collection_marker(n) == "●":
                 n_full_collections += 1
             col = cfg.collections[n] if cfg else None
             if not col:
                 continue
+            collection_full = n in self.collections
             for s in col.sources:
                 source_id = str(Path(str(s.path)).expanduser().resolve())
-                if source_id in active_sources:
+                if collection_full or source_id in active_sources:
                     active_source_count += 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/scope_panel.py` around lines 489 - 502, The title source-counting
logic in _refresh_collections_panel_title() is undercounting because it only
increments active_source_count when source_id in active_sources, but
refresh_collections_panel() treats a collection as fully active when name in
self.collections; update the loop (over names/col.sources) so that if the
collection is present in self.collections (or collection_marker(n) == "●") you
add all of that collection's sources to active_source_count (e.g., increment by
len(col.sources)), otherwise fall back to checking each source_id in
active_sources; ensure you still compute total_source_count from cfg.collections
and use the same names/col lookup as present code so counts align with
refresh_collections_panel() and the row-marker logic.

Comment on lines +156 to +162
try:
plan = QueryPlan.from_user_text(query)
except QueryError as e:
self._show_query_notice(e)
self.groups = []
self._app._refresh_results_tree()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear latest_trace whenever there is no current result set.

These branches empty groups / preview state but keep the previous trace alive, so :explain can still show the last successful plan after a syntax error, filter error, or clear_results(). Reset self.latest_trace alongside the result reset so the controller state stays self-consistent.

Suggested fix
         try:
             plan = QueryPlan.from_user_text(query)
         except QueryError as e:
             self._show_query_notice(e)
+            self.latest_trace = None
             self.groups = []
             self._app._refresh_results_tree()
             return
@@
         try:
             self.groups = self._search_layered(
@@
         except (QueryError, FilterError) as e:
             self._show_query_notice(e)
+            self.latest_trace = None
             self.groups = []
             self._app._refresh_results_tree()
             return
@@
         self.groups = []
+        self.latest_trace = None
         self._app._chunk_cache.clear()

Also applies to: 215-219, 398-421

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/search_controller.py` around lines 156 - 162, When clearing the
current result set (e.g., in the QueryError exception handler around
QueryPlan.from_user_text, in the other branches that set self.groups = [], and
inside clear_results()), also reset controller trace state by setting
self.latest_trace = None before calling self._app._refresh_results_tree so stale
explain output is not shown; update the handlers that call
self._show_query_notice and any places where groups/preview state are emptied
(including the blocks referenced around lines ~215-219 and ~398-421) to clear
latest_trace alongside groups and preview state.

Comment on lines +680 to +689
def update(self, markdown): # type: ignore[no-untyped-def, override]
# Textual's dispatcher walks the MRO and invokes every class's
# _on_mount — overriding _on_mount and calling super() ran
# Markdown._on_mount twice; the second pass saw _initial_markdown
# already consumed and called update("") which removed all
# blocks. Hook into update() instead: AwaitComplete's future
# fires when parse+mount completes — set build_done from there.
aw = super().update(markdown)
aw._future.add_done_callback(lambda _: self.build_done.set()) # type: ignore[attr-defined]
return aw

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset per-render state before delegating to Markdown.update().

On the second update(), build_done is already set and _first_match_block still points at the previous block tree. Any caller waiting for build completion can run early, and match navigation can jump to a stale anchor if the new render’s first hit moved or disappeared.

Suggested fix
     def update(self, markdown):  # type: ignore[no-untyped-def, override]
+        self._first_match_block = None
+        self.build_done.clear()
         # Textual's dispatcher walks the MRO and invokes every class's
         # _on_mount — overriding _on_mount and calling super() ran
         # Markdown._on_mount twice; the second pass saw _initial_markdown
         # already consumed and called update("") which removed all
         # blocks. Hook into update() instead: AwaitComplete's future
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def update(self, markdown): # type: ignore[no-untyped-def, override]
# Textual's dispatcher walks the MRO and invokes every class's
# _on_mount — overriding _on_mount and calling super() ran
# Markdown._on_mount twice; the second pass saw _initial_markdown
# already consumed and called update("") which removed all
# blocks. Hook into update() instead: AwaitComplete's future
# fires when parse+mount completes — set build_done from there.
aw = super().update(markdown)
aw._future.add_done_callback(lambda _: self.build_done.set()) # type: ignore[attr-defined]
return aw
def update(self, markdown): # type: ignore[no-untyped-def, override]
self._first_match_block = None
self.build_done.clear()
# Textual's dispatcher walks the MRO and invokes every class's
# _on_mount — overriding _on_mount and calling super() ran
# Markdown._on_mount twice; the second pass saw _initial_markdown
# already consumed and called update("") which removed all
# blocks. Hook into update() instead: AwaitComplete's future
# fires when parse+mount completes — set build_done from there.
aw = super().update(markdown)
aw._future.add_done_callback(lambda _: self.build_done.set()) # type: ignore[attr-defined]
return aw
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/widgets/markdown.py` around lines 680 - 689, Before calling the
superclass Markdown.update(markdown) reset per-render state so prior renders
don't leak: clear or reset self.build_done (e.g. call clear() or reinitialize
the event) and set self._first_match_block = None (and any related
match/navigation state) before invoking aw = super().update(markdown); then
attach the done callback as before so the new render's build_done is set only
after the current parse+mount completes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant