f
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Iterable
|
||||
import traceback
|
||||
|
||||
from textual import on, work
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select
|
||||
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox
|
||||
from pathlib import Path
|
||||
|
||||
from SYS.config import load_config, save_config, reload_config, global_config, count_changed_entries, ConfigSaveConflict
|
||||
from SYS.database import db
|
||||
from SYS.logger import log
|
||||
from SYS.logger import log, debug
|
||||
from Store.registry import _discover_store_classes, _required_keys_for
|
||||
from ProviderCore.registry import list_providers
|
||||
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
|
||||
@@ -81,11 +82,6 @@ class ConfigModal(ModalScreen):
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
.paste-btn {
|
||||
width: 10;
|
||||
margin-left: 1;
|
||||
}
|
||||
|
||||
#config-actions {
|
||||
height: 3;
|
||||
align: right middle;
|
||||
@@ -112,6 +108,20 @@ class ConfigModal(ModalScreen):
|
||||
Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
/* Inline matrix rooms list sizing & style (larger, scrollable) */
|
||||
#matrix-rooms-inline {
|
||||
height: 16;
|
||||
border: solid $surface;
|
||||
padding: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.matrix-room-row {
|
||||
border-bottom: solid $surface;
|
||||
padding: 1 0;
|
||||
align: left middle;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -126,6 +136,9 @@ class ConfigModal(ModalScreen):
|
||||
self._matrix_status: Optional[Static] = None
|
||||
self._matrix_test_running = False
|
||||
self._editor_snapshot: Optional[Dict[str, Any]] = None
|
||||
# Inline matrix rooms controls
|
||||
self._matrix_inline_list: Optional[ListView] = None
|
||||
self._matrix_inline_checkbox_map: Dict[str, str] = {}
|
||||
# Path to the database file used by this process (for diagnostics)
|
||||
self._db_path = str(db.db_path)
|
||||
|
||||
@@ -277,18 +290,30 @@ class ConfigModal(ModalScreen):
|
||||
container.mount(Label(label_text))
|
||||
inp_id = f"global-{idx}"
|
||||
self._input_id_map[inp_id] = found_key
|
||||
|
||||
|
||||
if choices:
|
||||
select_options = [(str(c), str(c)) for c in choices]
|
||||
if current_val not in [str(c) for c in choices]:
|
||||
select_options.insert(0, (current_val, current_val))
|
||||
sel = Select(select_options, value=current_val, id=inp_id)
|
||||
# Normalize boolean-like choices to lowercase ('true'/'false') to avoid duplicate choices
|
||||
normalized_choices = []
|
||||
for c in choices:
|
||||
s = str(c)
|
||||
if s.lower() in ("true", "false"):
|
||||
normalized_choices.append(s.lower())
|
||||
else:
|
||||
normalized_choices.append(s)
|
||||
|
||||
select_options = [(str(c), str(c)) for c in normalized_choices]
|
||||
# Normalize current value as well
|
||||
cur_val = str(current_val) if current_val is not None else ""
|
||||
if cur_val.lower() in ("true", "false"):
|
||||
cur_val = cur_val.lower()
|
||||
if cur_val not in normalized_choices:
|
||||
select_options.insert(0, (cur_val, cur_val))
|
||||
sel = Select(select_options, value=cur_val, id=inp_id)
|
||||
container.mount(sel)
|
||||
else:
|
||||
row = Horizontal(classes="field-row")
|
||||
container.mount(row)
|
||||
row.mount(Input(value=current_val, id=inp_id, classes="config-input"))
|
||||
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||
idx += 1
|
||||
|
||||
# Show any other top-level keys not in schema
|
||||
@@ -444,13 +469,23 @@ class ConfigModal(ModalScreen):
|
||||
idx = 0
|
||||
for k, v in section.items():
|
||||
if k.startswith("_"): continue
|
||||
|
||||
|
||||
# Skip low-level keys that shouldn't be editable via the form UI
|
||||
if (
|
||||
item_type == "provider"
|
||||
and isinstance(item_name, str)
|
||||
and item_name.strip().lower() == "matrix"
|
||||
and str(k or "").strip().lower() in ("rooms", "cached_rooms")
|
||||
):
|
||||
# These are managed by the inline UI and should not be edited directly.
|
||||
continue
|
||||
|
||||
# Deduplicate keys case-insensitively (e.g. name vs NAME vs Name)
|
||||
k_upper = k.upper()
|
||||
if k_upper in existing_keys_upper:
|
||||
continue
|
||||
existing_keys_upper.add(k_upper)
|
||||
|
||||
|
||||
# Determine display props from schema
|
||||
label_text = k
|
||||
is_secret = False
|
||||
@@ -469,22 +504,29 @@ class ConfigModal(ModalScreen):
|
||||
self._input_id_map[inp_id] = k
|
||||
|
||||
if choices:
|
||||
# Select takes a list of (label, value) tuples
|
||||
# Select takes a list of (label, value) tuples; normalize boolean-like values
|
||||
select_options = []
|
||||
choice_values = []
|
||||
for c in choices:
|
||||
if isinstance(c, tuple) and len(c) == 2:
|
||||
select_options.append((str(c[0]), str(c[1])))
|
||||
choice_values.append(str(c[1]))
|
||||
label = str(c[0])
|
||||
val = str(c[1])
|
||||
else:
|
||||
select_options.append((str(c), str(c)))
|
||||
choice_values.append(str(c))
|
||||
|
||||
label = str(c)
|
||||
val = str(c)
|
||||
if val.lower() in ("true", "false"):
|
||||
val = val.lower()
|
||||
label = val
|
||||
select_options.append((label, val))
|
||||
choice_values.append(val)
|
||||
|
||||
# If current value not in choices, add it or stay blank
|
||||
current_val = str(v)
|
||||
if current_val.lower() in ("true", "false"):
|
||||
current_val = current_val.lower()
|
||||
if current_val not in choice_values:
|
||||
select_options.insert(0, (current_val, current_val))
|
||||
|
||||
|
||||
sel = Select(select_options, value=current_val, id=inp_id)
|
||||
container.mount(sel)
|
||||
else:
|
||||
@@ -494,7 +536,6 @@ class ConfigModal(ModalScreen):
|
||||
if is_secret:
|
||||
inp.password = True
|
||||
row.mount(inp)
|
||||
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||
idx += 1
|
||||
|
||||
# Add required/optional fields from schema that are missing
|
||||
@@ -518,16 +559,25 @@ class ConfigModal(ModalScreen):
|
||||
choice_values = []
|
||||
for c in choices:
|
||||
if isinstance(c, tuple) and len(c) == 2:
|
||||
select_options.append((str(c[0]), str(c[1])))
|
||||
choice_values.append(str(c[1]))
|
||||
label = str(c[0])
|
||||
val = str(c[1])
|
||||
else:
|
||||
select_options.append((str(c), str(c)))
|
||||
choice_values.append(str(c))
|
||||
|
||||
if default_val not in choice_values:
|
||||
select_options.insert(0, (default_val, default_val))
|
||||
label = str(c)
|
||||
val = str(c)
|
||||
if val.lower() in ("true", "false"):
|
||||
val = val.lower()
|
||||
label = val
|
||||
select_options.append((label, val))
|
||||
choice_values.append(val)
|
||||
|
||||
sel = Select(select_options, value=default_val, id=inp_id)
|
||||
# Normalize default/current value
|
||||
current_val = str(default_val) if default_val is not None else ""
|
||||
if current_val.lower() in ("true", "false"):
|
||||
current_val = current_val.lower()
|
||||
if current_val not in choice_values:
|
||||
select_options.insert(0, (current_val, current_val))
|
||||
|
||||
sel = Select(select_options, value=current_val, id=inp_id)
|
||||
container.mount(sel)
|
||||
else:
|
||||
row = Horizontal(classes="field-row")
|
||||
@@ -556,7 +606,6 @@ class ConfigModal(ModalScreen):
|
||||
row = Horizontal(classes="field-row")
|
||||
container.mount(row)
|
||||
row.mount(Input(value="", id=inp_id, classes="config-input"))
|
||||
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
|
||||
idx += 1
|
||||
|
||||
# If it's a provider, we might have required keys (legacy check fallback)
|
||||
@@ -592,25 +641,101 @@ class ConfigModal(ModalScreen):
|
||||
row = Horizontal(classes="field-row")
|
||||
container.mount(row)
|
||||
row.mount(Button("Test connection", id="matrix-test-btn"))
|
||||
row.mount(Button("Choose default rooms", variant="primary", id="matrix-rooms-btn"))
|
||||
# Load rooms refreshes the inline list and caches the results (no popup)
|
||||
row.mount(Button("Load rooms", variant="primary", id="matrix-load-btn"))
|
||||
self._matrix_status = status
|
||||
|
||||
# Inline rooms list for selecting default rooms (populated after a successful test)
|
||||
container.mount(Label("Default Rooms", classes="config-label", id="matrix-inline-label"))
|
||||
# Start with empty list; it will be filled when test loads rooms
|
||||
container.mount(ListView(id="matrix-rooms-inline"))
|
||||
# Inline actions
|
||||
row2 = Horizontal(classes="field-row")
|
||||
container.mount(row2)
|
||||
row2.mount(Button("Select All", id="matrix-inline-select-all"))
|
||||
row2.mount(Button("Clear All", id="matrix-inline-clear"))
|
||||
save_inline = Button("Save defaults", variant="success", id="matrix-inline-save")
|
||||
save_inline.disabled = True
|
||||
row2.mount(save_inline)
|
||||
# Local bookkeeping maps
|
||||
try:
|
||||
self._matrix_inline_checkbox_map = {}
|
||||
self._matrix_inline_list = self.query_one("#matrix-rooms-inline", ListView)
|
||||
# Do NOT auto-render cached rooms here; only show explicitly saved defaults
|
||||
try:
|
||||
existing_ids = self._parse_matrix_rooms_value()
|
||||
cached = self._get_cached_matrix_rooms()
|
||||
rooms_to_render: List[Dict[str, Any]] = []
|
||||
|
||||
# Start with cached rooms (from last Load). These are shown
|
||||
# in the inline Default Rooms list but are unselected unless
|
||||
# they are in the saved defaults list.
|
||||
if cached:
|
||||
rooms_to_render.extend(cached)
|
||||
|
||||
# Ensure saved default room ids are present and will be selected
|
||||
if existing_ids:
|
||||
cached_ids = {str(r.get("room_id") or "").strip() for r in rooms_to_render if isinstance(r, dict)}
|
||||
need_resolve = [rid for rid in existing_ids if rid not in cached_ids]
|
||||
if need_resolve:
|
||||
try:
|
||||
resolved = self._resolve_matrix_rooms_by_ids(need_resolve)
|
||||
if resolved:
|
||||
rooms_to_render.extend(resolved)
|
||||
else:
|
||||
rooms_to_render.extend([{"room_id": rid, "name": ""} for rid in need_resolve])
|
||||
except Exception:
|
||||
rooms_to_render.extend([{"room_id": rid, "name": ""} for rid in need_resolve])
|
||||
|
||||
# Deduplicate while preserving order
|
||||
deduped: List[Dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
for r in rooms_to_render:
|
||||
try:
|
||||
rid = str(r.get("room_id") or "").strip()
|
||||
if not rid or rid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(rid)
|
||||
deduped.append(r)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if self._matrix_inline_list is not None and deduped:
|
||||
try:
|
||||
self._render_matrix_rooms_inline(deduped)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
self._matrix_inline_checkbox_map = {}
|
||||
self._matrix_inline_list = None
|
||||
|
||||
def create_field(self, name: str, value: Any, id: str) -> Vertical:
|
||||
# This method is now unused - we mount labels and inputs directly
|
||||
v = Vertical(classes="config-field")
|
||||
return v
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
if not event.item: return
|
||||
if event.item.id == "cat-globals":
|
||||
# Only respond to selections from the left-hand category list. Avoid
|
||||
# resetting editor state when other ListViews (like the inline rooms
|
||||
# list) trigger selection events.
|
||||
if not event.item:
|
||||
return
|
||||
item_id = getattr(event.item, "id", None)
|
||||
if item_id not in ("cat-globals", "cat-stores", "cat-providers", "cat-tools"):
|
||||
return
|
||||
|
||||
if item_id == "cat-globals":
|
||||
self.current_category = "globals"
|
||||
elif event.item.id == "cat-stores":
|
||||
elif item_id == "cat-stores":
|
||||
self.current_category = "stores"
|
||||
elif event.item.id == "cat-providers":
|
||||
elif item_id == "cat-providers":
|
||||
self.current_category = "providers"
|
||||
elif event.item.id == "cat-tools":
|
||||
elif item_id == "cat-tools":
|
||||
self.current_category = "tools"
|
||||
|
||||
|
||||
# Reset editor state and refresh view for the new category
|
||||
self.editing_item_name = None
|
||||
self.editing_item_type = None
|
||||
self.refresh_view()
|
||||
@@ -745,17 +870,63 @@ class ConfigModal(ModalScreen):
|
||||
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected)
|
||||
elif bid == "matrix-test-btn":
|
||||
self._request_matrix_test()
|
||||
elif bid == "matrix-rooms-btn":
|
||||
self._open_matrix_room_picker()
|
||||
# Restore UI removed: backups/audit remain available programmatically
|
||||
elif bid.startswith("paste-"):
|
||||
# Programmatic paste button
|
||||
target_id = bid.replace("paste-", "")
|
||||
elif bid == "matrix-load-btn":
|
||||
# Refresh the inline rooms list and cache the results (no popup)
|
||||
self._request_matrix_load()
|
||||
elif bid == "matrix-inline-select-all":
|
||||
for checkbox_id in list(self._matrix_inline_checkbox_map.keys()):
|
||||
try:
|
||||
cb = self.query_one(f"#{checkbox_id}", Checkbox)
|
||||
cb.value = True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
inp = self.query_one(f"#{target_id}", Input)
|
||||
self.focus_and_paste(inp)
|
||||
self.query_one("#matrix-inline-save", Button).disabled = False
|
||||
except Exception:
|
||||
pass
|
||||
elif bid == "matrix-inline-clear":
|
||||
for checkbox_id in list(self._matrix_inline_checkbox_map.keys()):
|
||||
try:
|
||||
cb = self.query_one(f"#{checkbox_id}", Checkbox)
|
||||
cb.value = False
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.query_one("#matrix-inline-save", Button).disabled = True
|
||||
except Exception:
|
||||
pass
|
||||
elif bid == "matrix-inline-save":
|
||||
selected: List[str] = []
|
||||
for checkbox_id, room_id in self._matrix_inline_checkbox_map.items():
|
||||
try:
|
||||
cb = self.query_one(f"#{checkbox_id}", Checkbox)
|
||||
if cb.value and room_id:
|
||||
selected.append(room_id)
|
||||
except Exception:
|
||||
pass
|
||||
if not selected:
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update("No default rooms were saved.")
|
||||
return
|
||||
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
|
||||
matrix_block["rooms"] = ", ".join(selected)
|
||||
changed = count_changed_entries(self.config_data)
|
||||
try:
|
||||
entries = save_config(self.config_data)
|
||||
except Exception as exc:
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update(f"Saving default rooms failed: {exc}")
|
||||
return
|
||||
self.config_data = reload_config()
|
||||
if self._matrix_status:
|
||||
status = f"Saved {len(selected)} default room(s) ({changed} change(s)) to {db.db_path.name}."
|
||||
self._matrix_status.update(status)
|
||||
try:
|
||||
self.query_one("#matrix-inline-save", Button).disabled = True
|
||||
except Exception:
|
||||
pass
|
||||
self.refresh_view()
|
||||
|
||||
|
||||
async def focus_and_paste(self, inp: Input) -> None:
|
||||
if hasattr(self.app, "paste_from_clipboard"):
|
||||
@@ -1025,6 +1196,20 @@ class ConfigModal(ModalScreen):
|
||||
if self._matrix_test_running:
|
||||
return
|
||||
self._synchronize_inputs_to_config()
|
||||
|
||||
# Quick client-side pre-check before attempting to save/test to provide
|
||||
# immediate guidance when required fields are missing.
|
||||
try:
|
||||
matrix_block = self.config_data.get("provider", {}).get("matrix", {})
|
||||
hs = matrix_block.get("homeserver")
|
||||
token = matrix_block.get("access_token")
|
||||
if not hs or not token:
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update("Matrix test skipped: please set both 'homeserver' and 'access_token' before testing.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update("Saving configuration before testing…")
|
||||
changed = count_changed_entries(self.config_data)
|
||||
@@ -1050,23 +1235,215 @@ class ConfigModal(ModalScreen):
|
||||
rooms = provider.list_rooms()
|
||||
self.app.call_from_thread(self._matrix_test_result, True, rooms, None)
|
||||
except Exception as exc:
|
||||
self.app.call_from_thread(self._matrix_test_result, False, [], str(exc))
|
||||
# Log full traceback for diagnostics but present a concise, actionable
|
||||
# message to the user in the UI.
|
||||
tb = traceback.format_exc()
|
||||
try:
|
||||
debug(f"[matrix] Test connection failed: {exc}\n{tb}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _matrix_test_result(
|
||||
self,
|
||||
success: bool,
|
||||
rooms: List[Dict[str, Any]],
|
||||
error: Optional[str],
|
||||
) -> None:
|
||||
msg = str(exc) or "Matrix test failed"
|
||||
m_lower = msg.lower()
|
||||
if "auth" in m_lower or "authentication" in m_lower:
|
||||
msg = msg + ". Please verify your access token and try again."
|
||||
elif "homeserver" in m_lower or "missing" in m_lower:
|
||||
msg = msg + ". Check your homeserver URL (include https://)."
|
||||
else:
|
||||
msg = msg + " (see logs for details)"
|
||||
|
||||
self.app.call_from_thread(self._matrix_test_result, False, [], msg)
|
||||
|
||||
def _get_cached_matrix_rooms(self) -> List[Dict[str, Any]]:
|
||||
"""Return cached rooms stored in the provider config (normalized).
|
||||
|
||||
The config value can be a list/dict, a JSON string, or a Python literal
|
||||
string (repr). This method normalizes the input and returns a list of
|
||||
dicts containing 'room_id' and 'name'. Malformed inputs are ignored.
|
||||
"""
|
||||
try:
|
||||
block = self._get_matrix_provider_block()
|
||||
raw = block.get("cached_rooms")
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
# If it's already a list or tuple, normalize each element
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return self._normalize_cached_raw(list(raw))
|
||||
|
||||
# If it's a dict, wrap and normalize
|
||||
if isinstance(raw, dict):
|
||||
return self._normalize_cached_raw([raw])
|
||||
|
||||
# If it's a string, try JSON -> ast.literal_eval -> regex ID extraction
|
||||
if isinstance(raw, str):
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return []
|
||||
|
||||
# Try JSON first (strict)
|
||||
try:
|
||||
import json
|
||||
|
||||
parsed = json.loads(s)
|
||||
if isinstance(parsed, (list, tuple, dict)):
|
||||
return self._normalize_cached_raw(parsed if isinstance(parsed, (list, tuple)) else [parsed])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try Python literal eval (accepts single quotes, repr-style lists)
|
||||
try:
|
||||
import ast
|
||||
|
||||
parsed = ast.literal_eval(s)
|
||||
if isinstance(parsed, (list, tuple, dict)):
|
||||
return self._normalize_cached_raw(parsed if isinstance(parsed, (list, tuple)) else [parsed])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try to extract dict-like pairs for room_id/name when the string looks like
|
||||
# a Python repr or partial dict fragment (e.g., "'room_id': '!r1', 'name': 'Room'"
|
||||
try:
|
||||
import re
|
||||
|
||||
pair_pat = re.compile(r"[\"']room_id[\"']\s*:\s*[\"'](?P<id>[^\"']+)[\"']\s*,\s*[\"']name[\"']\s*:\s*[\"'](?P<name>[^\"']+)[\"']")
|
||||
pairs = [m.groupdict() for m in pair_pat.finditer(s)]
|
||||
if pairs:
|
||||
out = []
|
||||
for p in pairs:
|
||||
rid = str(p.get("id") or "").strip()
|
||||
name = str(p.get("name") or "").strip()
|
||||
if rid:
|
||||
out.append({"room_id": rid, "name": name})
|
||||
if out:
|
||||
return out
|
||||
|
||||
# As a last resort, extract candidate room ids via regex (look for leading '!')
|
||||
ids = re.findall(r"![-A-Za-z0-9._=]+(?::[-A-Za-z0-9._=]+)?", s)
|
||||
if ids:
|
||||
return [{"room_id": rid, "name": ""} for rid in ids]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return []
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _normalize_cached_raw(self, parsed: List[Any]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for it in parsed:
|
||||
try:
|
||||
if isinstance(it, dict):
|
||||
rid = str(it.get("room_id") or "").strip()
|
||||
name = str(it.get("name") or "").strip()
|
||||
if rid:
|
||||
out.append({"room_id": rid, "name": name})
|
||||
elif isinstance(it, str):
|
||||
s = str(it or "").strip()
|
||||
if s:
|
||||
out.append({"room_id": s, "name": ""})
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
def _request_matrix_load(self) -> None:
|
||||
"""Save current config and request a background load of joined rooms.
|
||||
|
||||
This replaces the old "Choose default rooms" popup and instead refreshes
|
||||
the inline default rooms list and caches the results to config.
|
||||
"""
|
||||
if self._matrix_test_running:
|
||||
return
|
||||
self._synchronize_inputs_to_config()
|
||||
|
||||
# Quick client-side pre-check for required fields
|
||||
try:
|
||||
matrix_block = self.config_data.get("provider", {}).get("matrix", {})
|
||||
hs = matrix_block.get("homeserver")
|
||||
token = matrix_block.get("access_token")
|
||||
if not hs or not token:
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update("Load skipped: please set both 'homeserver' and 'access_token' before loading rooms.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update("Saving configuration before loading rooms…")
|
||||
changed = count_changed_entries(self.config_data)
|
||||
try:
|
||||
entries = save_config(self.config_data)
|
||||
except Exception as exc:
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update(f"Saving configuration failed: {exc}")
|
||||
self._matrix_test_running = False
|
||||
return
|
||||
self.config_data = reload_config()
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update(f"Saved configuration ({changed} change(s)) to {db.db_path.name}. Loading Matrix rooms…")
|
||||
self._matrix_test_running = True
|
||||
self._matrix_load_background()
|
||||
|
||||
@work(thread=True)
|
||||
def _matrix_load_background(self) -> None:
|
||||
try:
|
||||
from Provider.matrix import Matrix
|
||||
|
||||
provider = Matrix(self.config_data)
|
||||
rooms = provider.list_rooms()
|
||||
self.app.call_from_thread(self._matrix_load_result, True, rooms, None)
|
||||
except Exception as exc:
|
||||
tb = traceback.format_exc()
|
||||
try:
|
||||
debug(f"[matrix] Load rooms failed: {exc}\n{tb}")
|
||||
except Exception:
|
||||
pass
|
||||
msg = str(exc) or "Matrix load failed"
|
||||
if "auth" in msg.lower():
|
||||
msg = msg + ". Please verify your access token and try again."
|
||||
self.app.call_from_thread(self._matrix_load_result, False, [], msg)
|
||||
|
||||
def _matrix_load_result(self, success: bool, rooms: List[Dict[str, Any]], error: Optional[str]) -> None:
|
||||
# Called on the main thread via call_from_thread
|
||||
self._matrix_test_running = False
|
||||
if success:
|
||||
msg = f"Matrix test succeeded ({len(rooms)} room(s))."
|
||||
if not success:
|
||||
full_msg = f"Matrix load failed: {error or '(error)'}"
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update(msg)
|
||||
self._open_matrix_room_picker(prefetched_rooms=rooms)
|
||||
else:
|
||||
self._matrix_status.update(full_msg)
|
||||
try:
|
||||
self.notify(full_msg, severity="error", timeout=8)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Populate inline list
|
||||
try:
|
||||
self._render_matrix_rooms_inline(rooms)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist cached rooms so they are available on next editor open
|
||||
try:
|
||||
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
|
||||
matrix_block["cached_rooms"] = rooms
|
||||
# Schedule a background save of the config (non-blocking)
|
||||
try:
|
||||
self.save_all()
|
||||
except Exception:
|
||||
# Fallback to direct save when save_all is unavailable (tests)
|
||||
try:
|
||||
save_config(self.config_data)
|
||||
except Exception:
|
||||
pass
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update(f"Matrix test failed: {error}")
|
||||
self._matrix_status.update(f"Loaded and cached {len(rooms)} room(s).")
|
||||
try:
|
||||
self.notify(f"Loaded {len(rooms)} rooms and cached the results", timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _open_matrix_room_picker(
|
||||
self,
|
||||
@@ -1083,6 +1460,138 @@ class ConfigModal(ModalScreen):
|
||||
callback=self.on_matrix_rooms_selected,
|
||||
)
|
||||
|
||||
def _render_matrix_rooms_inline(self, rooms: List[Dict[str, Any]]) -> None:
|
||||
"""
|
||||
Populate the inline matrix rooms ListView with checkboxes based on the
|
||||
list of rooms returned from a successful test. If the inline ListView
|
||||
is not present in the current editor view, fall back to opening the
|
||||
MatrixRoomPicker popup with the results.
|
||||
"""
|
||||
try:
|
||||
inline_list = self._matrix_inline_list or self.query_one("#matrix-rooms-inline", ListView)
|
||||
except Exception:
|
||||
inline_list = None
|
||||
|
||||
if inline_list is None:
|
||||
# Inline view isn't available in this context; cache the rooms and persist
|
||||
try:
|
||||
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
|
||||
matrix_block["cached_rooms"] = rooms
|
||||
try:
|
||||
self.save_all()
|
||||
except Exception:
|
||||
try:
|
||||
save_config(self.config_data)
|
||||
except Exception:
|
||||
pass
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update(f"Loaded {len(rooms)} rooms (cached)")
|
||||
try:
|
||||
self.notify(f"Loaded {len(rooms)} rooms and cached the results", timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Clear current entries
|
||||
for child in list(inline_list.children):
|
||||
child.remove()
|
||||
self._matrix_inline_checkbox_map.clear()
|
||||
self._matrix_inline_list = inline_list
|
||||
|
||||
# Determine existing selection from current config (so saved defaults are pre-selected)
|
||||
existing = set(self._parse_matrix_rooms_value())
|
||||
|
||||
if not rooms:
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update("Matrix returned no rooms.")
|
||||
try:
|
||||
save_btn = self.query_one("#matrix-inline-save", Button)
|
||||
save_btn.disabled = True
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
any_selected = False
|
||||
# Filter and normalize rooms to avoid malformed cache entries or split-word artifacts.
|
||||
normalized: List[Dict[str, str]] = []
|
||||
for r in rooms:
|
||||
try:
|
||||
rid = str(r.get("room_id") or "").strip()
|
||||
name = str(r.get("name") or "").strip()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Ignore obviously malformed tokens coming from bad caching/parsing
|
||||
le_rid = rid.lower()
|
||||
le_name = name.lower()
|
||||
if "room_id" in le_rid or "room_id" in le_name:
|
||||
continue
|
||||
|
||||
# Require a valid room id (Matrix ids usually start with '!' and often contain ':')
|
||||
if not rid or (not rid.startswith("!") and ":" not in rid):
|
||||
# Skip entries without a sensible ID (we rely on IDs for saving)
|
||||
continue
|
||||
|
||||
normalized.append({"room_id": rid, "name": name})
|
||||
|
||||
for idx, room in enumerate(normalized):
|
||||
room_id = room.get("room_id") or ""
|
||||
name = room.get("name") or ""
|
||||
checkbox_id = f"matrix-inline-room-{idx}"
|
||||
|
||||
label_text = name or room_id or "Matrix Room"
|
||||
|
||||
checked = bool(room_id and room_id in existing)
|
||||
if checked:
|
||||
any_selected = True
|
||||
|
||||
from textual.widgets import Checkbox as _Checkbox # local import to avoid top-level change
|
||||
checkbox = _Checkbox(label_text, id=checkbox_id, value=checked)
|
||||
self._matrix_inline_checkbox_map[checkbox_id] = room_id
|
||||
inline_list.mount(ListItem(checkbox, classes="matrix-room-row"))
|
||||
|
||||
if self._matrix_status:
|
||||
self._matrix_status.update("Loaded rooms. Select one or more and save.")
|
||||
try:
|
||||
save_btn = self.query_one("#matrix-inline-save", Button)
|
||||
save_btn.disabled = not any_selected
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _resolve_matrix_rooms_by_ids(self, ids: Iterable[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve room display names for a list of room IDs using the Matrix provider.
|
||||
Returns a list of dictionaries with keys 'room_id' and 'name' on success, or an
|
||||
empty list on failure.
|
||||
"""
|
||||
try:
|
||||
ids_list = [str(i).strip() for i in ids if str(i).strip()]
|
||||
except Exception:
|
||||
return []
|
||||
if not ids_list:
|
||||
return []
|
||||
|
||||
# Only attempt network resolution if homeserver + token are present
|
||||
block = self._get_matrix_provider_block()
|
||||
hs = block.get("homeserver")
|
||||
token = block.get("access_token")
|
||||
if not hs or not token:
|
||||
return []
|
||||
|
||||
try:
|
||||
from Provider.matrix import Matrix
|
||||
provider = Matrix(self.config_data)
|
||||
rooms = provider.list_rooms(room_ids=ids_list)
|
||||
return rooms or []
|
||||
except Exception as exc:
|
||||
try:
|
||||
debug(f"[config] failed to resolve matrix room names: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def on_matrix_rooms_selected(self, result: Any = None) -> None:
|
||||
if not isinstance(result, list):
|
||||
if self._matrix_status:
|
||||
@@ -1113,6 +1622,30 @@ class ConfigModal(ModalScreen):
|
||||
self.refresh_view()
|
||||
|
||||
@on(Input.Changed)
|
||||
@on(Checkbox.Changed)
|
||||
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
||||
# Only respond to inline matrix checkboxes
|
||||
try:
|
||||
cbid = event.checkbox.id
|
||||
except Exception:
|
||||
cbid = None
|
||||
if not cbid or cbid not in self._matrix_inline_checkbox_map:
|
||||
return
|
||||
|
||||
any_selected = False
|
||||
for checkbox_id in self._matrix_inline_checkbox_map.keys():
|
||||
try:
|
||||
cb = self.query_one(f"#{checkbox_id}", Checkbox)
|
||||
if cb.value:
|
||||
any_selected = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
self.query_one("#matrix-inline-save", Button).disabled = not any_selected
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
if event.input.id:
|
||||
self._update_config_value(event.input.id, event.value)
|
||||
|
||||
@@ -3,9 +3,9 @@ from __future__ import annotations
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
|
||||
from textual.containers import Container, Horizontal, ScrollableContainer
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static, Button, Checkbox
|
||||
from textual.widgets import Static, Button, Checkbox, ListView, ListItem
|
||||
from textual import work
|
||||
from rich.text import Text
|
||||
|
||||
@@ -40,13 +40,13 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#matrix-room-checklist {
|
||||
#matrix-room-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.matrix-room-row {
|
||||
border-bottom: solid $surface;
|
||||
padding: 0.5 0;
|
||||
padding: 1 0;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
@@ -91,15 +91,17 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
||||
id="matrix-room-picker-hint",
|
||||
)
|
||||
with ScrollableContainer(id="matrix-room-scroll"):
|
||||
yield Vertical(id="matrix-room-checklist")
|
||||
yield ListView(id="matrix-room-list")
|
||||
with Horizontal(id="matrix-room-actions"):
|
||||
yield Button("Cancel", variant="error", id="matrix-room-cancel")
|
||||
yield Button("Select All", id="matrix-room-select-all")
|
||||
yield Button("Clear All", id="matrix-room-clear")
|
||||
yield Button("Save defaults", variant="success", id="matrix-room-save")
|
||||
yield Static("Loading rooms...", id="matrix-room-status")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._status_widget = self.query_one("#matrix-room-status", Static)
|
||||
self._checklist = self.query_one("#matrix-room-checklist", Vertical)
|
||||
self._checklist = self.query_one("#matrix-room-list", ListView)
|
||||
self._save_button = self.query_one("#matrix-room-save", Button)
|
||||
if self._save_button:
|
||||
self._save_button.disabled = True
|
||||
@@ -110,10 +112,35 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
||||
self._set_status("Loading rooms...")
|
||||
self._load_rooms_background()
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Intercept ListView.Selected events and prevent them from bubbling up
|
||||
to parent components which may react (e.g., the main config modal).
|
||||
Selecting an item should not implicitly close the picker or change the
|
||||
outer editor state."""
|
||||
try:
|
||||
# Stop propagation so parent handlers (ConfigModal) don't react.
|
||||
event.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
if self._status_widget:
|
||||
self._status_widget.update(text)
|
||||
|
||||
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
||||
# Enable Save only when at least one checkbox is selected
|
||||
any_selected = False
|
||||
for checkbox_id in self._checkbox_map.keys():
|
||||
try:
|
||||
cb = self.query_one(f"#{checkbox_id}", Checkbox)
|
||||
if cb.value:
|
||||
any_selected = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if self._save_button:
|
||||
self._save_button.disabled = not any_selected
|
||||
|
||||
def _render_rooms(self, rooms: List[Dict[str, Any]]) -> None:
|
||||
if not self._checklist:
|
||||
return
|
||||
@@ -130,22 +157,20 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
||||
room_id = str(room.get("room_id") or "").strip()
|
||||
name = str(room.get("name") or "").strip()
|
||||
checkbox_id = f"matrix-room-{idx}"
|
||||
|
||||
# Prefer display name; otherwise fall back to room id
|
||||
label_text = name or room_id or "Matrix Room"
|
||||
|
||||
checkbox = Checkbox(
|
||||
"",
|
||||
label_text,
|
||||
id=checkbox_id,
|
||||
value=bool(room_id and room_id in self._existing_ids),
|
||||
)
|
||||
self._checkbox_map[checkbox_id] = room_id
|
||||
|
||||
label = Text(name or room_id or "Matrix Room")
|
||||
label.stylize("bold")
|
||||
label.append("\n")
|
||||
label.append(room_id or "(no id)", style="dim")
|
||||
list_item = ListItem(checkbox, classes="matrix-room-row")
|
||||
self._checklist.mount(list_item)
|
||||
|
||||
row = Horizontal(classes="matrix-room-row")
|
||||
self._checklist.mount(row)
|
||||
row.mount(checkbox)
|
||||
row.mount(Static(label, classes="matrix-room-meta"))
|
||||
self._set_status("Loaded rooms. Select one or more and save.")
|
||||
if self._save_button:
|
||||
self._save_button.disabled = False
|
||||
@@ -167,10 +192,40 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
|
||||
self._save_button.disabled = True
|
||||
return
|
||||
self._render_rooms(rooms)
|
||||
# Ensure save button is enabled only if at least one checkbox is selected
|
||||
any_selected = False
|
||||
for cbid in self._checkbox_map.keys():
|
||||
try:
|
||||
cb = self.query_one(f"#{cbid}", Checkbox)
|
||||
if cb.value:
|
||||
any_selected = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if self._save_button:
|
||||
self._save_button.disabled = not any_selected
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "matrix-room-cancel":
|
||||
self.dismiss([])
|
||||
elif event.button.id == "matrix-room-select-all":
|
||||
for checkbox_id in self._checkbox_map.keys():
|
||||
try:
|
||||
cb = self.query_one(f"#{checkbox_id}", Checkbox)
|
||||
cb.value = True
|
||||
except Exception:
|
||||
pass
|
||||
if self._save_button:
|
||||
self._save_button.disabled = False
|
||||
elif event.button.id == "matrix-room-clear":
|
||||
for checkbox_id in self._checkbox_map.keys():
|
||||
try:
|
||||
cb = self.query_one(f"#{checkbox_id}", Checkbox)
|
||||
cb.value = False
|
||||
except Exception:
|
||||
pass
|
||||
if self._save_button:
|
||||
self._save_button.disabled = True
|
||||
elif event.button.id == "matrix-room-save":
|
||||
selected: List[str] = []
|
||||
for checkbox_id, room_id in self._checkbox_map.items():
|
||||
|
||||
Reference in New Issue
Block a user