This commit is contained in:
2026-01-26 02:29:56 -08:00
parent 0c336ef1d1
commit 334841dcfa
5 changed files with 510 additions and 65 deletions

2
CLI.py
View File

@@ -1207,6 +1207,8 @@ class CmdletExecutor:
"get_url", "get_url",
"search-file", "search-file",
"search_file", "search_file",
"add-file",
"add_file",
} }
if cmd_name in self_managing_commands: if cmd_name in self_managing_commands:

View File

@@ -249,12 +249,6 @@ class Matrix(TableProviderMixin, Provider):
"default": "", "default": "",
"secret": True "secret": True
}, },
{
"key": "password",
"label": "Password (fallback)",
"default": "",
"secret": True
}
] ]
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
@@ -270,12 +264,10 @@ class Matrix(TableProviderMixin, Provider):
) )
homeserver = matrix_conf.get("homeserver") homeserver = matrix_conf.get("homeserver")
access_token = matrix_conf.get("access_token") access_token = matrix_conf.get("access_token")
password = matrix_conf.get("password")
# Not configured: keep instance but mark invalid via validate(). # Not configured: keep instance but mark invalid via validate().
# Note: `room_id` is intentionally NOT required, since the CLI can prompt # Note: `room_id` is intentionally NOT required, since the CLI can prompt
# the user to select a room dynamically. # the user to select a room dynamically.
if not (homeserver and (access_token or password)): if not (homeserver and access_token):
self._init_ok = None self._init_ok = None
self._init_reason = None self._init_reason = None
return return
@@ -305,7 +297,7 @@ class Matrix(TableProviderMixin, Provider):
{}) {})
return bool( return bool(
matrix_conf.get("homeserver") matrix_conf.get("homeserver")
and (matrix_conf.get("access_token") or matrix_conf.get("password")) and matrix_conf.get("access_token")
) )
def search( def search(

View File

@@ -1,14 +1,17 @@
from textual.app import ComposeResult import re
from textual.screen import ModalScreen from typing import Any, Dict, List, Optional
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select
from textual import on, work from textual import on, work
from typing import Any 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 SYS.config import load_config, save_config, reload_config, global_config from SYS.config import load_config, save_config, reload_config, global_config
from SYS.logger import log from SYS.logger import log
from Store.registry import _discover_store_classes, _required_keys_for from Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import list_providers from ProviderCore.registry import list_providers
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
from TUI.modalscreen.selection_modal import SelectionModal from TUI.modalscreen.selection_modal import SelectionModal
class ConfigModal(ModalScreen): class ConfigModal(ModalScreen):
@@ -117,6 +120,8 @@ class ConfigModal(ModalScreen):
self.editing_item_name = None self.editing_item_name = None
self._button_id_map = {} self._button_id_map = {}
self._input_id_map = {} self._input_id_map = {}
self._matrix_status: Optional[Static] = None
self._matrix_test_running = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(id="config-container"): with Container(id="config-container"):
@@ -491,13 +496,28 @@ class ConfigModal(ModalScreen):
inp_id = f"item-{idx}" inp_id = f"item-{idx}"
self._input_id_map[inp_id] = rk self._input_id_map[inp_id] = rk
row = Horizontal(classes="field-row") row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input")) row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn")) row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
container.mount(row)
idx += 1 idx += 1
except Exception: except Exception:
pass pass
if (
item_type == "provider"
and isinstance(item_name, str)
and item_name.strip().lower() == "matrix"
):
container.mount(Rule())
container.mount(Label("Matrix helpers", classes="config-label"))
status = Static("Set homeserver + token, then test before saving", id="matrix-status")
container.mount(status)
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"))
self._matrix_status = status
def create_field(self, name: str, value: Any, id: str) -> Vertical: def create_field(self, name: str, value: Any, id: str) -> Vertical:
# This method is now unused - we mount labels and inputs directly # This method is now unused - we mount labels and inputs directly
v = Vertical(classes="config-field") v = Vertical(classes="config-field")
@@ -594,6 +614,10 @@ class ConfigModal(ModalScreen):
except Exception: except Exception:
pass pass
self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected) self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected)
elif bid == "matrix-test-btn":
self._request_matrix_test()
elif bid == "matrix-rooms-btn":
self._open_matrix_room_picker()
elif bid.startswith("paste-"): elif bid.startswith("paste-"):
# Programmatic paste button # Programmatic paste button
target_id = bid.replace("paste-", "") target_id = bid.replace("paste-", "")
@@ -777,6 +801,94 @@ class ConfigModal(ModalScreen):
self._update_config_value(widget_id, value) self._update_config_value(widget_id, value)
def _get_matrix_provider_block(self) -> Dict[str, Any]:
providers = self.config_data.get("provider")
if not isinstance(providers, dict):
return {}
block = providers.get("matrix")
return block if isinstance(block, dict) else {}
def _parse_matrix_rooms_value(self) -> List[str]:
block = self._get_matrix_provider_block()
raw = block.get("rooms")
if isinstance(raw, (list, tuple, set)):
return [str(item).strip() for item in raw if str(item).strip()]
text = str(raw or "").strip()
if not text:
return []
return [segment for segment in re.split(r"[\s,]+", text) if segment]
def _request_matrix_test(self) -> None:
if self._matrix_test_running:
return
self._synchronize_inputs_to_config()
if self._matrix_status:
self._matrix_status.update("Testing Matrix connection…")
self._matrix_test_running = True
self._matrix_test_background()
@work(thread=True)
def _matrix_test_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_test_result, True, rooms, None)
except Exception as exc:
self.app.call_from_thread(self._matrix_test_result, False, [], str(exc))
def _matrix_test_result(
self,
success: bool,
rooms: List[Dict[str, Any]],
error: Optional[str],
) -> None:
self._matrix_test_running = False
if success:
msg = f"Matrix test succeeded ({len(rooms)} room(s))."
if self._matrix_status:
self._matrix_status.update(msg)
self._open_matrix_room_picker(prefetched_rooms=rooms)
else:
if self._matrix_status:
self._matrix_status.update(f"Matrix test failed: {error}")
def _open_matrix_room_picker(
self,
*,
prefetched_rooms: Optional[List[Dict[str, Any]]] = None,
) -> None:
existing = self._parse_matrix_rooms_value()
self.app.push_screen(
MatrixRoomPicker(
self.config_data,
existing=existing,
rooms=prefetched_rooms,
),
callback=self.on_matrix_rooms_selected,
)
def on_matrix_rooms_selected(self, result: Any = None) -> None:
if not isinstance(result, list):
if self._matrix_status:
self._matrix_status.update("Room selection cancelled.")
return
cleaned: List[str] = []
for item in result:
candidate = str(item or "").strip()
if candidate and candidate not in cleaned:
cleaned.append(candidate)
if not cleaned:
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(cleaned)
if self._matrix_status:
self._matrix_status.update(f"Saved {len(cleaned)} default room(s).")
self.refresh_view()
@on(Input.Changed) @on(Input.Changed)
def on_input_changed(self, event: Input.Changed) -> None: def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id: if event.input.id:

View File

@@ -0,0 +1,197 @@
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.screen import ModalScreen
from textual.widgets import Static, Button, Checkbox
from textual import work
class MatrixRoomPicker(ModalScreen[List[str]]):
"""Modal that lists Matrix rooms and returns selected defaults."""
CSS = """
MatrixRoomPicker {
align: center middle;
background: rgba(0, 0, 0, 0.45);
}
#matrix-room-picker {
width: 70%;
height: 70%;
background: $panel;
border: thick $primary;
padding: 1;
}
#matrix-room-picker-hint {
text-style: italic;
color: $text-muted;
margin-bottom: 1;
}
#matrix-room-scroll {
height: 1fr;
border: solid $surface;
padding: 1;
margin-bottom: 1;
}
#matrix-room-checklist {
padding: 0;
}
.matrix-room-row {
border-bottom: solid $surface;
padding: 1 0;
align: left middle;
background: $surface;
}
.matrix-room-checkbox {
width: 3;
padding: 0;
margin-right: 1;
}
.matrix-room-meta {
padding: 0 1;
}
.matrix-room-name {
content-align: left middle;
color: $text;
}
.matrix-room-id {
content-align: left middle;
text-style: dim;
color: $text-muted;
}
#matrix-room-actions {
height: 3;
align: right middle;
}
#matrix-room-status {
height: 3;
text-style: bold;
}
"""
def __init__(
self,
config: Dict[str, Any],
*,
existing: Optional[List[str]] = None,
rooms: Optional[List[Dict[str, Any]]] = None,
) -> None:
super().__init__()
self.config = config
self._prefetched_rooms = rooms
self._existing_ids = {str(r).strip() for r in (existing or []) if str(r).strip()}
self._checkbox_map: Dict[str, str] = {}
self._rooms: List[Dict[str, Any]] = []
self._status_widget: Optional[Static] = None
self._checklist: Optional[Vertical] = None
self._save_button: Optional[Button] = None
def compose(self) -> ComposeResult:
with Container(id="matrix-room-picker"):
yield Static("Matrix Default Rooms", classes="section-title")
yield Static(
"Choose rooms to keep in the sharing defaults and autocomplete.",
id="matrix-room-picker-hint",
)
with ScrollableContainer(id="matrix-room-scroll"):
yield Vertical(id="matrix-room-checklist")
with Horizontal(id="matrix-room-actions"):
yield Button("Cancel", variant="error", id="matrix-room-cancel")
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._save_button = self.query_one("#matrix-room-save", Button)
if self._save_button:
self._save_button.disabled = True
if self._prefetched_rooms is not None:
self._apply_room_results(self._prefetched_rooms, None)
else:
if self._status_widget:
self._set_status("Loading rooms...")
self._load_rooms_background()
def _set_status(self, text: str) -> None:
if self._status_widget:
self._status_widget.update(text)
def _render_rooms(self, rooms: List[Dict[str, Any]]) -> None:
if not self._checklist:
return
for child in list(self._checklist.children):
child.remove()
self._checkbox_map.clear()
self._rooms = rooms
if not rooms:
self._set_status("Matrix returned no rooms.")
if self._save_button:
self._save_button.disabled = True
return
for idx, room in enumerate(rooms):
room_id = str(room.get("room_id") or "").strip()
name = str(room.get("name") or "").strip()
checkbox_id = f"matrix-room-{idx}"
checkbox = Checkbox(
"",
id=checkbox_id,
classes="matrix-room-checkbox",
value=bool(room_id and room_id in self._existing_ids),
)
self._checkbox_map[checkbox_id] = room_id
info = Vertical(classes="matrix-room-meta")
info.mount(Static(name or room_id or "Matrix Room", classes="matrix-room-name"))
info.mount(Static(room_id or "(no id)", classes="matrix-room-id"))
row = Horizontal(classes="matrix-room-row")
self._checklist.mount(row)
row.mount(checkbox)
row.mount(info)
self._set_status("Loaded rooms. Select one or more and save.")
if self._save_button:
self._save_button.disabled = False
@work(thread=True)
def _load_rooms_background(self) -> None:
try:
from Provider.matrix import Matrix
provider = Matrix(self.config)
rooms = provider.list_rooms()
self.app.call_from_thread(self._apply_room_results, rooms, None)
except Exception as exc:
self.app.call_from_thread(self._apply_room_results, [], str(exc))
def _apply_room_results(self, rooms: List[Dict[str, Any]], error: Optional[str]) -> None:
if error:
self._set_status(f"Failed to load Matrix rooms: {error}")
if self._save_button:
self._save_button.disabled = True
return
self._render_rooms(rooms)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "matrix-room-cancel":
self.dismiss([])
elif event.button.id == "matrix-room-save":
selected: List[str] = []
for checkbox_id, room_id in self._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
self.dismiss(selected)

View File

@@ -9,6 +9,7 @@ import uuid
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from cmdlet._shared import Cmdlet, CmdletArg from cmdlet._shared import Cmdlet, CmdletArg
from SYS.config import load_config, save_config
from SYS.logger import log, debug from SYS.logger import log, debug
from SYS.result_table import Table from SYS.result_table import Table
from SYS import pipeline as ctx from SYS import pipeline as ctx
@@ -78,55 +79,43 @@ def _extract_set_value_arg(args: Sequence[str]) -> Optional[str]:
def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool: def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
"""Update a Matrix config value and write to config file. """Update the Matrix provider block in the shared config.
Returns True if successful, False otherwise. This method writes to the unified config store so changes persist between
sessions.
""" """
try: try:
from SYS.config import get_config_path
from configparser import ConfigParser
if not isinstance(config, dict): if not isinstance(config, dict):
return False return False
# Ensure provider.matrix section exists value_str = str(value)
providers = config.get("provider", {})
current_cfg = load_config() or {}
providers = current_cfg.setdefault("provider", {})
if not isinstance(providers, dict): if not isinstance(providers, dict):
providers = {} providers = {}
config["provider"] = providers current_cfg["provider"] = providers
matrix_conf = providers.get("matrix", {}) matrix_cfg = providers.setdefault("matrix", {})
if not isinstance(matrix_conf, dict): if not isinstance(matrix_cfg, dict):
matrix_conf = {} matrix_cfg = {}
providers["matrix"] = matrix_conf providers["matrix"] = matrix_cfg
# Update the in-memory config matrix_cfg[key] = value_str
matrix_conf[key] = value
save_config(current_cfg)
# Try to write to config file using configparser
try: # Keep the supplied config dict in sync for the running CLI
config_path = get_config_path() target_providers = config.setdefault("provider", {})
if not config_path: if not isinstance(target_providers, dict):
return False target_providers = {}
config["provider"] = target_providers
parser = ConfigParser() target_matrix = target_providers.setdefault("matrix", {})
if Path(config_path).exists(): if not isinstance(target_matrix, dict):
parser.read(config_path) target_matrix = {}
target_providers["matrix"] = target_matrix
section_name = "provider=matrix" target_matrix[key] = value_str
if not parser.has_section(section_name): return True
parser.add_section(section_name)
parser.set(section_name, key, str(value))
with open(config_path, "w") as f:
parser.write(f)
return True
except Exception as exc:
debug(f"[matrix] Failed to write config file: {exc}")
# Config was updated in memory at least
return True
except Exception as exc: except Exception as exc:
debug(f"[matrix] Failed to update Matrix config: {exc}") debug(f"[matrix] Failed to update Matrix config: {exc}")
return False return False
@@ -628,14 +617,14 @@ def _show_settings_table(config: Dict[str, Any]) -> int:
settings_items = [] settings_items = []
if isinstance(matrix_conf, dict): if isinstance(matrix_conf, dict):
sensitive_keys = {"access_token", "password"}
for key in sorted(matrix_conf.keys()): for key in sorted(matrix_conf.keys()):
value = matrix_conf[key] value = matrix_conf[key]
# Skip sensitive/complex values display_value = "***" if key in sensitive_keys else str(value)
if key in ("password",):
value = "***"
settings_items.append({ settings_items.append({
"key": key, "key": key,
"value": str(value), "label": key,
"value": display_value,
"original_value": value, "original_value": value,
}) })
@@ -643,10 +632,19 @@ def _show_settings_table(config: Dict[str, Any]) -> int:
log("No Matrix settings configured. Edit config.conf manually.", file=sys.stderr) log("No Matrix settings configured. Edit config.conf manually.", file=sys.stderr)
return 0 return 0
settings_items.append({
"action": "test",
"label": "Test connection",
"value": "Verify the homeserver and token before picking rooms",
"description": "Runs a health check and then prompts for default rooms",
})
for item in settings_items: for item in settings_items:
row = table.add_row() row = table.add_row()
row.add_column("Key", item["key"]) label = item.get("label") or item.get("key") or "Setting"
row.add_column("Value", item["value"]) value_text = item.get("value") or item.get("description") or ""
row.add_column("Key", label)
row.add_column("Value", value_text)
ctx.set_last_result_table_overlay(table, settings_items) ctx.set_last_result_table_overlay(table, settings_items)
ctx.set_current_stage_table(table) ctx.set_current_stage_table(table)
@@ -712,6 +710,15 @@ def _handle_settings_edit(result: Any, args: Sequence[str], config: Dict[str, An
return 1 return 1
selected_item = last_items[idx] selected_item = last_items[idx]
selected_action = None
if isinstance(selected_item, dict):
selected_action = selected_item.get("action")
else:
selected_action = getattr(selected_item, "action", None)
if selected_action == "test":
return _handle_settings_test(config)
key = None key = None
if isinstance(selected_item, dict): if isinstance(selected_item, dict):
key = selected_item.get("key") key = selected_item.get("key")
@@ -742,6 +749,132 @@ def _handle_settings_edit(result: Any, args: Sequence[str], config: Dict[str, An
return 1 return 1
def _handle_settings_test(config: Dict[str, Any]) -> int:
"""Test Matrix credentials and prompt for default rooms upon success."""
from Provider.matrix import Matrix
try:
provider = Matrix(config)
except Exception as exc:
log(f"Matrix test failed: {exc}", file=sys.stderr)
return 1
log("Matrix configuration validated. Select default rooms to share.")
return _show_default_room_picker(config, provider=provider)
def _show_default_room_picker(config: Dict[str, Any], *, provider: Optional["Matrix"] = None) -> int:
"""Display joined rooms so the user can select defaults for sharing."""
from Provider.matrix import Matrix
try:
if provider is None:
provider = Matrix(config)
except Exception as exc:
log(f"Matrix not available: {exc}", file=sys.stderr)
return 1
try:
rooms = provider.list_rooms()
except Exception as exc:
log(f"Failed to list Matrix rooms: {exc}", file=sys.stderr)
return 1
if not rooms:
log("No joined rooms found.", file=sys.stderr)
return 0
default_ids = {
str(v).strip()
for v in _parse_config_room_filter_ids(config)
if str(v).strip()
}
table = Table("Matrix Rooms (select defaults with @N)")
table.set_table("matrix")
table.set_source_command(".matrix", ["-settings"])
room_items: List[Dict[str, Any]] = []
for room in rooms:
if isinstance(room, dict):
room_id = str(room.get("room_id") or "").strip()
name = str(room.get("name") or "").strip()
else:
room_id = ""
name = ""
row = table.add_row()
row.add_column("Name", name)
row.add_column("Room", room_id)
row.add_column("Default", "" if room_id and room_id in default_ids else "")
room_items.append({
**(room if isinstance(room, dict) else {}),
"room_id": room_id,
"name": name,
"store": "matrix",
"title": name or room_id or "Matrix Room",
})
ctx.set_last_result_table_overlay(table, room_items)
ctx.set_current_stage_table(table)
ctx.set_pending_pipeline_tail([[".matrix", "-settings-rooms"]], ".matrix")
log("Select default rooms to share (used by @N and autocomplete).")
return 0
def _handle_settings_rooms(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Store the selected rooms list as the default sharing target."""
selection_indices = []
try:
selection_indices = ctx.get_last_selection() or []
except Exception:
pass
last_items = []
try:
last_items = ctx.get_last_result_items() or []
except Exception:
pass
if not selection_indices or not last_items:
log("No Matrix room selected. Use @N on the rooms table.", file=sys.stderr)
return 1
room_ids: List[str] = []
for idx in selection_indices:
if not isinstance(idx, int):
continue
if idx < 0 or idx >= len(last_items):
continue
item = last_items[idx]
candidate = None
if isinstance(item, dict):
candidate = item.get("room_id") or item.get("id")
else:
candidate = getattr(item, "room_id", None) or getattr(item, "id", None)
if candidate:
room_ids.append(str(candidate).strip())
cleaned: List[str] = []
for rid in room_ids:
clean = str(rid or "").strip()
if clean and clean not in cleaned:
cleaned.append(clean)
if not cleaned:
log("No valid Matrix room selected.", file=sys.stderr)
return 1
value = ", ".join(cleaned)
if not _update_matrix_config(config, "rooms", value):
log("✗ Failed to save default rooms", file=sys.stderr)
return 1
log(f"✓ Default rooms saved: {value}")
return _show_settings_table(config)
def _show_rooms_table(config: Dict[str, Any]) -> int: def _show_rooms_table(config: Dict[str, Any]) -> int:
"""Display rooms (refactored original behavior).""" """Display rooms (refactored original behavior)."""
from Provider.matrix import Matrix from Provider.matrix import Matrix
@@ -855,6 +988,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if _has_flag(args, "-settings-edit"): if _has_flag(args, "-settings-edit"):
return _handle_settings_edit(result, args, config) return _handle_settings_edit(result, args, config)
if _has_flag(args, "-settings-rooms"):
return _handle_settings_rooms(result, args, config)
# Internal stage: send previously selected items to selected rooms. # Internal stage: send previously selected items to selected rooms.
if _has_flag(args, "-send"): if _has_flag(args, "-send"):
# Ensure we don't re-print the rooms picker table on the send stage. # Ensure we don't re-print the rooms picker table on the send stage.
@@ -1031,6 +1167,12 @@ CMDLET = Cmdlet(
description="(internal) Handle settings modification", description="(internal) Handle settings modification",
required=False, required=False,
), ),
CmdletArg(
name="settings-rooms",
type="bool",
description="(internal) Save selected default rooms",
required=False,
),
CmdletArg( CmdletArg(
name="set-value", name="set-value",
type="string", type="string",