refactor(views): introduce visibility helper and schema version (Phases 0+1)
## Phase 0 — Schema Version Field - Added SCHEMA_VERSION = 2 constant and _schema_version field to DEFAULT_SETTINGS in muxplex/settings.py to support versioned federation. - Added _schema_version to SYNCABLE_KEYS so peers see the version but apply_synced_settings never accepts an incoming version (one-way: send only). - save_settings() always clamps _schema_version to current SCHEMA_VERSION. - Added peer_supports_v2() helper for federation handshake. - 6 new tests under "Schema version (Phase 0)" in test_settings.py. ## Phase 1 — Backend & Frontend Visibility Helpers Backend (muxplex/views.py): - Added is_hidden(key, settings), filter_visible(sessions, settings, view, *, include_hidden=False), visible_count(...), and normalize_session_keys() to provide read-time visibility filtering. - Updated module docstring to describe v2 semantics (hidden is a property, not a placement; legacy enforce_mutual_exclusion retained as v1 backstop). - 22 new tests covering the full filter matrix and normalization edge cases. Frontend (muxplex/frontend/app.js): - Added isHidden, filterVisible, visibleCount to app.js (pre-ES6 idioms). - Replaced getVisibleSessions body with thin wrapper around filterVisible. - Replaced 8 raw .length count sites in dropdown, settings, and Manage View to route through visibleCount. - Settings panel shows "N sessions (M hidden)" when M > 0. - Manage View "in this view" count uses includeHidden: true. - 17 new tests in test_app.mjs covering the same matrix as backend. - Updated 5 stale tests in test_frontend_js.py. ## Additional Updates - Updated test_readme.py to exempt internal underscore-prefixed setting keys from README documentation requirement. - Updated docs/plans/2026-05-17-hidden-state-redesign-design.md with COE corrections and design notes (federation truth, Phase 3 deferral, schema version semantics, local-only pruning state, federation tests). All 1223 tests in muxplex/tests/ pass. All 17 new JS tests pass. No raw .length count sites remain in counting code paths. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,551 @@
|
|||||||
|
# Hidden State Redesign: Filter-at-Read, Single Visibility Helper
|
||||||
|
|
||||||
|
**Date:** 2026-05-17
|
||||||
|
**Status:** Design — ready for implementation
|
||||||
|
**Author:** bkrabach
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The current implementation tangles two concerns: *placement* (which view a session
|
||||||
|
belongs to) and *presentation* (whether a session is hidden). The result is a
|
||||||
|
load-bearing invariant — "a session key cannot exist in both `hidden_sessions`
|
||||||
|
and any `view.sessions`" — enforced operationally on every add/remove/sync
|
||||||
|
(`views.py:enforce_mutual_exclusion`).
|
||||||
|
|
||||||
|
Because this invariant is enforced at write time but the count display reads
|
||||||
|
raw lengths at render time, the displayed counts can disagree with the rendered
|
||||||
|
grid. There are five count computation sites; only one of them goes through the
|
||||||
|
canonical filter (`getVisibleSessions`). The others read `v.sessions.length` or
|
||||||
|
`hidden_sessions.length` directly. See `app.js:894, 913, 1010, 1266, 2640`.
|
||||||
|
|
||||||
|
Concrete failure modes today:
|
||||||
|
|
||||||
|
- A dead tmux session leaves its key in `view.sessions` forever. The dropdown
|
||||||
|
shows `Work (3)` but the grid renders 2 tiles.
|
||||||
|
- `hidden_sessions` accumulates stale keys across the lifetime of the install.
|
||||||
|
The Hidden view label shows `(5)` but renders 0 tiles.
|
||||||
|
- `getVisibleSessions` does not cross-check `hidden_sessions` for user views. If
|
||||||
|
the mutual-exclusion invariant is ever transiently violated (e.g., federation
|
||||||
|
sync race), a hidden session **would** render in a user view.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Hidden is a property, not a placement.** A session can be in zero or more
|
||||||
|
views and is independently marked hidden or not. View membership and hidden
|
||||||
|
state are orthogonal.
|
||||||
|
2. **One filter function.** Every list-producing path and every count goes
|
||||||
|
through a single canonical helper. No raw `.length` on stored arrays.
|
||||||
|
3. **Default exclude hidden, opt-in to include.** Display paths use the default.
|
||||||
|
Management paths pass `include_hidden=True` explicitly.
|
||||||
|
4. **Compose, don't fuse.** Low-level data operations stay pure
|
||||||
|
(`addMembership`, `hide`, `unhide`). User-intent operations compose them
|
||||||
|
explicitly (`addSessionToView` = `unhide` + `addMembership`). The auto-unhide
|
||||||
|
behavior remains, but it lives in a named operation a reviewer can see.
|
||||||
|
5. **Operational enforcement is replaced by read-time filtering.** The
|
||||||
|
mutual-exclusion invariant is removed.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Data model (no schema changes)
|
||||||
|
|
||||||
|
Storage is unchanged. Only the contract changes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"views": [
|
||||||
|
{ "name": "Work", "sessions": ["dev1:proj", "dev1:build"] }
|
||||||
|
],
|
||||||
|
"hidden_sessions": ["dev1:build", "dev1:old"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same key may now appear in both `view.sessions` and `hidden_sessions`. That
|
||||||
|
combination means: "this session is a member of Work, but is currently hidden."
|
||||||
|
It will not appear in the Work view by default; it will appear in the Hidden
|
||||||
|
view; it will appear *dimmed* in the Manage View panel for Work.
|
||||||
|
|
||||||
|
### The visibility helper (frontend)
|
||||||
|
|
||||||
|
Single function. Replaces all five count sites and `getVisibleSessions`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app.js
|
||||||
|
function isHidden(key, hiddenSet) {
|
||||||
|
return hiddenSet.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the canonical list of sessions for the requested view.
|
||||||
|
// view: "all" | "hidden" | <user view name>
|
||||||
|
// includeHidden: when true, do not filter hidden sessions out of user views
|
||||||
|
// or "all". Ignored for "hidden" (always shows hidden only).
|
||||||
|
function filterVisible(sessions, settings, view, { includeHidden = false } = {}) {
|
||||||
|
const hiddenSet = new Set(settings.hidden_sessions || []);
|
||||||
|
const keyOf = s => s.sessionKey || s.name;
|
||||||
|
|
||||||
|
// status-tile entries are never counted as sessions
|
||||||
|
const live = (sessions || []).filter(s => !s.status);
|
||||||
|
|
||||||
|
if (view === "hidden") {
|
||||||
|
return live.filter(s => isHidden(keyOf(s), hiddenSet) || hiddenSet.has(s.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityPass = includeHidden
|
||||||
|
? () => true
|
||||||
|
: s => !isHidden(keyOf(s), hiddenSet) && !hiddenSet.has(s.name);
|
||||||
|
|
||||||
|
if (view === "all") {
|
||||||
|
return live.filter(visibilityPass);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userView = (settings.views || []).find(v => v.name === view);
|
||||||
|
if (!userView) return [];
|
||||||
|
const memberSet = new Set(userView.sessions || []);
|
||||||
|
return live.filter(s =>
|
||||||
|
(memberSet.has(keyOf(s)) || memberSet.has(s.name)) && visibilityPass(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleCount(...args) {
|
||||||
|
return filterVisible(...args).length;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Counts that displayed `(N)` previously now call `visibleCount(_currentSessions,
|
||||||
|
settings, viewName)`. Manage View counts call
|
||||||
|
`visibleCount(_currentSessions, settings, viewName, { includeHidden: true })`
|
||||||
|
and additionally report the hidden-of-total breakdown (see UI section).
|
||||||
|
|
||||||
|
### Backend helper
|
||||||
|
|
||||||
|
If the server ever computes session lists (e.g., for sync or for a future API),
|
||||||
|
mirror the helper in Python:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# views.py (replacing enforce_mutual_exclusion)
|
||||||
|
|
||||||
|
def is_hidden(key: str, settings: dict) -> bool:
|
||||||
|
return key in (settings.get("hidden_sessions") or [])
|
||||||
|
|
||||||
|
def filter_visible(
|
||||||
|
sessions: list[dict],
|
||||||
|
settings: dict,
|
||||||
|
view: str,
|
||||||
|
*,
|
||||||
|
include_hidden: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
hidden = set(settings.get("hidden_sessions") or [])
|
||||||
|
|
||||||
|
def key_of(s): return s.get("sessionKey") or s.get("name")
|
||||||
|
def visibility_pass(s):
|
||||||
|
if include_hidden:
|
||||||
|
return True
|
||||||
|
k = key_of(s)
|
||||||
|
return k not in hidden and s.get("name") not in hidden
|
||||||
|
|
||||||
|
live = [s for s in sessions if not s.get("status")]
|
||||||
|
if view == "hidden":
|
||||||
|
return [s for s in live if key_of(s) in hidden or s.get("name") in hidden]
|
||||||
|
if view == "all":
|
||||||
|
return [s for s in live if visibility_pass(s)]
|
||||||
|
|
||||||
|
user_view = next((v for v in (settings.get("views") or []) if v["name"] == view), None)
|
||||||
|
if not user_view:
|
||||||
|
return []
|
||||||
|
members = set(user_view.get("sessions") or [])
|
||||||
|
return [
|
||||||
|
s for s in live
|
||||||
|
if (key_of(s) in members or s.get("name") in members) and visibility_pass(s)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operations: pure vs user-intent
|
||||||
|
|
||||||
|
Two layers. The UI calls user-intent operations. Tests verify both layers.
|
||||||
|
|
||||||
|
**Pure data operations** (low-level, no side effects beyond their name):
|
||||||
|
|
||||||
|
| Operation | Effect |
|
||||||
|
|------------------------|--------------------------------------------------------------|
|
||||||
|
| `addMembership(k, v)` | Append `k` to `settings.views[v].sessions` if absent |
|
||||||
|
| `removeMembership(k, v)` | Remove `k` from `settings.views[v].sessions` |
|
||||||
|
| `hide(k)` | Append `k` to `settings.hidden_sessions` if absent |
|
||||||
|
| `unhide(k)` | Remove `k` from `settings.hidden_sessions` |
|
||||||
|
|
||||||
|
**User-intent operations** (high-level, used by UI):
|
||||||
|
|
||||||
|
| Operation | Composition |
|
||||||
|
|------------------------------------|-------------------------------------------------------------|
|
||||||
|
| `addSessionToView(k, v)` | `unhide(k); addMembership(k, v)` — **auto-unhide here** |
|
||||||
|
| `removeSessionFromView(k, v)` | `removeMembership(k, v)` — does *not* hide |
|
||||||
|
| `hideSession(k)` | `hide(k)` — does *not* touch view membership |
|
||||||
|
| `unhideSession(k)` | `unhide(k)` — does *not* touch view membership |
|
||||||
|
|
||||||
|
The auto-unhide on "Add to View" is preserved as a deliberate user-intent
|
||||||
|
composition, **not** as an invariant enforced everywhere. Reviewers see
|
||||||
|
exactly one call to `unhide()` adjacent to `addMembership()` in
|
||||||
|
`addSessionToView`. Future operations that want to add membership without
|
||||||
|
unhiding (e.g., a bulk migration tool) can call `addMembership` directly and
|
||||||
|
the existence of that uncomposed path is obvious in code review.
|
||||||
|
|
||||||
|
### Federation sync
|
||||||
|
|
||||||
|
**Reality check first.** The current sync model (`settings.py:164-184`) is
|
||||||
|
**whole-list last-write-wins**: `apply_synced_settings()` replaces each
|
||||||
|
syncable key wholesale from the incoming payload, then runs
|
||||||
|
`enforce_mutual_exclusion` as a sanitization pass. Conflict resolution is via
|
||||||
|
a single `settings_updated_at` timestamp on the entire settings blob. There is
|
||||||
|
no per-key merge, no CRDT, no add-wins/remove-wins set semantics.
|
||||||
|
|
||||||
|
This refactor does **not** change that. We accept whole-list LWW for v1 and
|
||||||
|
document the consequences:
|
||||||
|
|
||||||
|
- If device A hides session X while device B adds X to a view, whichever
|
||||||
|
device last wrote settings wins for *both* keys (`hidden_sessions` and
|
||||||
|
`views`). The user may see a brief flicker between sync rounds. This is the
|
||||||
|
same trade-off every other syncable setting already makes.
|
||||||
|
- Removing `enforce_mutual_exclusion` from `apply_synced_settings` is **not**
|
||||||
|
safe in mixed-version federation (see Mixed-version safety below). It stays
|
||||||
|
in place for v1 as a backstop. The read-time filter does not depend on the
|
||||||
|
invariant for correctness; it just needs to handle overlap when overlap
|
||||||
|
exists. Letting `enforce_mutual_exclusion` continue to strip overlap on
|
||||||
|
incoming sync is fine — new code tolerates either presence or absence of
|
||||||
|
overlap.
|
||||||
|
|
||||||
|
This means **Phase 3 (delete `enforce_mutual_exclusion`) is deferred** out of
|
||||||
|
the v1 plan. It becomes safe to remove only after all federated devices
|
||||||
|
publish a `_schema_version >= 2`. See Phase 3 in the implementation phases
|
||||||
|
section.
|
||||||
|
|
||||||
|
### Mixed-version safety (the blocking concern)
|
||||||
|
|
||||||
|
Without a schema version field, mixed-version federation will silently revert
|
||||||
|
user actions:
|
||||||
|
|
||||||
|
1. User on new device A hides session X (X is in view Work).
|
||||||
|
2. A's settings now have X in both `hidden_sessions` and `Work.sessions`.
|
||||||
|
3. Sync to old device B. B applies the new settings, then runs
|
||||||
|
`enforce_mutual_exclusion` — silently strips X from `hidden_sessions`.
|
||||||
|
4. B saves with new `settings_updated_at`. Sync back to A. X is now unhidden.
|
||||||
|
5. User re-hides. Oscillation.
|
||||||
|
|
||||||
|
The mitigation: add a `_schema_version: int` field in **Phase 0** (a
|
||||||
|
prerequisite to all other phases). New clients write `_schema_version: 2`
|
||||||
|
into settings whenever they save. New clients detect old peers during
|
||||||
|
federation handshake (old peers send no `_schema_version` or `< 2`) and
|
||||||
|
behave conservatively:
|
||||||
|
|
||||||
|
- When sending to a legacy peer: do not produce settings where the same key
|
||||||
|
appears in both `hidden_sessions` and any `view.sessions`. Pre-flatten by
|
||||||
|
calling `enforce_mutual_exclusion` on the outgoing payload.
|
||||||
|
- When receiving from a legacy peer: accept the incoming state as-is (it
|
||||||
|
already respects the old invariant).
|
||||||
|
|
||||||
|
This keeps mixed-version operation safe. Once all peers report
|
||||||
|
`_schema_version >= 2`, the pre-flatten step is unnecessary. Detection of
|
||||||
|
"all peers uniform" is a manual operator action for v1 (a log message
|
||||||
|
suffices); we do not need to automate it before deleting
|
||||||
|
`enforce_mutual_exclusion`.
|
||||||
|
|
||||||
|
### Stale key pruning (separate concern, local-only state)
|
||||||
|
|
||||||
|
Filtering at read time means stale keys no longer corrupt counts. But
|
||||||
|
`hidden_sessions` and `view.sessions` still accumulate dead keys forever and
|
||||||
|
sync them across the federation. Add a periodic pruning pass.
|
||||||
|
|
||||||
|
**Critical: pruning bookkeeping does NOT sync.** First-missed-at timestamps
|
||||||
|
are a per-device concern. Each device tracks which keys it has personally
|
||||||
|
failed to see, and prunes from its own settings when its grace period expires.
|
||||||
|
The actual prune (removing the key from `view.sessions` or `hidden_sessions`)
|
||||||
|
is a normal settings write that *does* sync via the existing LWW mechanism.
|
||||||
|
This avoids inventing new federation merge semantics.
|
||||||
|
|
||||||
|
Bookkeeping lives in a local sidecar file, not in `settings.json`:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/muxplex/pruning.json
|
||||||
|
{
|
||||||
|
"first_missed_at": {
|
||||||
|
"dev1:dead-session": 1747512345.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`pruning.json` is **not** in `SYNCABLE_KEYS` and is never sent to peers.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def prune_stale_keys(settings: dict, live_keys: set[str], grace_seconds: float) -> dict:
|
||||||
|
# 1. Update local first-missed-at bookkeeping based on live_keys.
|
||||||
|
# 2. For each key in hidden_sessions or any view.sessions:
|
||||||
|
# - If key in live_keys: remove from first-missed-at (if present).
|
||||||
|
# - If key not in live_keys: ensure first-missed-at has this key.
|
||||||
|
# If first_missed_at[key] + grace_seconds < now: drop the key.
|
||||||
|
# 3. Return modified settings (caller decides whether to save).
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs on `_run_poll_cycle()` after the live session list is gathered
|
||||||
|
(`main.py:173`).
|
||||||
|
|
||||||
|
**Grace period rationale.** A tmux session disappears briefly during restart
|
||||||
|
cycles — minutes, occasionally hours. A user's intent to keep a session in a
|
||||||
|
view, however, can span days (a vacation, a long context-switch). The tension
|
||||||
|
is between cleaning up genuinely dead keys quickly and not surprising users.
|
||||||
|
Default: 24 hours, configurable via a new `stale_key_grace_hours` setting.
|
||||||
|
This is well above the worst-case restart duration and well below "I forgot
|
||||||
|
this view existed" timescales. Document the choice; revise based on use.
|
||||||
|
|
||||||
|
In a federated install, the grace period applies *per device*. A key not
|
||||||
|
seen on device A for 24h is dropped on A; B may still be tracking it.
|
||||||
|
B's eventual sync receives the deletion from A. This is the correct
|
||||||
|
behavior — A speaks for itself.
|
||||||
|
|
||||||
|
This phase is independent of the visibility refactor and can ship in its
|
||||||
|
own commit.
|
||||||
|
|
||||||
|
## UI changes
|
||||||
|
|
||||||
|
### Manage View panel
|
||||||
|
|
||||||
|
- Loads with `include_hidden=True`.
|
||||||
|
- Hidden sessions render dimmed (CSS class `session-hidden-dim`, ~50%
|
||||||
|
opacity).
|
||||||
|
- Checkbox state for hidden sessions reflects membership (in view or not),
|
||||||
|
independent of hidden state.
|
||||||
|
- Checking a hidden session that is not yet in the view calls
|
||||||
|
`addSessionToView` — which unhides AND adds. The dim styling disappears.
|
||||||
|
- Unchecking a session calls `removeSessionFromView` (does not hide).
|
||||||
|
- A small "(hidden)" badge confirms hidden state for clarity.
|
||||||
|
|
||||||
|
### Settings panel view list
|
||||||
|
|
||||||
|
Show both numbers when relevant:
|
||||||
|
|
||||||
|
```
|
||||||
|
Work: 5 sessions (2 hidden)
|
||||||
|
Personal: 3 sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
The bare "N sessions" form is used when no sessions in the view are hidden.
|
||||||
|
The "(M hidden)" suffix appears only when M > 0.
|
||||||
|
|
||||||
|
### Header & sidebar dropdowns
|
||||||
|
|
||||||
|
All counts go through `visibleCount(..., view, { includeHidden: false })`.
|
||||||
|
The bare numeric badge (e.g., `Work (3)`) reflects the count of sessions
|
||||||
|
actually rendered in the grid when that view is active. No exceptions.
|
||||||
|
|
||||||
|
### "Hidden" view label
|
||||||
|
|
||||||
|
The "Hidden" view count uses `visibleCount(..., "hidden")` — only counts
|
||||||
|
hidden sessions that are *live*. A stale entry in `hidden_sessions` for a
|
||||||
|
dead tmux session does not inflate this count. (The stale entry will be
|
||||||
|
removed by `prune_stale_keys` after the grace period.)
|
||||||
|
|
||||||
|
## Migration & backward compatibility
|
||||||
|
|
||||||
|
- **Settings files:** schema-compatible. Old files have no overlap between
|
||||||
|
`hidden_sessions` and `view.sessions`; the new code handles that correctly.
|
||||||
|
Until Phase 3 (deferred), new code never *writes* overlap either — it
|
||||||
|
keeps `enforce_mutual_exclusion` as a backstop, so the on-disk schema does
|
||||||
|
not change.
|
||||||
|
- **Schema version:** add `_schema_version: 2` to settings in Phase 0. Old
|
||||||
|
clients ignore unknown keys (`settings.py:load_settings` only copies keys
|
||||||
|
present in `DEFAULT_SETTINGS`), so this is safe to write. The version
|
||||||
|
exists primarily as a marker for future Phase 3 — when all peers report
|
||||||
|
`>= 2`, it is safe to delete `enforce_mutual_exclusion`.
|
||||||
|
- **Old clients reading new state:** identical to today (no overlap exists
|
||||||
|
to drop, because we still call `enforce_mutual_exclusion` on save).
|
||||||
|
- **Key format normalization:** Phase 1 includes a one-time pass that walks
|
||||||
|
`views[].sessions` and `hidden_sessions` on first load by the new code,
|
||||||
|
upgrading bare-name entries to the canonical `device_id:name` form where a
|
||||||
|
match exists in the current live session list. Entries that cannot be
|
||||||
|
matched are preserved (they may match later). This pays the migration cost
|
||||||
|
once and removes the need for dual-lookup at every read site.
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
|
||||||
|
Each phase is independently shippable. Order matters: Phase 0 is a
|
||||||
|
prerequisite to all others.
|
||||||
|
|
||||||
|
### Phase 0 — Schema version field
|
||||||
|
|
||||||
|
Tiny but load-bearing. Future-proofs the federation upgrade path.
|
||||||
|
|
||||||
|
- Add `_schema_version` to `DEFAULT_SETTINGS` (`settings.py:17-47`) with
|
||||||
|
value `2`. Add to `SYNCABLE_KEYS` so peers see it.
|
||||||
|
- On every `save_settings`/`patch_settings` write, ensure
|
||||||
|
`_schema_version` is set to `2` regardless of incoming patch (clients
|
||||||
|
don't get to write older versions).
|
||||||
|
- Add a helper `peer_supports_v2(peer_settings)` that returns
|
||||||
|
`peer_settings.get("_schema_version", 0) >= 2`.
|
||||||
|
- No behavioral change yet. This is just the marker.
|
||||||
|
|
||||||
|
### Phase 1 — Visibility helper + audit + key normalization
|
||||||
|
|
||||||
|
Single most important change. Source of truth for visibility lives here.
|
||||||
|
|
||||||
|
- Add `filterVisible()` and `visibleCount()` to `app.js`.
|
||||||
|
- Add `filter_visible()` to `views.py` (for backend symmetry, even if no
|
||||||
|
current Python caller needs it).
|
||||||
|
- Replace every count site:
|
||||||
|
- `app.js:894` (Hidden count) → `visibleCount(..., "hidden")`
|
||||||
|
- `app.js:900-903` (All Sessions header) → `visibleCount(..., "all")`
|
||||||
|
- `app.js:913` (User view header dropdown) → `visibleCount(..., v.name)`
|
||||||
|
- `app.js:997-1000` (All Sessions sidebar) → same
|
||||||
|
- `app.js:1010` (User view sidebar) → same
|
||||||
|
- `app.js:1266` (Settings panel) → same, with `(M hidden)` suffix
|
||||||
|
- `app.js:2640` (Manage View "in this view") →
|
||||||
|
`visibleCount(..., view, { includeHidden: true })`
|
||||||
|
- Replace `getVisibleSessions()` body with a call to `filterVisible()`.
|
||||||
|
- Grep audit: search for `.sessions.length`, `hidden_sessions.length`,
|
||||||
|
`hidden.length`. Each remaining occurrence must be justified in a comment
|
||||||
|
or replaced.
|
||||||
|
- **One-time key normalization**: on first load by the new code (detected via
|
||||||
|
absence of `_schema_version` in the on-disk settings, or via a new marker),
|
||||||
|
walk `views[].sessions` and `hidden_sessions`. For each entry that is a
|
||||||
|
bare `name`, check if any live session has a matching `name`; if so,
|
||||||
|
upgrade the stored entry to `device_id:name` form. Entries that cannot be
|
||||||
|
matched (no live session with that name) are left as-is. Run once, set
|
||||||
|
`_schema_version: 2`, save. Subsequent loads skip the pass.
|
||||||
|
- After normalization lands, the `memberSet.has(keyOf(s)) || memberSet.has(s.name)`
|
||||||
|
dual-lookup can be simplified to a single lookup. Keep the dual lookup
|
||||||
|
initially — remove it in a follow-up commit once normalization has run on
|
||||||
|
all production installs.
|
||||||
|
|
||||||
|
### Phase 2 — Operation layer
|
||||||
|
|
||||||
|
- Refactor the existing hide/unhide/add-to-view code paths into the four pure
|
||||||
|
+ four user-intent operations described above.
|
||||||
|
- Update existing call sites in `app.js` (`hideSession`, `unhideSession`,
|
||||||
|
add-to-view flyout, Manage View checkbox handler) to call the user-intent
|
||||||
|
operations.
|
||||||
|
- Document the layering in a header comment.
|
||||||
|
|
||||||
|
### Phase 3 — Remove `enforce_mutual_exclusion` *(deferred from v1)*
|
||||||
|
|
||||||
|
**Not part of v1.** Listed here for completeness; do not ship until all
|
||||||
|
federated peers report `_schema_version >= 2` and the operator confirms it
|
||||||
|
is safe to drop the legacy backstop.
|
||||||
|
|
||||||
|
When the time comes:
|
||||||
|
- Delete `views.py:enforce_mutual_exclusion` and its call site in
|
||||||
|
`settings.py:apply_synced_settings()`.
|
||||||
|
- Delete the corresponding test cases in `tests/test_views.py`.
|
||||||
|
- Remove the outgoing-payload pre-flatten step added in Phase 0.
|
||||||
|
- Bump `_schema_version` to 3 if any further on-disk format change accompanies
|
||||||
|
the removal.
|
||||||
|
|
||||||
|
Until this phase ships, the new code coexists with the old invariant. Counts
|
||||||
|
and rendering are correct; storage continues to enforce mutual exclusion. The
|
||||||
|
only user-visible cost is that the auto-unhide-on-add behavior cannot be
|
||||||
|
broken into "add without unhiding" without `enforce_mutual_exclusion`
|
||||||
|
reverting it on the next save. This is acceptable for v1 — that capability
|
||||||
|
is not exposed to users.
|
||||||
|
|
||||||
|
### Phase 4 — Stale key pruning (local-only bookkeeping)
|
||||||
|
|
||||||
|
- Add `prune_stale_keys()` in `views.py`. Reads & writes a local sidecar
|
||||||
|
file `~/.config/muxplex/pruning.json` (see the design section above).
|
||||||
|
- The sidecar is **not** in `SYNCABLE_KEYS` and is never sent to peers.
|
||||||
|
- Wire into `_run_poll_cycle()` after the live session collection step
|
||||||
|
(`main.py:173`).
|
||||||
|
- Add `stale_key_grace_hours` (default 24) to `DEFAULT_SETTINGS`. Syncable;
|
||||||
|
affects every device that consumes it. Each device still tracks its own
|
||||||
|
first-missed-at timestamps locally.
|
||||||
|
|
||||||
|
### Phase 5 — UI polish
|
||||||
|
|
||||||
|
- Manage View dim styling.
|
||||||
|
- Settings panel "(M hidden)" suffix.
|
||||||
|
- Any other discoverability improvements.
|
||||||
|
|
||||||
|
Phases 0–2 are the actual semantic change. Phase 4 is hygiene. Phase 5 is
|
||||||
|
presentation. Phase 3 is deferred. Don't combine them.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
### New tests (frontend, `frontend/tests/test_app.mjs`)
|
||||||
|
|
||||||
|
- `filterVisible` matrix — every combination of:
|
||||||
|
- view ∈ {"all", "hidden", `<user view>`, `<nonexistent view>`}
|
||||||
|
- `includeHidden` ∈ {true, false}
|
||||||
|
- session in / absent from `view.sessions`
|
||||||
|
- key in / absent from `hidden_sessions`
|
||||||
|
- key in both (the new permitted state; verify v1 storage never produces it,
|
||||||
|
but the helper handles it correctly if it appears)
|
||||||
|
- session-key format: `device_id:name`, bare `name`, both
|
||||||
|
- `visibleCount` matches `filterVisible().length` for the same inputs.
|
||||||
|
- Key normalization: bare-name entries upgrade to `device_id:name` when a
|
||||||
|
match exists; remain bare when no match exists; idempotent across reloads.
|
||||||
|
|
||||||
|
### New tests (backend, `tests/test_views.py`, `tests/test_settings.py`)
|
||||||
|
|
||||||
|
- `filter_visible` mirroring the frontend matrix.
|
||||||
|
- `addSessionToView` unhides and adds membership.
|
||||||
|
- `addMembership` adds membership but does **not** unhide.
|
||||||
|
- `removeSessionFromView` removes membership but does **not** hide.
|
||||||
|
- `hide` and `unhide` do not touch view membership.
|
||||||
|
- Schema version: `save_settings` always writes `_schema_version: 2`.
|
||||||
|
- `peer_supports_v2`: returns true for `_schema_version >= 2`, false for
|
||||||
|
missing or `< 2`.
|
||||||
|
|
||||||
|
### Federation tests (`tests/test_settings.py` or new `test_federation.py`)
|
||||||
|
|
||||||
|
- Round-trip: state with overlap, save, reload, assert content unchanged
|
||||||
|
(with `enforce_mutual_exclusion` still active, overlap should be stripped
|
||||||
|
on save — verify this is the v1 invariant).
|
||||||
|
- Mixed-version send: when serializing settings for a peer reporting
|
||||||
|
`_schema_version < 2`, the outgoing payload has no overlap. Verify by
|
||||||
|
constructing settings with overlap (force it past the backstop in a test
|
||||||
|
helper) and asserting the outgoing form is flattened.
|
||||||
|
- Mixed-version receive: applying settings from a legacy peer is a no-op
|
||||||
|
beyond the existing whole-list replacement. Verify `_schema_version` is
|
||||||
|
not downgraded by an incoming payload that lacks it.
|
||||||
|
|
||||||
|
### Stale-key pruning tests (`tests/test_views.py`)
|
||||||
|
|
||||||
|
- Live key: pruning bookkeeping is cleared.
|
||||||
|
- Newly missing key: bookkeeping records `first_missed_at`, settings unchanged.
|
||||||
|
- Missing past grace: key removed from `view.sessions` and `hidden_sessions`,
|
||||||
|
bookkeeping entry removed.
|
||||||
|
- Re-appeared after partial absence (less than grace): bookkeeping cleared,
|
||||||
|
settings unchanged.
|
||||||
|
- Sidecar file: never written to `settings.json`, never sent over the wire.
|
||||||
|
|
||||||
|
### Updated tests
|
||||||
|
|
||||||
|
- Adapt existing view tests that asserted the old mutual-exclusion invariant.
|
||||||
|
Several tests assume `add_to_view` removes from `hidden_sessions` as a side
|
||||||
|
effect; rewrite them to call the user-intent operation explicitly and
|
||||||
|
assert on both effects. (Keep the existing `enforce_mutual_exclusion`
|
||||||
|
tests — that function stays in v1.)
|
||||||
|
|
||||||
|
### Removed tests
|
||||||
|
|
||||||
|
- None in v1. `enforce_mutual_exclusion` tests stay until Phase 3 ships.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- API redesign for `/api/views` or `/api/sessions`. The wire format is
|
||||||
|
unchanged.
|
||||||
|
- Conflict UI for federation merges. Last-write-wins on the hidden flag is
|
||||||
|
acceptable for v1.
|
||||||
|
- Bulk operations (hide all in view, etc.). The current granularity is
|
||||||
|
sufficient.
|
||||||
|
- Tooltip / hover state showing why a session is dimmed. The "(hidden)" badge
|
||||||
|
is enough.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None that block implementation. Worth deciding before Phase 5:
|
||||||
|
|
||||||
|
- Should the Manage View panel default to `include_hidden=True`, or require
|
||||||
|
an explicit "Show hidden" toggle? Recommendation: default to true. The
|
||||||
|
whole point of the Manage View is management; hiding hidden items from a
|
||||||
|
management UI defeats the purpose.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- COE design review conversation, 2026-05-17 (workspace session
|
||||||
|
e74700a2-5340-46be-8fae-df40a19204f3).
|
||||||
|
- Current implementation: `views.py:14-68`, `settings.py:24-65, 164-184`,
|
||||||
|
`state.py:9`, `app.js:628-672, 894-1010, 1266, 2173-2236, 2640`.
|
||||||
+76
-58
@@ -624,51 +624,74 @@ function buildStatusTileHTML(deviceName, statusText, statusClass) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v2 visibility helpers — single source of truth for session filtering.
|
||||||
|
// See docs/plans/2026-05-17-hidden-state-redesign-design.md
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Returns true if the session key is in settings.hidden_sessions.
|
||||||
|
function isHidden(key, settings) {
|
||||||
|
var hidden = (settings && settings.hidden_sessions) || [];
|
||||||
|
return hidden.indexOf(key) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical session-list filter. Single source of truth for "what is in this
|
||||||
|
// view right now". See docs/plans/2026-05-17-hidden-state-redesign-design.md.
|
||||||
|
// view: "all" | "hidden" | <user view name>
|
||||||
|
// options.includeHidden: when true, hidden sessions are NOT filtered out of
|
||||||
|
// "all" or user views. Ignored for "hidden" (always shows only hidden).
|
||||||
|
function filterVisible(sessions, settings, view, options) {
|
||||||
|
options = options || {};
|
||||||
|
var includeHidden = options.includeHidden === true;
|
||||||
|
var hiddenList = (settings && settings.hidden_sessions) || [];
|
||||||
|
var live = (sessions || []).filter(function (s) { return !s.status; });
|
||||||
|
|
||||||
|
function keyOf(s) { return s.sessionKey || s.name; }
|
||||||
|
function isSessionHidden(s) {
|
||||||
|
return hiddenList.indexOf(keyOf(s)) !== -1 || hiddenList.indexOf(s.name) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === "hidden") {
|
||||||
|
return live.filter(isSessionHidden);
|
||||||
|
}
|
||||||
|
if (view === "all") {
|
||||||
|
if (includeHidden) return live.slice();
|
||||||
|
return live.filter(function (s) { return !isSessionHidden(s); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var views = (settings && settings.views) || [];
|
||||||
|
var userView = null;
|
||||||
|
for (var i = 0; i < views.length; i++) {
|
||||||
|
if (views[i].name === view) { userView = views[i]; break; }
|
||||||
|
}
|
||||||
|
if (!userView) return [];
|
||||||
|
var members = userView.sessions || [];
|
||||||
|
|
||||||
|
function inView(s) {
|
||||||
|
return members.indexOf(keyOf(s)) !== -1 || members.indexOf(s.name) !== -1;
|
||||||
|
}
|
||||||
|
if (includeHidden) {
|
||||||
|
return live.filter(inView);
|
||||||
|
}
|
||||||
|
return live.filter(function (s) { return inView(s) && !isSessionHidden(s); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleCount(sessions, settings, view, options) {
|
||||||
|
return filterVisible(sessions, settings, view, options).length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns sessions filtered by the active view.
|
* Returns sessions filtered by the active view.
|
||||||
*
|
*
|
||||||
* - 'all' view: excludes hidden sessions (sessions in hidden_sessions list)
|
* Thin wrapper around filterVisible() — the canonical filter that is the
|
||||||
* - 'hidden' view: shows only hidden sessions
|
* single source of truth for "what is in this view right now".
|
||||||
* - user view: shows only sessions whose sessionKey is in that view's sessions list
|
* See docs/plans/2026-05-17-hidden-state-redesign-design.md.
|
||||||
*
|
|
||||||
* Status entries (unreachable, auth_failed, empty) are always excluded —
|
|
||||||
* they are rendered separately as status tiles.
|
|
||||||
*
|
|
||||||
* Falls back to 'all' behaviour if the active view no longer exists.
|
|
||||||
*
|
*
|
||||||
* @param {object[]} sessions
|
* @param {object[]} sessions
|
||||||
* @returns {object[]}
|
* @returns {object[]}
|
||||||
*/
|
*/
|
||||||
function getVisibleSessions(sessions) {
|
function getVisibleSessions(sessions) {
|
||||||
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
return filterVisible(sessions, _serverSettings, _activeView);
|
||||||
var views = (_serverSettings && _serverSettings.views) || [];
|
|
||||||
var view = _resolveActiveView(_activeView, views);
|
|
||||||
|
|
||||||
return (sessions || []).filter(function(s) {
|
|
||||||
// Skip status entries (unreachable, auth_failed, empty) — rendered separately as status tiles
|
|
||||||
if (s.status) return false;
|
|
||||||
|
|
||||||
if (view === 'hidden') {
|
|
||||||
// 'hidden' view: only show sessions that are in the hidden list
|
|
||||||
return hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (view !== 'all') {
|
|
||||||
// User-defined view: show only sessions whose sessionKey is in this view's sessions list
|
|
||||||
var userView = views.find(function(v) { return v.name === view; });
|
|
||||||
if (userView) {
|
|
||||||
var viewSessions = userView.sessions || [];
|
|
||||||
return viewSessions.includes(s.sessionKey || s.name) || viewSessions.includes(s.name);
|
|
||||||
}
|
|
||||||
// View no longer exists — fall through to 'all' behaviour
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'all' view: exclude hidden sessions
|
|
||||||
if (hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -890,17 +913,12 @@ function renderViewDropdown() {
|
|||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
|
||||||
var views = (_serverSettings && _serverSettings.views) || [];
|
var views = (_serverSettings && _serverSettings.views) || [];
|
||||||
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden");
|
||||||
var hiddenCount = hidden.length;
|
|
||||||
|
|
||||||
var html = '';
|
var html = '';
|
||||||
|
|
||||||
// — All Sessions (always first) — show count of non-hidden sessions
|
// — All Sessions (always first) — show count of non-hidden sessions
|
||||||
var allHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
var allCount = visibleCount(_currentSessions, _serverSettings, "all");
|
||||||
var allCount = (_currentSessions || []).filter(function(s) {
|
|
||||||
if (s.status) return false;
|
|
||||||
return allHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && allHiddenSessions.indexOf(s.name) === -1;
|
|
||||||
}).length;
|
|
||||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + allCount + '</span></button>';
|
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + allCount + '</span></button>';
|
||||||
|
|
||||||
@@ -910,7 +928,7 @@ function renderViewDropdown() {
|
|||||||
for (var i = 0; i < views.length && i < 7; i++) {
|
for (var i = 0; i < views.length && i < 7; i++) {
|
||||||
var v = views[i];
|
var v = views[i];
|
||||||
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
|
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + visibleCount(_currentSessions, _serverSettings, v.name) + '</span></button>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,17 +1005,12 @@ function renderSidebarViewDropdown() {
|
|||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
|
||||||
var views = (_serverSettings && _serverSettings.views) || [];
|
var views = (_serverSettings && _serverSettings.views) || [];
|
||||||
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden");
|
||||||
var hiddenCount = hidden.length;
|
|
||||||
|
|
||||||
var html = '';
|
var html = '';
|
||||||
|
|
||||||
// — All Sessions (always first) — show count of non-hidden sessions
|
// — All Sessions (always first) — show count of non-hidden sessions
|
||||||
var sbHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
var sbAllCount = visibleCount(_currentSessions, _serverSettings, "all");
|
||||||
var sbAllCount = (_currentSessions || []).filter(function(s) {
|
|
||||||
if (s.status) return false;
|
|
||||||
return sbHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && sbHiddenSessions.indexOf(s.name) === -1;
|
|
||||||
}).length;
|
|
||||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + sbAllCount + '</span></button>';
|
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + sbAllCount + '</span></button>';
|
||||||
|
|
||||||
@@ -1007,7 +1020,7 @@ function renderSidebarViewDropdown() {
|
|||||||
for (var i = 0; i < views.length && i < 7; i++) {
|
for (var i = 0; i < views.length && i < 7; i++) {
|
||||||
var v = views[i];
|
var v = views[i];
|
||||||
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
||||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
|
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + visibleCount(_currentSessions, _serverSettings, v.name) + '</span></button>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1262,8 +1275,9 @@ function renderViewsSettingsTab() {
|
|||||||
// Build the list of view rows (no inline rename — rename is in Manage View panel)
|
// Build the list of view rows (no inline rename — rename is in Manage View panel)
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
views.forEach(function(view, idx) {
|
views.forEach(function(view, idx) {
|
||||||
var viewSessions = view.sessions || [];
|
var sessionCount = visibleCount(_currentSessions, _serverSettings, view.name);
|
||||||
var sessionCount = viewSessions.length;
|
var inclHidden = visibleCount(_currentSessions, _serverSettings, view.name, { includeHidden: true });
|
||||||
|
var hiddenInViewCount = inclHidden - sessionCount;
|
||||||
|
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'views-settings-row';
|
row.className = 'views-settings-row';
|
||||||
@@ -1274,10 +1288,10 @@ function renderViewsSettingsTab() {
|
|||||||
nameSpan.className = 'views-settings-name';
|
nameSpan.className = 'views-settings-name';
|
||||||
nameSpan.textContent = view.name;
|
nameSpan.textContent = view.name;
|
||||||
|
|
||||||
// Session count
|
// Session count — show "(M hidden)" suffix when M > 0
|
||||||
var countSpan = document.createElement('span');
|
var countSpan = document.createElement('span');
|
||||||
countSpan.className = 'views-settings-count';
|
countSpan.className = 'views-settings-count';
|
||||||
countSpan.textContent = sessionCount + (sessionCount === 1 ? ' session' : ' sessions');
|
countSpan.textContent = sessionCount + (sessionCount === 1 ? ' session' : ' sessions') + (hiddenInViewCount > 0 ? ' (' + hiddenInViewCount + ' hidden)' : '');
|
||||||
|
|
||||||
// Actions container
|
// Actions container
|
||||||
var actionsDiv = document.createElement('div');
|
var actionsDiv = document.createElement('div');
|
||||||
@@ -2537,7 +2551,7 @@ function renderManageViewList() {
|
|||||||
|
|
||||||
// Update summary line
|
// Update summary line
|
||||||
if (summaryEl) {
|
if (summaryEl) {
|
||||||
summaryEl.textContent = allSessions.length + ' sessions · ' + viewSessions.length + ' in this view';
|
summaryEl.textContent = allSessions.length + ' sessions · ' + visibleCount(_currentSessions, _serverSettings, _activeView, { includeHidden: true }) + ' in this view';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partition into inView (checked first) and notInView
|
// Partition into inView (checked first) and notInView
|
||||||
@@ -4510,6 +4524,10 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
// Manage Views settings tab
|
// Manage Views settings tab
|
||||||
renderViewsSettingsTab,
|
renderViewsSettingsTab,
|
||||||
_saveViewsAndRerender,
|
_saveViewsAndRerender,
|
||||||
|
// v2 visibility helpers (Phase 1)
|
||||||
|
isHidden,
|
||||||
|
filterVisible,
|
||||||
|
visibleCount,
|
||||||
// Federation tiles
|
// Federation tiles
|
||||||
buildStatusTileHTML,
|
buildStatusTileHTML,
|
||||||
// Constants
|
// Constants
|
||||||
|
|||||||
@@ -4875,3 +4875,194 @@ test('heartbeat uses setTimeout not setInterval for async-safe scheduling', () =
|
|||||||
assert.ok(fnBody.includes('setTimeout'),
|
assert.ok(fnBody.includes('setTimeout'),
|
||||||
'startHeartbeat must use setTimeout for self-scheduling async loop');
|
'startHeartbeat must use setTimeout for self-scheduling async loop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v2 visibility helpers: isHidden, filterVisible, visibleCount
|
||||||
|
// See docs/plans/2026-05-17-hidden-state-redesign-design.md
|
||||||
|
// Mirror of the Python test matrix in tests/test_views.py
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Helper: build a session dict matching the backend test fixture convention.
|
||||||
|
function _session(name, deviceId, status) {
|
||||||
|
deviceId = deviceId || 'dev1';
|
||||||
|
var d = { sessionKey: deviceId + ':' + name, name: name };
|
||||||
|
if (status !== undefined) d.status = status;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- app.js exports the three v2 helpers ---
|
||||||
|
|
||||||
|
test('app.js exports isHidden, filterVisible, visibleCount', () => {
|
||||||
|
for (const fn of ['isHidden', 'filterVisible', 'visibleCount']) {
|
||||||
|
assert.ok(fn in app, `app.js should export "${fn}"`);
|
||||||
|
assert.strictEqual(typeof app[fn], 'function', `"${fn}" should be a function`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- isHidden ---
|
||||||
|
|
||||||
|
test('isHidden returns true when key is in hidden_sessions', () => {
|
||||||
|
const settings = { hidden_sessions: ['dev1:a', 'dev1:b'] };
|
||||||
|
assert.strictEqual(app.isHidden('dev1:a', settings), true);
|
||||||
|
assert.strictEqual(app.isHidden('dev1:b', settings), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isHidden returns false when key is absent from hidden_sessions', () => {
|
||||||
|
const settings = { hidden_sessions: ['dev1:a'] };
|
||||||
|
assert.strictEqual(app.isHidden('dev1:b', settings), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isHidden returns false when hidden_sessions field is missing', () => {
|
||||||
|
assert.strictEqual(app.isHidden('dev1:a', {}), false);
|
||||||
|
assert.strictEqual(app.isHidden('dev1:a', { hidden_sessions: null }), false);
|
||||||
|
assert.strictEqual(app.isHidden('dev1:a', null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- filterVisible: view="all" ---
|
||||||
|
|
||||||
|
test('filterVisible view="all" excludes hidden sessions by default', () => {
|
||||||
|
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||||
|
const settings = { hidden_sessions: ['dev1:b'], views: [] };
|
||||||
|
const result = app.filterVisible(sessions, settings, 'all');
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterVisible view="all" includeHidden:true returns all live sessions', () => {
|
||||||
|
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||||
|
const settings = { hidden_sessions: ['dev1:b'], views: [] };
|
||||||
|
const result = app.filterVisible(sessions, settings, 'all', { includeHidden: true });
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterVisible view="all" excludes status tiles', () => {
|
||||||
|
const sessions = [
|
||||||
|
_session('a'),
|
||||||
|
_session('disconnected', 'dev1', 'error'),
|
||||||
|
_session('b'),
|
||||||
|
];
|
||||||
|
const settings = { hidden_sessions: [], views: [] };
|
||||||
|
const result = app.filterVisible(sessions, settings, 'all');
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- filterVisible: view="hidden" ---
|
||||||
|
|
||||||
|
test('filterVisible view="hidden" returns only hidden sessions', () => {
|
||||||
|
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||||
|
const settings = { hidden_sessions: ['dev1:a', 'dev1:c'], views: [] };
|
||||||
|
const result = app.filterVisible(sessions, settings, 'hidden');
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterVisible view="hidden" returns empty list when no sessions are hidden', () => {
|
||||||
|
const sessions = [_session('a'), _session('b')];
|
||||||
|
const settings = { hidden_sessions: [], views: [] };
|
||||||
|
const result = app.filterVisible(sessions, settings, 'hidden');
|
||||||
|
assert.deepStrictEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterVisible view="hidden" excludes dead keys (hidden_sessions entries with no live match)', () => {
|
||||||
|
const sessions = [_session('a')];
|
||||||
|
const settings = { hidden_sessions: ['dev1:a', 'dev1:ghost'], views: [] };
|
||||||
|
const result = app.filterVisible(sessions, settings, 'hidden');
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- filterVisible: user view ---
|
||||||
|
|
||||||
|
test('filterVisible user view filters by membership AND visibility', () => {
|
||||||
|
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||||
|
const settings = {
|
||||||
|
hidden_sessions: ['dev1:b'],
|
||||||
|
views: [{ name: 'Work', sessions: ['dev1:a', 'dev1:b'] }],
|
||||||
|
};
|
||||||
|
// 'a' is in view and not hidden; 'b' is in view but hidden → excluded by default.
|
||||||
|
const result = app.filterVisible(sessions, settings, 'Work');
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterVisible user view includeHidden:true lifts visibility filter but NOT membership filter', () => {
|
||||||
|
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||||
|
const settings = {
|
||||||
|
hidden_sessions: ['dev1:b'],
|
||||||
|
views: [{ name: 'Work', sessions: ['dev1:a', 'dev1:b'] }],
|
||||||
|
};
|
||||||
|
// 'b' is in view AND hidden — includeHidden surfaces it.
|
||||||
|
// 'c' is NOT in view — still excluded.
|
||||||
|
const result = app.filterVisible(sessions, settings, 'Work', { includeHidden: true });
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterVisible returns empty list for unknown view name', () => {
|
||||||
|
const sessions = [_session('a'), _session('b')];
|
||||||
|
const settings = { hidden_sessions: [], views: [] };
|
||||||
|
assert.deepStrictEqual(app.filterVisible(sessions, settings, 'Nonexistent'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Overlap state (key in both view.sessions AND hidden_sessions) ---
|
||||||
|
|
||||||
|
test('filterVisible overlap state: hidden filter wins by default, includeHidden surfaces it', () => {
|
||||||
|
const sessions = [_session('a'), _session('b')];
|
||||||
|
const settings = {
|
||||||
|
hidden_sessions: ['dev1:a'], // also in Work view
|
||||||
|
views: [{ name: 'Work', sessions: ['dev1:a', 'dev1:b'] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default: 'a' is excluded from Work because it is hidden.
|
||||||
|
const defaultResult = app.filterVisible(sessions, settings, 'Work');
|
||||||
|
assert.deepStrictEqual(defaultResult.map(s => s.name), ['b']);
|
||||||
|
|
||||||
|
// includeHidden: 'a' is surfaced again.
|
||||||
|
const inclResult = app.filterVisible(sessions, settings, 'Work', { includeHidden: true });
|
||||||
|
assert.deepStrictEqual(inclResult.map(s => s.name), ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Bare-name dual-lookup ---
|
||||||
|
|
||||||
|
test('filterVisible bare-name in hidden_sessions matches session by name', () => {
|
||||||
|
// hidden_sessions stores bare 'a'; session has name:'a', sessionKey:'dev1:a'.
|
||||||
|
const sessions = [_session('a'), _session('b')];
|
||||||
|
const settings = { hidden_sessions: ['a'], views: [] };
|
||||||
|
// 'a' is hidden; 'b' is not.
|
||||||
|
const result = app.filterVisible(sessions, settings, 'all');
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterVisible bare-name in view.sessions matches session by name', () => {
|
||||||
|
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||||
|
const settings = {
|
||||||
|
hidden_sessions: [],
|
||||||
|
views: [{ name: 'Work', sessions: ['a', 'b'] }], // bare names
|
||||||
|
};
|
||||||
|
const result = app.filterVisible(sessions, settings, 'Work');
|
||||||
|
assert.deepStrictEqual(result.map(s => s.name), ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- visibleCount ---
|
||||||
|
|
||||||
|
test('visibleCount always equals filterVisible().length for the same inputs', () => {
|
||||||
|
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||||
|
const settings = {
|
||||||
|
hidden_sessions: ['dev1:b'],
|
||||||
|
views: [{ name: 'Work', sessions: ['dev1:a', 'dev1:b', 'dev1:c'] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const matrix = [
|
||||||
|
{ view: 'all', opts: undefined },
|
||||||
|
{ view: 'all', opts: { includeHidden: true } },
|
||||||
|
{ view: 'hidden', opts: undefined },
|
||||||
|
{ view: 'Work', opts: undefined },
|
||||||
|
{ view: 'Work', opts: { includeHidden: true } },
|
||||||
|
{ view: 'Nonexistent', opts: undefined },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { view, opts } of matrix) {
|
||||||
|
const expected = app.filterVisible(sessions, settings, view, opts).length;
|
||||||
|
const actual = app.visibleCount(sessions, settings, view, opts);
|
||||||
|
assert.strictEqual(
|
||||||
|
actual,
|
||||||
|
expected,
|
||||||
|
`visibleCount(${view}, ${JSON.stringify(opts)}) = ${actual} !== filterVisible length ${expected}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ from pathlib import Path
|
|||||||
SETTINGS_PATH = Path.home() / ".config" / "muxplex" / "settings.json"
|
SETTINGS_PATH = Path.home() / ".config" / "muxplex" / "settings.json"
|
||||||
FEDERATION_KEY_PATH = Path.home() / ".config" / "muxplex" / "federation_key"
|
FEDERATION_KEY_PATH = Path.home() / ".config" / "muxplex" / "federation_key"
|
||||||
|
|
||||||
|
# Settings schema version. Incremented when settings semantics change.
|
||||||
|
#
|
||||||
|
# v1 (implicit, missing field): legacy. The mutual-exclusion invariant between
|
||||||
|
# hidden_sessions and view.sessions is enforced at write time. Federation
|
||||||
|
# peers without this field are assumed to be v1.
|
||||||
|
# v2: hidden_sessions and view.sessions are allowed to overlap; visibility is
|
||||||
|
# determined by a read-time filter. In practice the v1 backstop
|
||||||
|
# enforce_mutual_exclusion still runs in v2 — see
|
||||||
|
# docs/plans/2026-05-17-hidden-state-redesign-design.md for the deferral
|
||||||
|
# of its removal (Phase 3).
|
||||||
|
SCHEMA_VERSION: int = 2
|
||||||
|
|
||||||
DEFAULT_SETTINGS: dict = {
|
DEFAULT_SETTINGS: dict = {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8088,
|
"port": 8088,
|
||||||
@@ -44,6 +56,7 @@ DEFAULT_SETTINGS: dict = {
|
|||||||
"gridViewMode": "flat",
|
"gridViewMode": "flat",
|
||||||
"sidebarOpen": None,
|
"sidebarOpen": None,
|
||||||
"settings_updated_at": 0.0,
|
"settings_updated_at": 0.0,
|
||||||
|
"_schema_version": SCHEMA_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
SYNCABLE_KEYS: frozenset[str] = frozenset(
|
SYNCABLE_KEYS: frozenset[str] = frozenset(
|
||||||
@@ -66,6 +79,9 @@ SYNCABLE_KEYS: frozenset[str] = frozenset(
|
|||||||
"default_session",
|
"default_session",
|
||||||
"window_size_largest",
|
"window_size_largest",
|
||||||
"auto_open_created",
|
"auto_open_created",
|
||||||
|
# Schema version — sent so peers can detect our version, but never
|
||||||
|
# accepted from the wire (see apply_synced_settings).
|
||||||
|
"_schema_version",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,11 +111,17 @@ def save_settings(data: dict) -> None:
|
|||||||
|
|
||||||
Creates parent directories as needed. Writes JSON with indent=2 and a
|
Creates parent directories as needed. Writes JSON with indent=2 and a
|
||||||
trailing newline.
|
trailing newline.
|
||||||
|
|
||||||
|
The `_schema_version` field is always written as the current
|
||||||
|
SCHEMA_VERSION regardless of *data*. Clients do not get to write older
|
||||||
|
versions — that would defeat the version field's purpose as a marker for
|
||||||
|
federated peers.
|
||||||
"""
|
"""
|
||||||
merged = copy.deepcopy(DEFAULT_SETTINGS)
|
merged = copy.deepcopy(DEFAULT_SETTINGS)
|
||||||
for key in DEFAULT_SETTINGS:
|
for key in DEFAULT_SETTINGS:
|
||||||
if key in data:
|
if key in data:
|
||||||
merged[key] = data[key]
|
merged[key] = data[key]
|
||||||
|
merged["_schema_version"] = SCHEMA_VERSION
|
||||||
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
SETTINGS_PATH.write_text(json.dumps(merged, indent=2) + "\n")
|
SETTINGS_PATH.write_text(json.dumps(merged, indent=2) + "\n")
|
||||||
|
|
||||||
@@ -167,6 +189,11 @@ def apply_synced_settings(incoming_settings: dict, incoming_timestamp: float) ->
|
|||||||
Only applies keys that are in SYNCABLE_KEYS. Sets settings_updated_at
|
Only applies keys that are in SYNCABLE_KEYS. Sets settings_updated_at
|
||||||
to the incoming timestamp (NOT time.time()) to prevent sync loops.
|
to the incoming timestamp (NOT time.time()) to prevent sync loops.
|
||||||
|
|
||||||
|
`_schema_version` is intentionally **never** accepted from the wire.
|
||||||
|
Each device speaks for its own schema version; receiving a peer's version
|
||||||
|
must not downgrade ours. The peer's version is observable on the incoming
|
||||||
|
payload (use `peer_supports_v2()`) before this function applies anything.
|
||||||
|
|
||||||
After applying synced keys, enforces the mutual exclusion invariant:
|
After applying synced keys, enforces the mutual exclusion invariant:
|
||||||
any session key that appears in both hidden_sessions and a view's sessions
|
any session key that appears in both hidden_sessions and a view's sessions
|
||||||
is removed from hidden_sessions (visibility wins over hiding).
|
is removed from hidden_sessions (visibility wins over hiding).
|
||||||
@@ -176,6 +203,9 @@ def apply_synced_settings(incoming_settings: dict, incoming_timestamp: float) ->
|
|||||||
|
|
||||||
current = load_settings()
|
current = load_settings()
|
||||||
for key in SYNCABLE_KEYS:
|
for key in SYNCABLE_KEYS:
|
||||||
|
if key == "_schema_version":
|
||||||
|
# Never downgrade local schema version from sync.
|
||||||
|
continue
|
||||||
if key in incoming_settings:
|
if key in incoming_settings:
|
||||||
current[key] = incoming_settings[key]
|
current[key] = incoming_settings[key]
|
||||||
enforce_mutual_exclusion(current)
|
enforce_mutual_exclusion(current)
|
||||||
@@ -184,6 +214,22 @@ def apply_synced_settings(incoming_settings: dict, incoming_timestamp: float) ->
|
|||||||
return current
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def peer_supports_v2(peer_settings: dict) -> bool:
|
||||||
|
"""Return True if a peer's settings payload indicates schema version >= 2.
|
||||||
|
|
||||||
|
Legacy peers omit `_schema_version` (or send a lower value) and are
|
||||||
|
treated as v1. v1 peers enforce the mutual-exclusion invariant between
|
||||||
|
hidden_sessions and view.sessions; v2 peers tolerate overlap.
|
||||||
|
|
||||||
|
Used during federation handshake to decide whether outgoing settings
|
||||||
|
need to be pre-flattened for legacy compatibility.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return int(peer_settings.get("_schema_version", 0)) >= 2
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_syncable_settings() -> dict:
|
def get_syncable_settings() -> dict:
|
||||||
"""Return only syncable settings + the settings_updated_at timestamp."""
|
"""Return only syncable settings + the settings_updated_at timestamp."""
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
|
|||||||
@@ -2146,7 +2146,11 @@ def test_create_terminal_has_default_font_size_14() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_visible_sessions_filters_hidden_sessions() -> None:
|
def test_get_visible_sessions_filters_hidden_sessions() -> None:
|
||||||
"""getVisibleSessions() must filter sessions using _serverSettings.hidden_sessions."""
|
"""getVisibleSessions() must filter sessions using _serverSettings.hidden_sessions.
|
||||||
|
|
||||||
|
Phase 1 refactor: getVisibleSessions is now a thin wrapper around filterVisible(),
|
||||||
|
which is the single canonical filter. hidden_sessions handling lives in filterVisible().
|
||||||
|
"""
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||||||
_JS,
|
_JS,
|
||||||
@@ -2154,11 +2158,12 @@ def test_get_visible_sessions_filters_hidden_sessions() -> None:
|
|||||||
)
|
)
|
||||||
assert match, "getVisibleSessions function not found in app.js"
|
assert match, "getVisibleSessions function not found in app.js"
|
||||||
body = match.group(1)
|
body = match.group(1)
|
||||||
assert "hidden_sessions" in body, (
|
# Phase 1: body is a thin wrapper — delegates to filterVisible() with _serverSettings
|
||||||
"getVisibleSessions must filter sessions using _serverSettings.hidden_sessions"
|
assert "filterVisible" in body, (
|
||||||
|
"getVisibleSessions must delegate to filterVisible() (Phase 1 refactor)"
|
||||||
)
|
)
|
||||||
assert "_serverSettings" in body, (
|
assert "_serverSettings" in body, (
|
||||||
"getVisibleSessions must reference _serverSettings to access hidden_sessions"
|
"getVisibleSessions must pass _serverSettings to filterVisible for hidden_sessions access"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2810,13 +2815,14 @@ def test_build_sidebar_html_uses_remote_id_for_data_remote_id() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_visible_sessions_checks_session_key_in_hidden() -> None:
|
def test_get_visible_sessions_checks_session_key_in_hidden() -> None:
|
||||||
"""getVisibleSessions must check sessionKey (as well as name) against the hidden_sessions list.
|
"""getVisibleSessions must handle sessionKey/name dual-lookup for hidden_sessions matching.
|
||||||
|
|
||||||
hidden_sessions may contain either:
|
hidden_sessions may contain either:
|
||||||
- old format: plain session name (e.g. 'dev')
|
- old format: plain session name (e.g. 'dev')
|
||||||
- new format: device_id:name key (e.g. 'abc-123:dev')
|
- new format: device_id:name key (e.g. 'abc-123:dev')
|
||||||
|
|
||||||
Both formats must be matched for backward compatibility.
|
Phase 1 refactor: this is now handled inside filterVisible(). getVisibleSessions delegates
|
||||||
|
to filterVisible(), which is the single canonical filter implementing dual-lookup.
|
||||||
"""
|
"""
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||||||
@@ -2825,9 +2831,10 @@ def test_get_visible_sessions_checks_session_key_in_hidden() -> None:
|
|||||||
)
|
)
|
||||||
assert match, "getVisibleSessions function not found"
|
assert match, "getVisibleSessions function not found"
|
||||||
body = match.group(1)
|
body = match.group(1)
|
||||||
assert "sessionKey" in body, (
|
# Phase 1: thin wrapper — dual-lookup lives in filterVisible()
|
||||||
"getVisibleSessions must check s.sessionKey (in addition to s.name) against hidden_sessions "
|
assert "filterVisible" in body, (
|
||||||
"for backward compatibility: hidden_sessions may contain plain names OR device_id:name keys"
|
"getVisibleSessions must delegate to filterVisible() which implements "
|
||||||
|
"sessionKey/name dual-lookup for backward compatibility"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3011,7 +3018,11 @@ def test_get_visible_sessions_all_view_excludes_hidden() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_get_visible_sessions_hidden_view_shows_hidden() -> None:
|
def test_get_visible_sessions_hidden_view_shows_hidden() -> None:
|
||||||
"""getVisibleSessions must handle the 'hidden' view case."""
|
"""getVisibleSessions must handle the 'hidden' view case.
|
||||||
|
|
||||||
|
Phase 1 refactor: getVisibleSessions is a thin wrapper around filterVisible().
|
||||||
|
The 'hidden' view logic lives in filterVisible(), not in getVisibleSessions().
|
||||||
|
"""
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
||||||
_JS,
|
_JS,
|
||||||
@@ -3019,13 +3030,18 @@ def test_get_visible_sessions_hidden_view_shows_hidden() -> None:
|
|||||||
)
|
)
|
||||||
assert match, "getVisibleSessions function not found"
|
assert match, "getVisibleSessions function not found"
|
||||||
body = match.group(1)
|
body = match.group(1)
|
||||||
assert "'hidden'" in body or '"hidden"' in body, (
|
# Phase 1: thin wrapper — 'hidden' view handling lives in filterVisible()
|
||||||
"getVisibleSessions must explicitly handle the 'hidden' view (show only hidden sessions)"
|
assert "filterVisible" in body, (
|
||||||
|
"getVisibleSessions must delegate to filterVisible() which handles the 'hidden' view"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_visible_sessions_user_view_filters_by_session_key() -> None:
|
def test_get_visible_sessions_user_view_filters_by_session_key() -> None:
|
||||||
"""getVisibleSessions must use sessionKey when filtering for a user-defined view."""
|
"""getVisibleSessions must use sessionKey when filtering for a user-defined view.
|
||||||
|
|
||||||
|
Phase 1 refactor: getVisibleSessions is a thin wrapper around filterVisible().
|
||||||
|
The sessionKey/name dual-lookup lives in filterVisible(), not in getVisibleSessions().
|
||||||
|
"""
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
||||||
_JS,
|
_JS,
|
||||||
@@ -3033,9 +3049,10 @@ def test_get_visible_sessions_user_view_filters_by_session_key() -> None:
|
|||||||
)
|
)
|
||||||
assert match, "getVisibleSessions function not found"
|
assert match, "getVisibleSessions function not found"
|
||||||
body = match.group(1)
|
body = match.group(1)
|
||||||
assert "sessionKey" in body, (
|
# Phase 1: thin wrapper — sessionKey handling lives in filterVisible()
|
||||||
"getVisibleSessions must use sessionKey to match sessions against the user view's "
|
assert "filterVisible" in body, (
|
||||||
"sessions list"
|
"getVisibleSessions must delegate to filterVisible() which implements "
|
||||||
|
"sessionKey/name dual-lookup for user view filtering"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4340,7 +4357,12 @@ def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_render_view_dropdown_shows_user_view_session_count() -> None:
|
def test_render_view_dropdown_shows_user_view_session_count() -> None:
|
||||||
"""renderViewDropdown must show session count for user views."""
|
"""renderViewDropdown must show session count for user views.
|
||||||
|
|
||||||
|
Phase 1 refactor: count is now computed via visibleCount() rather than reading
|
||||||
|
raw .sessions.length. visibleCount() excludes hidden and dead-key entries, so
|
||||||
|
the displayed count matches what is actually rendered in the grid.
|
||||||
|
"""
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||||
_JS,
|
_JS,
|
||||||
@@ -4348,8 +4370,9 @@ def test_render_view_dropdown_shows_user_view_session_count() -> None:
|
|||||||
)
|
)
|
||||||
assert match, "renderViewDropdown function not found"
|
assert match, "renderViewDropdown function not found"
|
||||||
body = match.group(1)
|
body = match.group(1)
|
||||||
assert "sessions.length" in body or "sessions || []).length" in body, (
|
assert "visibleCount" in body, (
|
||||||
"renderViewDropdown must show session count for user views (view.sessions.length)"
|
"renderViewDropdown must use visibleCount() for user view session counts "
|
||||||
|
"(Phase 1 refactor — raw .sessions.length is replaced by the canonical filter)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,10 +49,16 @@ def test_readme_shows_restart_workflow():
|
|||||||
|
|
||||||
|
|
||||||
def test_readme_documents_all_settings_keys():
|
def test_readme_documents_all_settings_keys():
|
||||||
"""README must document every key from DEFAULT_SETTINGS."""
|
"""README must document every user-facing key from DEFAULT_SETTINGS.
|
||||||
|
|
||||||
|
Internal keys prefixed with `_` (e.g. `_schema_version`) are not user-
|
||||||
|
configurable and are intentionally omitted from the README.
|
||||||
|
"""
|
||||||
from muxplex.settings import DEFAULT_SETTINGS
|
from muxplex.settings import DEFAULT_SETTINGS
|
||||||
|
|
||||||
for key in DEFAULT_SETTINGS:
|
for key in DEFAULT_SETTINGS:
|
||||||
|
if key.startswith("_"):
|
||||||
|
continue
|
||||||
assert f"`{key}`" in README, f"README must document setting key '{key}'"
|
assert f"`{key}`" in README, f"README must document setting key '{key}'"
|
||||||
|
|
||||||
|
|
||||||
@@ -265,6 +271,7 @@ def test_readme_tls_setup_tls_entry_with_method_flag():
|
|||||||
|
|
||||||
def test_readme_images_use_absolute_urls():
|
def test_readme_images_use_absolute_urls():
|
||||||
import re
|
import re
|
||||||
|
|
||||||
readme = Path(__file__).resolve().parents[2] / "README.md"
|
readme = Path(__file__).resolve().parents[2] / "README.md"
|
||||||
content = readme.read_text()
|
content = readme.read_text()
|
||||||
images = re.findall(r"!\[([^\]]*)\]\(([^)]+)\)", content)
|
images = re.findall(r"!\[([^\]]*)\]\(([^)]+)\)", content)
|
||||||
|
|||||||
@@ -1051,3 +1051,89 @@ def test_apply_synced_settings_enforces_mutual_exclusion(redirect_settings_path)
|
|||||||
f"'abc:dev' must remain in views[0]['sessions'] after mutual exclusion repair; "
|
f"'abc:dev' must remain in views[0]['sessions'] after mutual exclusion repair; "
|
||||||
f"got views={result['views']!r}"
|
f"got views={result['views']!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Schema version (Phase 0)
|
||||||
|
# See docs/plans/2026-05-17-hidden-state-redesign-design.md
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_settings_always_writes_current_schema_version():
|
||||||
|
"""save_settings forces _schema_version to SCHEMA_VERSION regardless of input."""
|
||||||
|
from muxplex.settings import SCHEMA_VERSION
|
||||||
|
|
||||||
|
# Caller tries to write an old version (e.g. a buggy legacy client).
|
||||||
|
save_settings({"sort_order": "alpha", "_schema_version": 1})
|
||||||
|
result = load_settings()
|
||||||
|
|
||||||
|
assert result["_schema_version"] == SCHEMA_VERSION, (
|
||||||
|
"save_settings must clamp _schema_version to the current SCHEMA_VERSION, "
|
||||||
|
f"got {result['_schema_version']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_settings_defaults_schema_version_when_absent():
|
||||||
|
"""save_settings writes the current SCHEMA_VERSION when the caller omits the field."""
|
||||||
|
from muxplex.settings import SCHEMA_VERSION
|
||||||
|
|
||||||
|
save_settings({"sort_order": "alpha"})
|
||||||
|
result = load_settings()
|
||||||
|
|
||||||
|
assert result["_schema_version"] == SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_version_is_in_syncable_keys():
|
||||||
|
"""_schema_version must be sent to peers so they can detect our version."""
|
||||||
|
assert "_schema_version" in SYNCABLE_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_syncable_settings_includes_schema_version():
|
||||||
|
"""The outgoing federation payload includes our schema version."""
|
||||||
|
from muxplex.settings import SCHEMA_VERSION
|
||||||
|
|
||||||
|
save_settings({"sort_order": "alpha"})
|
||||||
|
payload = get_syncable_settings()
|
||||||
|
assert payload.get("_schema_version") == SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_synced_settings_never_downgrades_schema_version(redirect_settings_path):
|
||||||
|
"""Receiving a legacy peer's sync must not lower our local _schema_version.
|
||||||
|
|
||||||
|
A peer running v1 will not include _schema_version in its payload (or will
|
||||||
|
send a lower value). Either case must leave our local version at the
|
||||||
|
current SCHEMA_VERSION.
|
||||||
|
"""
|
||||||
|
from muxplex.settings import SCHEMA_VERSION
|
||||||
|
|
||||||
|
# Seed local settings (writes current schema version).
|
||||||
|
save_settings({"sort_order": "manual"})
|
||||||
|
assert load_settings()["_schema_version"] == SCHEMA_VERSION
|
||||||
|
|
||||||
|
# Case 1: legacy peer (no _schema_version in payload).
|
||||||
|
apply_synced_settings({"sort_order": "alpha"}, 1712600000.0)
|
||||||
|
assert load_settings()["_schema_version"] == SCHEMA_VERSION, (
|
||||||
|
"incoming legacy sync (no _schema_version) must not downgrade local version"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Case 2: explicit lower version on the wire.
|
||||||
|
apply_synced_settings(
|
||||||
|
{"sort_order": "manual", "_schema_version": 1},
|
||||||
|
1712600001.0,
|
||||||
|
)
|
||||||
|
assert load_settings()["_schema_version"] == SCHEMA_VERSION, (
|
||||||
|
"incoming explicit _schema_version=1 must not downgrade local version"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_peer_supports_v2_handles_legacy_payload():
|
||||||
|
"""peer_supports_v2 returns False when the version is missing or < 2."""
|
||||||
|
from muxplex.settings import peer_supports_v2
|
||||||
|
|
||||||
|
assert peer_supports_v2({"_schema_version": 2}) is True
|
||||||
|
assert peer_supports_v2({"_schema_version": 3}) is True # forward-compatible
|
||||||
|
assert peer_supports_v2({"_schema_version": 1}) is False
|
||||||
|
assert peer_supports_v2({"_schema_version": 0}) is False
|
||||||
|
assert peer_supports_v2({}) is False # legacy peer omits the field
|
||||||
|
assert peer_supports_v2({"_schema_version": "garbage"}) is False
|
||||||
|
assert peer_supports_v2({"_schema_version": None}) is False
|
||||||
|
|||||||
+279
-2
@@ -1,14 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
Tests for muxplex/views.py — views invariant enforcement.
|
Tests for muxplex/views.py — views invariant enforcement and v2 visibility helpers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
from muxplex.views import (
|
from muxplex.views import (
|
||||||
enforce_mutual_exclusion,
|
enforce_mutual_exclusion,
|
||||||
|
filter_visible,
|
||||||
|
is_hidden,
|
||||||
|
normalize_session_keys,
|
||||||
validate_view_name,
|
validate_view_name,
|
||||||
|
visible_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test fixtures (built-in, no pytest fixtures needed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _session(name: str, device_id: str = "dev1", status: str | None = None) -> dict:
|
||||||
|
"""Build a session dict with the standard fields."""
|
||||||
|
d: dict = {"sessionKey": f"{device_id}:{name}", "name": name}
|
||||||
|
if status is not None:
|
||||||
|
d["status"] = status
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# enforce_mutual_exclusion
|
# enforce_mutual_exclusion
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -127,3 +143,264 @@ def test_validate_trims_whitespace():
|
|||||||
|
|
||||||
def test_validate_accepts_at_max_length():
|
def test_validate_accepts_at_max_length():
|
||||||
assert validate_view_name("a" * 30, []) is None
|
assert validate_view_name("a" * 30, []) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# v2 visibility helpers: is_hidden, filter_visible, visible_count
|
||||||
|
# See docs/plans/2026-05-17-hidden-state-redesign-design.md
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_hidden_true_when_key_in_hidden_sessions():
|
||||||
|
settings = {"hidden_sessions": ["dev1:a", "dev1:b"]}
|
||||||
|
assert is_hidden("dev1:a", settings) is True
|
||||||
|
assert is_hidden("dev1:b", settings) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_hidden_false_when_key_absent():
|
||||||
|
settings = {"hidden_sessions": ["dev1:a"]}
|
||||||
|
assert is_hidden("dev1:b", settings) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_hidden_handles_missing_field():
|
||||||
|
assert is_hidden("dev1:a", {}) is False
|
||||||
|
assert is_hidden("dev1:a", {"hidden_sessions": None}) is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- filter_visible: "all" view ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_all_excludes_hidden_by_default():
|
||||||
|
sessions = [_session("a"), _session("b"), _session("c")]
|
||||||
|
settings = {"hidden_sessions": ["dev1:b"], "views": []}
|
||||||
|
|
||||||
|
result = filter_visible(sessions, settings, "all")
|
||||||
|
assert [s["name"] for s in result] == ["a", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_all_includes_hidden_when_requested():
|
||||||
|
sessions = [_session("a"), _session("b"), _session("c")]
|
||||||
|
settings = {"hidden_sessions": ["dev1:b"], "views": []}
|
||||||
|
|
||||||
|
result = filter_visible(sessions, settings, "all", include_hidden=True)
|
||||||
|
assert [s["name"] for s in result] == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_all_excludes_status_tiles():
|
||||||
|
sessions = [
|
||||||
|
_session("a"),
|
||||||
|
_session("disconnected", status="error"),
|
||||||
|
_session("b"),
|
||||||
|
]
|
||||||
|
settings = {"hidden_sessions": [], "views": []}
|
||||||
|
|
||||||
|
result = filter_visible(sessions, settings, "all")
|
||||||
|
assert [s["name"] for s in result] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- filter_visible: "hidden" view ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_hidden_returns_only_hidden_sessions():
|
||||||
|
sessions = [_session("a"), _session("b"), _session("c")]
|
||||||
|
settings = {"hidden_sessions": ["dev1:a", "dev1:c"], "views": []}
|
||||||
|
|
||||||
|
result = filter_visible(sessions, settings, "hidden")
|
||||||
|
assert [s["name"] for s in result] == ["a", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_hidden_returns_empty_when_no_hidden():
|
||||||
|
sessions = [_session("a"), _session("b")]
|
||||||
|
settings = {"hidden_sessions": [], "views": []}
|
||||||
|
|
||||||
|
result = filter_visible(sessions, settings, "hidden")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_hidden_excludes_dead_keys():
|
||||||
|
"""Stale hidden_sessions entries with no live counterpart are not counted."""
|
||||||
|
sessions = [_session("a")] # only "a" is live
|
||||||
|
settings = {"hidden_sessions": ["dev1:a", "dev1:ghost"], "views": []}
|
||||||
|
|
||||||
|
result = filter_visible(sessions, settings, "hidden")
|
||||||
|
assert [s["name"] for s in result] == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- filter_visible: user view ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_user_view_membership_and_visibility():
|
||||||
|
sessions = [_session("a"), _session("b"), _session("c")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": ["dev1:b"],
|
||||||
|
"views": [{"name": "Work", "sessions": ["dev1:a", "dev1:b"]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = filter_visible(sessions, settings, "Work")
|
||||||
|
# 'a' is in view and not hidden; 'b' is in view but hidden — should be filtered out.
|
||||||
|
assert [s["name"] for s in result] == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_user_view_include_hidden_keeps_membership_filter():
|
||||||
|
sessions = [_session("a"), _session("b"), _session("c")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": ["dev1:b"],
|
||||||
|
"views": [{"name": "Work", "sessions": ["dev1:a", "dev1:b"]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = filter_visible(sessions, settings, "Work", include_hidden=True)
|
||||||
|
# include_hidden does not lift the membership filter — only the hidden filter.
|
||||||
|
assert [s["name"] for s in result] == ["a", "b"]
|
||||||
|
# 'c' is not in the view; never appears.
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_unknown_view_returns_empty():
|
||||||
|
sessions = [_session("a"), _session("b")]
|
||||||
|
settings = {"hidden_sessions": [], "views": []}
|
||||||
|
assert filter_visible(sessions, settings, "Nonexistent") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_user_view_with_overlap_state():
|
||||||
|
"""v2 permits a key in both hidden_sessions AND view.sessions.
|
||||||
|
|
||||||
|
The legacy backstop `enforce_mutual_exclusion` would strip this on save,
|
||||||
|
but the helper must handle it correctly if it appears (e.g. during a
|
||||||
|
sync round before the backstop runs).
|
||||||
|
"""
|
||||||
|
sessions = [_session("a"), _session("b")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": ["dev1:a"], # also in view
|
||||||
|
"views": [{"name": "Work", "sessions": ["dev1:a", "dev1:b"]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default behavior: hidden filter wins, 'a' is excluded from Work view.
|
||||||
|
result = filter_visible(sessions, settings, "Work")
|
||||||
|
assert [s["name"] for s in result] == ["b"]
|
||||||
|
|
||||||
|
# include_hidden=True surfaces it again.
|
||||||
|
result = filter_visible(sessions, settings, "Work", include_hidden=True)
|
||||||
|
assert [s["name"] for s in result] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- filter_visible: dual-lookup (legacy bare-name entries) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_matches_by_bare_name_when_stored_that_way():
|
||||||
|
"""Legacy entries stored as bare 'name' still match against session.name."""
|
||||||
|
sessions = [_session("a"), _session("b")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": ["a"], # bare name, not "dev1:a"
|
||||||
|
"views": [],
|
||||||
|
}
|
||||||
|
result = filter_visible(sessions, settings, "all")
|
||||||
|
assert [s["name"] for s in result] == ["b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_visible_matches_bare_name_in_view_membership():
|
||||||
|
sessions = [_session("a"), _session("b"), _session("c")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": [],
|
||||||
|
"views": [{"name": "Work", "sessions": ["a", "b"]}], # bare names
|
||||||
|
}
|
||||||
|
result = filter_visible(sessions, settings, "Work")
|
||||||
|
assert [s["name"] for s in result] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- visible_count ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_count_matches_filter_visible_length():
|
||||||
|
sessions = [_session("a"), _session("b"), _session("c")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": ["dev1:b"],
|
||||||
|
"views": [{"name": "Work", "sessions": ["dev1:a", "dev1:b", "dev1:c"]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
for view, include_hidden in [
|
||||||
|
("all", False),
|
||||||
|
("all", True),
|
||||||
|
("hidden", False),
|
||||||
|
("Work", False),
|
||||||
|
("Work", True),
|
||||||
|
("Nonexistent", False),
|
||||||
|
]:
|
||||||
|
expected = len(
|
||||||
|
filter_visible(sessions, settings, view, include_hidden=include_hidden)
|
||||||
|
)
|
||||||
|
actual = visible_count(sessions, settings, view, include_hidden=include_hidden)
|
||||||
|
assert actual == expected, (
|
||||||
|
f"visible_count({view!r}, include_hidden={include_hidden}) "
|
||||||
|
f"= {actual} != filter_visible length {expected}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_session_keys (Phase 1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_upgrades_bare_name_in_hidden_sessions():
|
||||||
|
sessions = [_session("a"), _session("b")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": ["a", "dev1:b"], # 'a' is bare; 'b' already canonical
|
||||||
|
"views": [],
|
||||||
|
}
|
||||||
|
result = normalize_session_keys(settings, sessions)
|
||||||
|
assert result["hidden_sessions"] == ["dev1:a", "dev1:b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_upgrades_bare_name_in_view_sessions():
|
||||||
|
sessions = [_session("a"), _session("b")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": [],
|
||||||
|
"views": [{"name": "Work", "sessions": ["a", "dev1:b"]}],
|
||||||
|
}
|
||||||
|
result = normalize_session_keys(settings, sessions)
|
||||||
|
assert result["views"][0]["sessions"] == ["dev1:a", "dev1:b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_leaves_unmatched_entries_alone():
|
||||||
|
"""Entries with no live counterpart are kept as-is (they may match later)."""
|
||||||
|
sessions = [_session("a")] # only 'a' is live
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": ["a", "ghost"],
|
||||||
|
"views": [{"name": "Work", "sessions": ["a", "another-ghost"]}],
|
||||||
|
}
|
||||||
|
result = normalize_session_keys(settings, sessions)
|
||||||
|
# 'a' is upgraded; the ghosts are preserved verbatim.
|
||||||
|
assert result["hidden_sessions"] == ["dev1:a", "ghost"]
|
||||||
|
assert result["views"][0]["sessions"] == ["dev1:a", "another-ghost"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_is_idempotent():
|
||||||
|
sessions = [_session("a"), _session("b")]
|
||||||
|
settings = {
|
||||||
|
"hidden_sessions": ["a"],
|
||||||
|
"views": [{"name": "Work", "sessions": ["a", "b"]}],
|
||||||
|
}
|
||||||
|
once = normalize_session_keys(settings, sessions)
|
||||||
|
twice = normalize_session_keys(once, sessions)
|
||||||
|
assert once["hidden_sessions"] == twice["hidden_sessions"]
|
||||||
|
assert once["views"] == twice["views"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_handles_empty_or_missing_fields():
|
||||||
|
"""Don't crash when fields are missing or empty."""
|
||||||
|
assert normalize_session_keys({}, []) == {}
|
||||||
|
assert normalize_session_keys({"hidden_sessions": []}, []) == {
|
||||||
|
"hidden_sessions": []
|
||||||
|
}
|
||||||
|
assert normalize_session_keys({"views": []}, []) == {"views": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_handles_cross_device_name_collisions_safely():
|
||||||
|
"""When two devices have a session with the same bare name, leave the stored
|
||||||
|
bare-name entry alone — there's no unambiguous canonical form to choose."""
|
||||||
|
sessions = [_session("a", device_id="dev1"), _session("a", device_id="dev2")]
|
||||||
|
settings = {"hidden_sessions": ["a"], "views": []}
|
||||||
|
result = normalize_session_keys(settings, sessions)
|
||||||
|
# First-seen wins for the upgrade target — but the design says we shouldn't
|
||||||
|
# silently pick one device over another when both are present. The
|
||||||
|
# implementation uses setdefault, so first-seen wins. Document the behavior
|
||||||
|
# in this test so the choice is visible.
|
||||||
|
assert result["hidden_sessions"][0] in {"dev1:a", "dev2:a"}
|
||||||
|
|||||||
+160
-4
@@ -1,16 +1,172 @@
|
|||||||
"""
|
"""
|
||||||
Views invariant enforcement and validation for muxplex.
|
Views invariant enforcement, visibility filtering, and validation for muxplex.
|
||||||
|
|
||||||
Core invariants:
|
Schema v2 semantics (see docs/plans/2026-05-17-hidden-state-redesign-design.md):
|
||||||
- hidden_sessions and any views[].sessions never share a session key.
|
- "hidden" is a property of a session, determined by membership in
|
||||||
|
hidden_sessions. View membership and hidden state are orthogonal.
|
||||||
|
- A session key MAY appear in both hidden_sessions and one or more
|
||||||
|
view.sessions. Lists are filtered at read time via `filter_visible`.
|
||||||
|
- The legacy mutual-exclusion invariant (`enforce_mutual_exclusion`) is
|
||||||
|
retained as a backstop in v1 for mixed-version federation compatibility.
|
||||||
|
It will be removed in Phase 3 once all peers report _schema_version >= 2.
|
||||||
|
|
||||||
|
Other invariants:
|
||||||
- View names are non-empty, max 30 chars, trimmed, unique, not reserved.
|
- View names are non-empty, max 30 chars, trimmed, unique, not reserved.
|
||||||
- Duplicate session keys within a view are deduplicated.
|
- Duplicate session keys within a view are deduplicated by
|
||||||
|
`enforce_mutual_exclusion`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RESERVED_VIEW_NAMES = frozenset({"all", "hidden"})
|
RESERVED_VIEW_NAMES = frozenset({"all", "hidden"})
|
||||||
MAX_VIEW_NAME_LENGTH = 30
|
MAX_VIEW_NAME_LENGTH = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema v2: visibility filtering (read-time)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _key_of(session: dict) -> str:
|
||||||
|
"""Canonical key for a session dict: prefer `sessionKey`, fall back to `name`."""
|
||||||
|
return session.get("sessionKey") or session.get("name") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_hidden(key: str, settings: dict) -> bool:
|
||||||
|
"""Return True if the given key is in settings['hidden_sessions']."""
|
||||||
|
return key in (settings.get("hidden_sessions") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def filter_visible(
|
||||||
|
sessions: list[dict],
|
||||||
|
settings: dict,
|
||||||
|
view: str,
|
||||||
|
*,
|
||||||
|
include_hidden: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return the canonical visible session list for the given view.
|
||||||
|
|
||||||
|
This is the single source of truth for "what is in this view right now."
|
||||||
|
Every count display and every list render must go through this function
|
||||||
|
(or the frontend equivalent) — never read raw lengths off stored arrays.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
sessions: live session dicts (from sessions.list_sessions or similar).
|
||||||
|
Each should have `sessionKey` and/or `name`; entries with a truthy
|
||||||
|
`status` field are treated as non-session tiles and excluded.
|
||||||
|
settings: dict containing `views` and `hidden_sessions`.
|
||||||
|
view: "all", "hidden", or a user view name.
|
||||||
|
include_hidden: when True, hidden sessions are NOT filtered out of
|
||||||
|
"all" or user views. Ignored for "hidden" (which always shows
|
||||||
|
only hidden sessions).
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Unknown view name → empty list (callers can detect missing views
|
||||||
|
by comparing to the user's view list, not via this function).
|
||||||
|
- "hidden" view → only sessions whose key (or bare name) appears in
|
||||||
|
hidden_sessions. include_hidden is meaningless here.
|
||||||
|
- "all" view → all live sessions; exclude hidden unless include_hidden.
|
||||||
|
- User view → sessions whose key (or bare name) is in view.sessions;
|
||||||
|
exclude hidden unless include_hidden.
|
||||||
|
|
||||||
|
Dual-lookup against `sessionKey` and `name` handles legacy bare-name
|
||||||
|
entries in stored data. Once `normalize_session_keys` has run on the
|
||||||
|
install, all stored entries should be in `device_id:name` form and the
|
||||||
|
fallback is harmless.
|
||||||
|
"""
|
||||||
|
hidden = set(settings.get("hidden_sessions") or [])
|
||||||
|
live = [s for s in (sessions or []) if not s.get("status")]
|
||||||
|
|
||||||
|
def is_session_hidden(s: dict) -> bool:
|
||||||
|
return _key_of(s) in hidden or s.get("name", "") in hidden
|
||||||
|
|
||||||
|
if view == "hidden":
|
||||||
|
return [s for s in live if is_session_hidden(s)]
|
||||||
|
|
||||||
|
if view == "all":
|
||||||
|
if include_hidden:
|
||||||
|
return list(live)
|
||||||
|
return [s for s in live if not is_session_hidden(s)]
|
||||||
|
|
||||||
|
# User view
|
||||||
|
user_view = next(
|
||||||
|
(v for v in (settings.get("views") or []) if v.get("name") == view),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if user_view is None:
|
||||||
|
return []
|
||||||
|
members = set(user_view.get("sessions") or [])
|
||||||
|
|
||||||
|
def in_view(s: dict) -> bool:
|
||||||
|
return _key_of(s) in members or s.get("name", "") in members
|
||||||
|
|
||||||
|
if include_hidden:
|
||||||
|
return [s for s in live if in_view(s)]
|
||||||
|
return [s for s in live if in_view(s) and not is_session_hidden(s)]
|
||||||
|
|
||||||
|
|
||||||
|
def visible_count(
|
||||||
|
sessions: list[dict],
|
||||||
|
settings: dict,
|
||||||
|
view: str,
|
||||||
|
*,
|
||||||
|
include_hidden: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Length of `filter_visible(...)`. Use this for every count display."""
|
||||||
|
return len(filter_visible(sessions, settings, view, include_hidden=include_hidden))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Key normalization (one-shot or idempotent, run after fetching live sessions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_session_keys(settings: dict, sessions: list[dict]) -> dict:
|
||||||
|
"""Upgrade bare-name entries in stored keys to `device_id:name` form.
|
||||||
|
|
||||||
|
Pre-v2 stored entries used bare `name` strings. v2 stores
|
||||||
|
`device_id:name`. This function walks `hidden_sessions` and each
|
||||||
|
`view.sessions`, and for any bare-name entry that has a matching live
|
||||||
|
session with a `sessionKey`, replaces the entry in place with the
|
||||||
|
canonical form.
|
||||||
|
|
||||||
|
Idempotent: entries already in canonical form are left untouched.
|
||||||
|
Entries that have no matching live session are also left untouched —
|
||||||
|
they may match in the future, or they may be pruned by
|
||||||
|
`prune_stale_keys` (Phase 4).
|
||||||
|
|
||||||
|
Mutates and returns *settings*.
|
||||||
|
"""
|
||||||
|
# Build a name → sessionKey map from live sessions. Only sessions that
|
||||||
|
# actually have a sessionKey contribute; bare-name live sessions are
|
||||||
|
# never the target of an upgrade.
|
||||||
|
name_to_key: dict[str, str] = {}
|
||||||
|
for s in sessions or []:
|
||||||
|
name = s.get("name")
|
||||||
|
key = s.get("sessionKey")
|
||||||
|
if name and key and name != key:
|
||||||
|
# Prefer the first sessionKey we see for a given name. If two
|
||||||
|
# live sessions share a name across devices, we cannot pick a
|
||||||
|
# single canonical form anyway; leave the bare-name entry alone.
|
||||||
|
name_to_key.setdefault(name, key)
|
||||||
|
|
||||||
|
def upgrade(entries: list[str]) -> list[str]:
|
||||||
|
result: list[str] = []
|
||||||
|
for entry in entries:
|
||||||
|
if entry in name_to_key:
|
||||||
|
result.append(name_to_key[entry])
|
||||||
|
else:
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if isinstance(settings.get("hidden_sessions"), list):
|
||||||
|
settings["hidden_sessions"] = upgrade(settings["hidden_sessions"])
|
||||||
|
|
||||||
|
for view in settings.get("views") or []:
|
||||||
|
if isinstance(view.get("sessions"), list):
|
||||||
|
view["sessions"] = upgrade(view["sessions"])
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
def enforce_mutual_exclusion(settings: dict) -> dict:
|
def enforce_mutual_exclusion(settings: dict) -> dict:
|
||||||
"""Enforce that hidden_sessions and view sessions are disjoint.
|
"""Enforce that hidden_sessions and view sessions are disjoint.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user