pycascadeui 3.3.2__tar.gz → 3.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycascadeui-3.3.2/pycascadeui.egg-info → pycascadeui-3.3.3}/PKG-INFO +1 -1
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/__init__.py +27 -1
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/devtools.py +143 -42
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/manager.py +12 -25
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/actions.py +13 -3
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/reducers.py +2 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/store.py +52 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/types.py +1 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/_navigation.py +79 -24
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/base.py +45 -25
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/layout.py +11 -4
- {pycascadeui-3.3.2 → pycascadeui-3.3.3/pycascadeui.egg-info}/PKG-INFO +1 -1
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/pyproject.toml +1 -1
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_devtools.py +140 -3
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_layout_view.py +16 -16
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_navigation.py +164 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_rebuild_callback.py +19 -18
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_reducers.py +17 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_state_store.py +123 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_undo.py +12 -4
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/LICENSE +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/README.md +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/__main__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/base.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/buttons.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/inputs.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/patterns/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/patterns/v1.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/patterns/v2.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/selects.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/types.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/v1_composition.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/components/wrappers.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/exceptions.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/backends/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/backends/memory.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/backends/postgres.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/backends/sqlite.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/config.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/migrations.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/protocols.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/schema.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/persistence/schema_postgres.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/py.typed +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/setup.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/computed.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/middleware/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/middleware/logging.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/middleware/persistence.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/middleware/undo.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/singleton.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/state/slots.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/theming/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/theming/context.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/theming/core.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/theming/themes.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/tracing.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/utils/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/utils/coercion.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/utils/decorators.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/utils/errors.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/utils/fetch.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/utils/logging.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/utils/strings.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/utils/tasks.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/validation.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/_interaction.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/_placement.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/__init__.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/form.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/leaderboard.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/menu.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/paginated.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/roles.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/tabs.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/types.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/patterns/wizard.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/persistent.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/cascadeui/views/view.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/pycascadeui.egg-info/SOURCES.txt +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/pycascadeui.egg-info/dependency_links.txt +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/pycascadeui.egg-info/requires.txt +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/pycascadeui.egg-info/top_level.txt +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/setup.cfg +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_auto_defer.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_backends.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_backends_postgres.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_backends_raw_sql.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_batching.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_button_grid.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_components.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_computed.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_dynamic_persistent_button.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_emoji_grid.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_ephemeral_refresh.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_fetch.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_five_pillars.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_form_patterns.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_hooks.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_input_coercion.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_inputs.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_instance_limit.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_layout_pagination.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_layout_patterns.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_layout_persistent.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_leaderboard_patterns.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_menu_patterns.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_message_cleanup.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_middleware.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_nav_version.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_navigation_artifact_restore.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_owner_only.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_paginated_patterns.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_pagination.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_persistence.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_persistence_middleware.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_persistent_views.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_pillar_isolation.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_placement.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_roles_patterns.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_scoping.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_selects.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_state_slots.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_tab_patterns.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_theming.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_update_coalescing.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_v2_helpers.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_validation.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_view_init.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_wizard_patterns.py +0 -0
- {pycascadeui-3.3.2 → pycascadeui-3.3.3}/tests/test_wrappers.py +0 -0
|
@@ -12,6 +12,14 @@ import logging as _logging
|
|
|
12
12
|
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
|
|
13
13
|
|
|
14
14
|
from .components.base import DynamicPersistentButton, StatefulButton, StatefulSelect
|
|
15
|
+
from .components.buttons import (
|
|
16
|
+
DangerButton,
|
|
17
|
+
LinkButton,
|
|
18
|
+
PrimaryButton,
|
|
19
|
+
SecondaryButton,
|
|
20
|
+
SuccessButton,
|
|
21
|
+
ToggleButton,
|
|
22
|
+
)
|
|
15
23
|
from .components.inputs import Checkbox, CheckboxGroup, FileUpload, Modal, RadioGroup, TextInput
|
|
16
24
|
from .components.patterns import (
|
|
17
25
|
ConfirmationButtons,
|
|
@@ -40,6 +48,13 @@ from .components.patterns import (
|
|
|
40
48
|
toggle_button,
|
|
41
49
|
toggle_section,
|
|
42
50
|
)
|
|
51
|
+
from .components.selects import (
|
|
52
|
+
ChannelSelect,
|
|
53
|
+
Dropdown,
|
|
54
|
+
MentionableSelect,
|
|
55
|
+
RoleSelect,
|
|
56
|
+
UserSelect,
|
|
57
|
+
)
|
|
43
58
|
from .components.types import EmojiInput, MediaInput
|
|
44
59
|
from .components.v1_composition import CompositeComponent, get_component, register_component
|
|
45
60
|
from .components.wrappers import with_confirmation, with_cooldown, with_loading_state
|
|
@@ -132,7 +147,7 @@ if "SQLiteBackend" in _persistence_all:
|
|
|
132
147
|
# // ========================================( Script )======================================== // #
|
|
133
148
|
|
|
134
149
|
|
|
135
|
-
__version__ = "3.3.
|
|
150
|
+
__version__ = "3.3.3"
|
|
136
151
|
|
|
137
152
|
# Export public API
|
|
138
153
|
__all__ = [
|
|
@@ -179,6 +194,17 @@ __all__ = [
|
|
|
179
194
|
"StatefulButton",
|
|
180
195
|
"StatefulSelect",
|
|
181
196
|
"DynamicPersistentButton",
|
|
197
|
+
"PrimaryButton",
|
|
198
|
+
"SecondaryButton",
|
|
199
|
+
"SuccessButton",
|
|
200
|
+
"DangerButton",
|
|
201
|
+
"LinkButton",
|
|
202
|
+
"ToggleButton",
|
|
203
|
+
"Dropdown",
|
|
204
|
+
"RoleSelect",
|
|
205
|
+
"ChannelSelect",
|
|
206
|
+
"UserSelect",
|
|
207
|
+
"MentionableSelect",
|
|
182
208
|
"CompositeComponent",
|
|
183
209
|
"ConfirmationButtons",
|
|
184
210
|
"PaginationControls",
|
|
@@ -14,7 +14,6 @@ from discord.ui import ActionRow, TextDisplay
|
|
|
14
14
|
from .components.base import StatefulButton, StatefulSelect
|
|
15
15
|
from .components.patterns import action_section, alert, card, divider, gap, key_value
|
|
16
16
|
from .exceptions import InstanceLimitError
|
|
17
|
-
from .state.actions import ActionCreators
|
|
18
17
|
from .state.computed import _SENTINEL as _SENTINEL_COMPUTED
|
|
19
18
|
from .state.computed import ComputedValue, computed
|
|
20
19
|
from .state.singleton import get_store
|
|
@@ -43,15 +42,15 @@ _LIST_DISPLAY_CAP = 15
|
|
|
43
42
|
async def _cleanup_ghost_view(store, view_id: str) -> None:
|
|
44
43
|
"""Drop a ghost view (state row with no live instance) from the store.
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
row outlives the object and only
|
|
45
|
+
Removes the subscriber slot, then tears the view down through the atomic
|
|
46
|
+
``_destroy_view`` seam: VIEW_DESTROYED trims ``state["views"][view_id]``
|
|
47
|
+
and session membership, and the active-registry entry is cleared only
|
|
48
|
+
after state confirms the removal. Used when an instance dies without
|
|
49
|
+
routing through ``exit()`` -- the state row outlives the object and only
|
|
50
|
+
the inspector can clean it.
|
|
51
51
|
"""
|
|
52
|
-
store._unregister_view(view_id)
|
|
53
52
|
store._unsubscribe(view_id)
|
|
54
|
-
await store.
|
|
53
|
+
await store._destroy_view(view_id)
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
# // ========================================( Computed Aggregations )======================================== // #
|
|
@@ -152,15 +151,33 @@ class InspectorView(TabLayoutView):
|
|
|
152
151
|
notification and the Views tab's Channel / Msg columns stay null
|
|
153
152
|
until the user manually refreshes.
|
|
154
153
|
"""
|
|
154
|
+
# Filter to the same rows the Views / Sessions tabs display (exclude
|
|
155
|
+
# every inspector, honor the guild scope) so a guild-scoped inspector
|
|
156
|
+
# does not rebuild on out-of-scope cross-guild activity.
|
|
155
157
|
views = frozenset(
|
|
156
158
|
(k, v.get("message_id"), v.get("channel_id"))
|
|
157
159
|
for k, v in state.get("views", {}).items()
|
|
158
160
|
if k != self.id
|
|
161
|
+
and v.get("type") != "InspectorView"
|
|
162
|
+
and self._in_scope(v.get("guild_id"))
|
|
163
|
+
)
|
|
164
|
+
view_rows = state.get("views", {})
|
|
165
|
+
inspector_sessions = {
|
|
166
|
+
v.get("session_id") for v in view_rows.values() if v.get("type") == "InspectorView"
|
|
167
|
+
}
|
|
168
|
+
inspector_sessions.add(self.session_id)
|
|
169
|
+
sessions = frozenset(
|
|
170
|
+
k
|
|
171
|
+
for k, v in state.get("sessions", {}).items()
|
|
172
|
+
if k not in inspector_sessions and self._in_scope(v.get("guild_id"))
|
|
159
173
|
)
|
|
160
|
-
sessions = frozenset(k for k in state.get("sessions", {}) if k != self.session_id)
|
|
161
174
|
return (views, sessions)
|
|
162
175
|
|
|
163
|
-
def __init__(self, *args, **kwargs):
|
|
176
|
+
def __init__(self, *args, scope_guild_id=None, **kwargs):
|
|
177
|
+
# scope_guild_id: an int restricts the Views / Sessions tabs to that
|
|
178
|
+
# guild; None shows every guild. The /cascadeui inspect command
|
|
179
|
+
# defaults this to the executing guild.
|
|
180
|
+
self._scope_guild_id = scope_guild_id
|
|
164
181
|
tabs = {
|
|
165
182
|
"\U0001f4ca Overview": self.build_overview,
|
|
166
183
|
"\U0001f441\ufe0f Views": self.build_views,
|
|
@@ -173,15 +190,46 @@ class InspectorView(TabLayoutView):
|
|
|
173
190
|
|
|
174
191
|
# // ==================( Filtering )================== // #
|
|
175
192
|
|
|
193
|
+
def _in_scope(self, guild_id) -> bool:
|
|
194
|
+
"""Whether a row's guild belongs to the inspector's scope.
|
|
195
|
+
|
|
196
|
+
A ``None`` scope is global -- every guild passes. A guildless row
|
|
197
|
+
(DM-originated, ``guild_id`` is ``None``) only passes the global scope.
|
|
198
|
+
"""
|
|
199
|
+
return self._scope_guild_id is None or guild_id == self._scope_guild_id
|
|
200
|
+
|
|
176
201
|
def _filtered_views(self):
|
|
177
|
-
"""
|
|
202
|
+
"""State views, excluding every inspector and rows outside the scope.
|
|
203
|
+
|
|
204
|
+
Filtering by ``type`` rather than ``self.id`` drops other inspectors
|
|
205
|
+
open in other guilds too, not just this one -- they are observer-effect
|
|
206
|
+
noise wherever they appear.
|
|
207
|
+
"""
|
|
178
208
|
views = self.state_store.state.get("views", {})
|
|
179
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
k: v
|
|
211
|
+
for k, v in views.items()
|
|
212
|
+
if k != self.id
|
|
213
|
+
and v.get("type") != "InspectorView"
|
|
214
|
+
and self._in_scope(v.get("guild_id"))
|
|
215
|
+
}
|
|
180
216
|
|
|
181
217
|
def _filtered_sessions(self):
|
|
182
|
-
"""
|
|
218
|
+
"""State sessions, excluding inspector sessions and out-of-scope guilds."""
|
|
219
|
+
views = self.state_store.state.get("views", {})
|
|
220
|
+
# Drop this inspector's own session plus any session whose view is
|
|
221
|
+
# another inspector. self.session_id covers the common case where the
|
|
222
|
+
# inspector's own view row is not present in the probed state.
|
|
223
|
+
inspector_sessions = {
|
|
224
|
+
v.get("session_id") for v in views.values() if v.get("type") == "InspectorView"
|
|
225
|
+
}
|
|
226
|
+
inspector_sessions.add(self.session_id)
|
|
183
227
|
sessions = self.state_store.state.get("sessions", {})
|
|
184
|
-
return {
|
|
228
|
+
return {
|
|
229
|
+
k: v
|
|
230
|
+
for k, v in sessions.items()
|
|
231
|
+
if k not in inspector_sessions and self._in_scope(v.get("guild_id"))
|
|
232
|
+
}
|
|
185
233
|
|
|
186
234
|
def _filtered_history(self):
|
|
187
235
|
"""Return action history excluding the inspector's own dispatches."""
|
|
@@ -242,30 +290,36 @@ class InspectorView(TabLayoutView):
|
|
|
242
290
|
components = self._filtered_components()
|
|
243
291
|
modals = self._filtered_modals()
|
|
244
292
|
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# unconditionally would under-count every other view by one.
|
|
251
|
-
# Probing state membership picks the right subtraction every render.
|
|
252
|
-
state_views = store.state.get("views", {})
|
|
253
|
-
state_sessions = store.state.get("sessions", {})
|
|
254
|
-
self_view_present = self.id in state_views
|
|
255
|
-
self_session_present = self.session_id in state_sessions
|
|
256
|
-
view_count = max(0, store.computed["total_views"] - (1 if self_view_present else 0))
|
|
257
|
-
session_count = max(
|
|
258
|
-
0,
|
|
259
|
-
store.computed["total_sessions"] - (1 if self_session_present else 0),
|
|
260
|
-
)
|
|
293
|
+
# Scoped counts come from the filtered helpers so the Overview agrees
|
|
294
|
+
# with the Views / Sessions tabs -- both exclude inspectors and honor
|
|
295
|
+
# the guild scope.
|
|
296
|
+
view_count = len(self._filtered_views())
|
|
297
|
+
session_count = len(self._filtered_sessions())
|
|
261
298
|
|
|
299
|
+
scope_label = (
|
|
300
|
+
f"Guild `{self._scope_guild_id}`"
|
|
301
|
+
if self._scope_guild_id is not None
|
|
302
|
+
else "Global (all guilds)"
|
|
303
|
+
)
|
|
262
304
|
stats = {
|
|
305
|
+
"Scope": scope_label,
|
|
263
306
|
"Views": view_count,
|
|
264
307
|
"Sessions": session_count,
|
|
265
308
|
"Components": len(components),
|
|
266
309
|
}
|
|
267
310
|
if modals:
|
|
268
311
|
stats["Modals"] = len(modals)
|
|
312
|
+
if self._scope_guild_id is not None:
|
|
313
|
+
# All-guilds totals from the @computed aggregates. TabLayoutView.send()
|
|
314
|
+
# builds this tab before VIEW_CREATED / SESSION_CREATED dispatch, so
|
|
315
|
+
# the inspector's own ids may not be in state yet -- probe membership
|
|
316
|
+
# to subtract its own contribution only once registered.
|
|
317
|
+
views_state = store.state.get("views", {})
|
|
318
|
+
sessions_state = store.state.get("sessions", {})
|
|
319
|
+
self_view = 1 if self.id in views_state else 0
|
|
320
|
+
self_session = 1 if self.session_id in sessions_state else 0
|
|
321
|
+
stats["Views (all guilds)"] = max(0, store.computed["total_views"] - self_view)
|
|
322
|
+
stats["Sessions (all guilds)"] = max(0, store.computed["total_sessions"] - self_session)
|
|
269
323
|
|
|
270
324
|
overview = card(
|
|
271
325
|
"## State Inspector",
|
|
@@ -1258,11 +1312,25 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
|
|
|
1258
1312
|
|
|
1259
1313
|
# // ==================( View Commands )================== // #
|
|
1260
1314
|
|
|
1315
|
+
@staticmethod
|
|
1316
|
+
def _scope_guild(ctx: Context):
|
|
1317
|
+
"""Guild id the text commands scope to, or None in a DM (all guilds).
|
|
1318
|
+
|
|
1319
|
+
The listing and bulk-exit commands filter to this guild; None passes
|
|
1320
|
+
every row.
|
|
1321
|
+
"""
|
|
1322
|
+
return ctx.guild.id if ctx.guild else None
|
|
1323
|
+
|
|
1261
1324
|
@cascadeui_group.command(name="views", description="List active CascadeUI views.")
|
|
1262
1325
|
async def list_views(self, ctx: Context) -> None:
|
|
1263
|
-
"""List
|
|
1326
|
+
"""List active views in the executing guild."""
|
|
1264
1327
|
store = get_store()
|
|
1265
|
-
|
|
1328
|
+
gid = self._scope_guild(ctx)
|
|
1329
|
+
views = {
|
|
1330
|
+
k: v
|
|
1331
|
+
for k, v in store.state.get("views", {}).items()
|
|
1332
|
+
if gid is None or v.get("guild_id") == gid
|
|
1333
|
+
}
|
|
1266
1334
|
active = store.get_active_views()
|
|
1267
1335
|
|
|
1268
1336
|
if not views:
|
|
@@ -1278,7 +1346,8 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
|
|
|
1278
1346
|
if len(views) > _LIST_DISPLAY_CAP:
|
|
1279
1347
|
lines.append(f"*...and {len(views) - _LIST_DISPLAY_CAP} more*")
|
|
1280
1348
|
|
|
1281
|
-
|
|
1349
|
+
live = sum(1 for vid in views if vid in active)
|
|
1350
|
+
header = f"**{len(views)} view(s)** ({live} live)\n"
|
|
1282
1351
|
await ctx.send(header + "\n".join(lines), ephemeral=True)
|
|
1283
1352
|
|
|
1284
1353
|
@cascadeui_group.command(name="exit", description="Exit a specific CascadeUI view by ID.")
|
|
@@ -1319,14 +1388,17 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
|
|
|
1319
1388
|
|
|
1320
1389
|
@cascadeui_group.command(name="exitall", description="Exit all active CascadeUI views.")
|
|
1321
1390
|
async def exit_all(self, ctx: Context) -> None:
|
|
1322
|
-
"""Exit
|
|
1391
|
+
"""Exit active views in the executing guild and clean their ghosts."""
|
|
1323
1392
|
store = get_store()
|
|
1393
|
+
gid = self._scope_guild(ctx)
|
|
1324
1394
|
views = dict(store.state.get("views", {}))
|
|
1325
1395
|
active = dict(store.get_active_views())
|
|
1326
1396
|
exited = 0
|
|
1327
1397
|
failed = 0
|
|
1328
1398
|
|
|
1329
1399
|
for view_id, view in list(active.items()):
|
|
1400
|
+
if gid is not None and getattr(view, "guild_id", None) != gid:
|
|
1401
|
+
continue
|
|
1330
1402
|
try:
|
|
1331
1403
|
await view.exit()
|
|
1332
1404
|
exited += 1
|
|
@@ -1334,10 +1406,13 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
|
|
|
1334
1406
|
logger.debug("exit_all: view %s failed to exit cleanly: %s", view_id, exc)
|
|
1335
1407
|
failed += 1
|
|
1336
1408
|
|
|
1337
|
-
for view_id in list(views.
|
|
1338
|
-
if view_id
|
|
1339
|
-
|
|
1340
|
-
|
|
1409
|
+
for view_id, view_data in list(views.items()):
|
|
1410
|
+
if view_id in active:
|
|
1411
|
+
continue
|
|
1412
|
+
if gid is not None and view_data.get("guild_id") != gid:
|
|
1413
|
+
continue
|
|
1414
|
+
await _cleanup_ghost_view(store, view_id)
|
|
1415
|
+
exited += 1
|
|
1341
1416
|
|
|
1342
1417
|
suffix = f" ({failed} failed)" if failed else ""
|
|
1343
1418
|
await ctx.send(f"Exited {exited} view(s){suffix}.", ephemeral=True)
|
|
@@ -1447,9 +1522,14 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
|
|
|
1447
1522
|
|
|
1448
1523
|
@cascadeui_group.command(name="sessions", description="List active CascadeUI sessions.")
|
|
1449
1524
|
async def list_sessions(self, ctx: Context) -> None:
|
|
1450
|
-
"""List
|
|
1525
|
+
"""List sessions in the executing guild."""
|
|
1451
1526
|
store = get_store()
|
|
1452
|
-
|
|
1527
|
+
gid = self._scope_guild(ctx)
|
|
1528
|
+
sessions = {
|
|
1529
|
+
k: v
|
|
1530
|
+
for k, v in store.state.get("sessions", {}).items()
|
|
1531
|
+
if gid is None or v.get("guild_id") == gid
|
|
1532
|
+
}
|
|
1453
1533
|
|
|
1454
1534
|
if not sessions:
|
|
1455
1535
|
return await ctx.send("No active sessions.", ephemeral=True)
|
|
@@ -1499,19 +1579,40 @@ class DevToolsCog(commands.Cog, name="cascadeui_devtools"):
|
|
|
1499
1579
|
# // ==================( Inspector Command )================== // #
|
|
1500
1580
|
|
|
1501
1581
|
@cascadeui_group.command(name="inspect", description="Open the CascadeUI state inspector.")
|
|
1502
|
-
async def inspect(self, ctx: Context) -> None:
|
|
1582
|
+
async def inspect(self, ctx: Context, scope: str = None) -> None:
|
|
1503
1583
|
"""Open the visual state inspector.
|
|
1504
1584
|
|
|
1505
1585
|
A tabbed V2 dashboard showing the live state tree, active views,
|
|
1506
1586
|
sessions, action history, and store configuration with interactive
|
|
1507
1587
|
controls for managing views, sessions, and state.
|
|
1588
|
+
|
|
1589
|
+
Scope defaults to the current guild. Pass ``global`` (or ``all``) to
|
|
1590
|
+
inspect every guild, or a guild id to target a specific guild.
|
|
1508
1591
|
"""
|
|
1592
|
+
scope_guild_id = self._resolve_inspect_scope(ctx, scope)
|
|
1509
1593
|
try:
|
|
1510
|
-
view = InspectorView(context=ctx)
|
|
1594
|
+
view = InspectorView(context=ctx, scope_guild_id=scope_guild_id)
|
|
1511
1595
|
await view.send()
|
|
1512
1596
|
except InstanceLimitError:
|
|
1513
1597
|
await ctx.send("Inspector already open.", ephemeral=True)
|
|
1514
1598
|
|
|
1599
|
+
@staticmethod
|
|
1600
|
+
def _resolve_inspect_scope(ctx: Context, scope: str = None):
|
|
1601
|
+
"""Map the optional scope argument to a guild id, or None for global.
|
|
1602
|
+
|
|
1603
|
+
No argument defaults to the executing guild (None in a DM). ``global``
|
|
1604
|
+
or ``all`` widen to every guild. A numeric string targets that guild
|
|
1605
|
+
id. Anything unrecognized falls back to the executing guild.
|
|
1606
|
+
"""
|
|
1607
|
+
if scope is None:
|
|
1608
|
+
return ctx.guild.id if ctx.guild else None
|
|
1609
|
+
normalized = scope.strip().lower()
|
|
1610
|
+
if normalized in ("global", "all"):
|
|
1611
|
+
return None
|
|
1612
|
+
if normalized.isdigit():
|
|
1613
|
+
return int(normalized)
|
|
1614
|
+
return ctx.guild.id if ctx.guild else None
|
|
1615
|
+
|
|
1515
1616
|
# // ==================( Registry Commands )================== // #
|
|
1516
1617
|
|
|
1517
1618
|
@cascadeui_group.command(
|
|
@@ -596,33 +596,20 @@ class PersistenceManager:
|
|
|
596
596
|
# subscriber + registry entry + undo-tracking row, all
|
|
597
597
|
# of which must go back before the next row is tried.
|
|
598
598
|
self._store._unsubscribe(view.id)
|
|
599
|
-
self._store._unregister_view(view.id)
|
|
600
599
|
self._store._undo_enabled_views.pop(view.id, None)
|
|
601
|
-
# If state registration succeeded before the failure,
|
|
602
|
-
# the SESSION_CREATED + VIEW_CREATED actions already
|
|
603
|
-
# landed in store state. Dispatch VIEW_DESTROYED to
|
|
604
|
-
# tear those down -- otherwise the session and view
|
|
605
|
-
# entries would persist as zombies for the rest of
|
|
606
|
-
# the process lifetime. ``reduce_view_destroyed``
|
|
607
|
-
# cleans up the session entry too when its members
|
|
608
|
-
# list empties.
|
|
609
600
|
if state_registered:
|
|
610
|
-
#
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
logger.error(
|
|
623
|
-
f"Rollback dispatch failed for {persistence_key!r}: " f"{cleanup_exc}",
|
|
624
|
-
exc_info=True,
|
|
625
|
-
)
|
|
601
|
+
# State registration landed SESSION_CREATED + VIEW_CREATED,
|
|
602
|
+
# so tear them down through the atomic seam: _destroy_view
|
|
603
|
+
# dispatches VIEW_DESTROYED, then clears the active-registry
|
|
604
|
+
# entry only after state confirms the removal. A failed
|
|
605
|
+
# dispatch leaves both registries intact instead of
|
|
606
|
+
# stranding a ghost; _destroy_view catches and logs the
|
|
607
|
+
# dispatch failure internally. ``reduce_view_destroyed``
|
|
608
|
+
# cleans up the session entry too when its members empty.
|
|
609
|
+
await self._store._destroy_view(view.id)
|
|
610
|
+
else:
|
|
611
|
+
# State never registered; just drop the active entry.
|
|
612
|
+
self._store._unregister_view(view.id)
|
|
626
613
|
return "failed"
|
|
627
614
|
|
|
628
615
|
# // ========================================( Middleware install )======================================== // #
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from typing import Any, Dict, List, Optional
|
|
5
5
|
|
|
6
|
-
from .types import ComponentId, SessionId, UserId, ViewId
|
|
6
|
+
from .types import ComponentId, GuildId, SessionId, UserId, ViewId
|
|
7
7
|
|
|
8
8
|
# Type alias for action payloads
|
|
9
9
|
ActionPayload = Dict[str, Any]
|
|
@@ -21,6 +21,7 @@ class ActionCreators:
|
|
|
21
21
|
view_type: str,
|
|
22
22
|
user_id: Optional[UserId] = None,
|
|
23
23
|
session_id: Optional[SessionId] = None,
|
|
24
|
+
guild_id: GuildId = None,
|
|
24
25
|
**props,
|
|
25
26
|
) -> ActionPayload:
|
|
26
27
|
"""Create a VIEW_CREATED action payload."""
|
|
@@ -29,6 +30,7 @@ class ActionCreators:
|
|
|
29
30
|
"view_type": view_type,
|
|
30
31
|
"user_id": user_id,
|
|
31
32
|
"session_id": session_id,
|
|
33
|
+
"guild_id": guild_id,
|
|
32
34
|
"props": props,
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -44,10 +46,18 @@ class ActionCreators:
|
|
|
44
46
|
|
|
45
47
|
@staticmethod
|
|
46
48
|
def session_created(
|
|
47
|
-
session_id: SessionId,
|
|
49
|
+
session_id: SessionId,
|
|
50
|
+
user_id: Optional[UserId] = None,
|
|
51
|
+
guild_id: GuildId = None,
|
|
52
|
+
**data,
|
|
48
53
|
) -> ActionPayload:
|
|
49
54
|
"""Create a SESSION_CREATED action payload."""
|
|
50
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
"session_id": session_id,
|
|
57
|
+
"user_id": user_id,
|
|
58
|
+
"guild_id": guild_id,
|
|
59
|
+
"shared_data": data,
|
|
60
|
+
}
|
|
51
61
|
|
|
52
62
|
@staticmethod
|
|
53
63
|
def session_updated(session_id: SessionId, **data) -> ActionPayload:
|
|
@@ -76,6 +76,7 @@ async def reduce_view_created(action: Action, state: StateData) -> StateData:
|
|
|
76
76
|
"id": view_id,
|
|
77
77
|
"type": payload.get("view_type"),
|
|
78
78
|
"user_id": payload.get("user_id"),
|
|
79
|
+
"guild_id": payload.get("guild_id"),
|
|
79
80
|
"session_id": payload.get("session_id"),
|
|
80
81
|
"created_at": action["timestamp"],
|
|
81
82
|
"updated_at": action["timestamp"],
|
|
@@ -182,6 +183,7 @@ async def reduce_session_created(action: Action, state: StateData) -> StateData:
|
|
|
182
183
|
new_session = {
|
|
183
184
|
"id": session_id,
|
|
184
185
|
"user_id": payload.get("user_id"),
|
|
186
|
+
"guild_id": payload.get("guild_id"),
|
|
185
187
|
"created_at": action["timestamp"],
|
|
186
188
|
"updated_at": action["timestamp"],
|
|
187
189
|
"members": [],
|
|
@@ -12,6 +12,7 @@ from typing import Any, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Uni
|
|
|
12
12
|
|
|
13
13
|
from ..utils.errors import with_error_boundary
|
|
14
14
|
from ..utils.tasks import get_task_manager
|
|
15
|
+
from .actions import ActionCreators
|
|
15
16
|
from .slots import access_slot, read_slot
|
|
16
17
|
from .types import Action, HookFn, MiddlewareFn, ReducerFn, SelectorFn, StateData, SubscriberFn
|
|
17
18
|
|
|
@@ -912,6 +913,57 @@ class StateStore:
|
|
|
912
913
|
if p_key is not None and p_key != scope_key:
|
|
913
914
|
self._remove_from_instance_index(view_id, view_type, p_key)
|
|
914
915
|
|
|
916
|
+
async def _destroy_view(self, view_id: str, *, source_id: Optional[str] = None) -> bool:
|
|
917
|
+
"""Atomic view teardown: dispatch ``VIEW_DESTROYED``, then drop the active entry.
|
|
918
|
+
|
|
919
|
+
A live view occupies two registries: ``state["views"]`` (the Redux
|
|
920
|
+
source of truth, mutated only through the reducer) and ``_active_views``
|
|
921
|
+
(the sync instance-limit and inspector index). This method tears them
|
|
922
|
+
down in a fixed order. The async ``VIEW_DESTROYED`` dispatch removes the
|
|
923
|
+
``state["views"]`` entry first; ``_unregister_view`` clears the
|
|
924
|
+
``_active_views`` entry only once state confirms the view is gone.
|
|
925
|
+
|
|
926
|
+
The ordering keeps the two registries consistent under failure. A
|
|
927
|
+
raising middleware, a reducer error the dispatch chain logs and
|
|
928
|
+
absorbs, or a cancellation mid-dispatch all leave both registries
|
|
929
|
+
intact rather than producing the inspector-flagged divergence (a view
|
|
930
|
+
present in ``state["views"]`` but absent from ``_active_views``).
|
|
931
|
+
Over-retention is transient and self-heals on the next teardown or
|
|
932
|
+
restart.
|
|
933
|
+
|
|
934
|
+
Idempotent and safe under double-teardown. Returns ``True`` when the
|
|
935
|
+
view was fully removed, ``False`` when the state removal did not land
|
|
936
|
+
and the active-registry entry was retained.
|
|
937
|
+
"""
|
|
938
|
+
try:
|
|
939
|
+
await self.dispatch(
|
|
940
|
+
"VIEW_DESTROYED", ActionCreators.view_destroyed(view_id), source_id=source_id
|
|
941
|
+
)
|
|
942
|
+
except Exception:
|
|
943
|
+
# Log and fall through to the post-dispatch state check below: if
|
|
944
|
+
# the reducer ran before the exception (state already clean), the
|
|
945
|
+
# finally clears the active entry and the check returns True; if not,
|
|
946
|
+
# the check returns False and retains both registries.
|
|
947
|
+
logger.exception(
|
|
948
|
+
f"VIEW_DESTROYED dispatch failed for {view_id}; "
|
|
949
|
+
f"checking whether the state entry was removed."
|
|
950
|
+
)
|
|
951
|
+
finally:
|
|
952
|
+
# Clear the active entry only once state confirms the removal. The
|
|
953
|
+
# finally clause also covers a cancellation mid-dispatch: when the
|
|
954
|
+
# reducer already removed the state entry before the await was
|
|
955
|
+
# cancelled, the active entry still gets cleared, so a cancelled
|
|
956
|
+
# teardown cannot leave the view stranded in _active_views.
|
|
957
|
+
if view_id not in self.state.get("views", {}):
|
|
958
|
+
self._unregister_view(view_id)
|
|
959
|
+
if view_id not in self.state.get("views", {}):
|
|
960
|
+
return True
|
|
961
|
+
logger.warning(
|
|
962
|
+
f"VIEW_DESTROYED did not remove {view_id} from state; "
|
|
963
|
+
f"retaining active-registry entry to avoid a ghost."
|
|
964
|
+
)
|
|
965
|
+
return False
|
|
966
|
+
|
|
915
967
|
def _get_active_views(self, view_type: str, scope_key: str) -> list:
|
|
916
968
|
"""Return active view instances for a type+scope, oldest-first. Internal plumbing."""
|
|
917
969
|
key = (view_type, scope_key)
|