make the appType allow-list a prop instead of fourteen copies
Every locked screen shipped the same recursive normaliser: an ALLOWED_APP_TYPES set, a
normalizeLayout, a useMemo to apply it before the wrong panel could render, and a useEffect to
persist the fix. Fourteen copies, character-for-character identical except the two names — so a
fifteenth screen was a copy-paste, and a bug in the shape was a bug in fourteen places.
It is now `<WorkspaceView appTypes={{ allowed, fallback }} />`. WorkspaceView normalises before it
renders and persists the diff itself, which is the same two effects the screens were writing by hand.
One deliberate behaviour change: the framework normaliser drops `config` when it replaces an app.
The fourteen copies did `{ ...node, appType: fallback }`, keeping the old app's config on the panel
the new app now owns. That is the opposite of what `setApp` does, and a config belongs to whoever
wrote it.
Headscale keeps a local useMemo. Its check is not "is this appType allowed" but "is the server
picker present at all" — a layout saved before that panel existed is discarded for the default
wholesale. That is about a panel being missing, which the allow-list cannot see.
QrTransfer gains a persist-back it never had: it normalised on read and threw the result away every
time.
Tests: normalizeLayout is pinned on reference-identity for a no-op, null always allowed, config
dropped on replacement, rebuilding only changed branches, and idempotence — because a normaliser
that does not normalise to itself makes the persist-back an infinite write loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
@@ -13,38 +12,16 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// resolves to the first collection inside the panels rather than by redirecting, because until the
|
// resolves to the first collection inside the panels rather than by redirecting, because until the
|
||||||
// collection list has loaded there is no canonical URL to redirect to.
|
// collection list has loaded there is no canonical URL to redirect to.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['calendar-nav', 'calendar-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'calendar-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CalendarScreen = () => {
|
export const CalendarScreen = () => {
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/calendar', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/calendar', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['calendar-nav', 'calendar-view'], fallback: 'calendar-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import type { LayoutNode, SelectedSession } from 'officerdev';
|
import type { LayoutNode, SelectedSession } from 'officerdev';
|
||||||
import { WorkspaceView, chatListPath, cwdFromSplat } from 'officerdev';
|
import { WorkspaceView, chatListPath, cwdFromSplat } from 'officerdev';
|
||||||
@@ -11,28 +11,9 @@ import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
|||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
// Allowed panel app types for the /chat screen
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['chat-session-list', 'chat-detail', null]);
|
|
||||||
|
|
||||||
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
||||||
const CHAT_TAIL = 20;
|
const CHAT_TAIL = 20;
|
||||||
|
|
||||||
/** Recursively fix any panel that uses a wrong app type (e.g. officerdev/chat) */
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
if (!ALLOWED_APP_TYPES.has(node.appType)) {
|
|
||||||
return { ...node, appType: 'chat-detail' };
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionListPageProps = {
|
type SessionListPageProps = {
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
};
|
};
|
||||||
@@ -45,25 +26,13 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
const selectedRef = useRef(selected);
|
const selectedRef = useRef(selected);
|
||||||
selectedRef.current = selected;
|
selectedRef.current = selected;
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
// A layout persisted before the chat panels were renamed still names `officerdev/chat`, which no
|
||||||
|
// longer resolves; `appTypes` lands anything unknown on the detail panel.
|
||||||
|
const workspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
||||||
|
|
||||||
// Normalize synchronously so the wrong panel never renders
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
// Persist the fix to the backend
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
// Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests
|
// Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests
|
||||||
// the address bar verbatim — so one left over from before the path-based groups sits there forever,
|
// the address bar verbatim — so one left over from before the path-based groups sits there forever,
|
||||||
// looking like it means something. On a bare /chat it still says which group you wanted, so upgrade
|
// looking like it means something. On a bare /chat it still says which group you wanted, so upgrade
|
||||||
@@ -133,6 +102,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
locked
|
locked
|
||||||
|
appTypes={{ allowed: ['chat-session-list', 'chat-detail'], fallback: 'chat-detail' }}
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onMobilePanelChange={(id) => {
|
onMobilePanelChange={(id) => {
|
||||||
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
|
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
@@ -7,38 +6,16 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected
|
// /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected
|
||||||
// collection is a query param rather than a route segment.
|
// collection is a query param rather than a route segment.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['contacts-nav', 'contacts-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'contacts-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContactsScreen = () => {
|
export const ContactsScreen = () => {
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/contacts', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/contacts', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['contacts-nav', 'contacts-view'], fallback: 'contacts-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { Navigate, useParams } from 'react-router';
|
import { Navigate, useParams } from 'react-router';
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView, DEFAULT_GITEA_SECTION, giteaSectionPath, isGiteaSection } from 'officerdev';
|
import { WorkspaceView, DEFAULT_GITEA_SECTION, giteaSectionPath, isGiteaSection } from 'officerdev';
|
||||||
@@ -13,35 +12,9 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// between themselves over a channel. This screen backs both /gitea and /gitea/:section and is the single
|
// between themselves over a channel. This screen backs both /gitea and /gitea/:section and is the single
|
||||||
// place that decides what an absent or bogus section means.
|
// place that decides what an absent or bogus section means.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['gitea-nav', 'gitea-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'gitea-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GiteaScreen = () => {
|
export const GiteaScreen = () => {
|
||||||
const { section, owner, name } = useParams();
|
const { section, owner, name } = useParams();
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/gitea', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/gitea', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
// Bare /gitea, or a section that doesn't exist, resolves to a canonical URL rather than rendering a default
|
// Bare /gitea, or a section that doesn't exist, resolves to a canonical URL rather than rendering a default
|
||||||
// while the address bar says something else — the nav highlight is derived from the URL.
|
// while the address bar says something else — the nav highlight is derived from the URL.
|
||||||
@@ -55,7 +28,11 @@ export const GiteaScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['gitea-nav', 'gitea-view'], fallback: 'gitea-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,20 +15,6 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the
|
// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the
|
||||||
// single place that decides what an absent or bogus section means.
|
// single place that decides what an absent or bogus section means.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['headscale-servers', 'headscale-nav', 'headscale-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'headscale-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAppType(node: LayoutNode, appType: string): boolean {
|
function hasAppType(node: LayoutNode, appType: string): boolean {
|
||||||
if (node.type === 'panel') return node.appType === appType;
|
if (node.type === 'panel') return node.appType === appType;
|
||||||
return node.children.some((c) => hasAppType(c.node, appType));
|
return node.children.some((c) => hasAppType(c.node, appType));
|
||||||
@@ -38,15 +24,13 @@ export const HeadscaleScreen = () => {
|
|||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
|
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
|
||||||
|
|
||||||
|
// A layout saved before the server picker existed has no panel for it, and nothing else would ever add
|
||||||
|
// one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is
|
||||||
|
// cheaper than a screen permanently missing a panel. Pinning the app types is `appTypes` below; this is
|
||||||
|
// the part the framework can't do, because it is about a panel that is *missing* rather than wrong.
|
||||||
const workspace = useMemo(() => {
|
const workspace = useMemo(() => {
|
||||||
// A layout saved before the server picker existed has no panel for it, and nothing else would ever add
|
if (hasAppType(rawWorkspace.value, 'headscale-servers')) return rawWorkspace;
|
||||||
// one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is
|
return { ...rawWorkspace, value: defaultLayout };
|
||||||
// cheaper than a screen permanently missing a panel.
|
|
||||||
const fixed = hasAppType(rawWorkspace.value, 'headscale-servers')
|
|
||||||
? normalizeLayout(rawWorkspace.value)
|
|
||||||
: defaultLayout;
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
}, [rawWorkspace]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,7 +48,14 @@ export const HeadscaleScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{
|
||||||
|
allowed: ['headscale-servers', 'headscale-nav', 'headscale-view'],
|
||||||
|
fallback: 'headscale-view',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { Navigate, useParams } from 'react-router';
|
import { Navigate, useParams } from 'react-router';
|
||||||
import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, WorkspaceView } from 'officerdev';
|
import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, WorkspaceView } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
@@ -14,35 +13,9 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// params. So the panels read the URL rather than passing state to each other, and this screen is the single
|
// params. So the panels read the URL rather than passing state to each other, and this screen is the single
|
||||||
// place that decides what an absent or bogus section means.
|
// place that decides what an absent or bogus section means.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['invoices-nav', 'invoices-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'invoices-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InvoicesScreen = () => {
|
export const InvoicesScreen = () => {
|
||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/invoices', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/invoices', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
// Bare /invoices, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
// Bare /invoices, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||||
// default while the address bar says something else — the nav highlight is derived from the URL.
|
// default while the address bar says something else — the nav highlight is derived from the URL.
|
||||||
@@ -52,7 +25,11 @@ export const InvoicesScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['invoices-nav', 'invoices-view'], fallback: 'invoices-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { Navigate, useParams } from 'react-router';
|
import { Navigate, useParams } from 'react-router';
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView, DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from 'officerdev';
|
import { WorkspaceView, DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from 'officerdev';
|
||||||
@@ -16,35 +15,9 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// The open section is :section in the URL; which library, which item and what is playing are in the query
|
// The open section is :section in the URL; which library, which item and what is playing are in the query
|
||||||
// string. Nothing about "what is open" lives in a panel channel.
|
// string. Nothing about "what is open" lives in a panel channel.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['jellyfin-nav', 'jellyfin-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'jellyfin-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const JellyfinScreen = () => {
|
export const JellyfinScreen = () => {
|
||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/jellyfin', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/jellyfin', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
// Bare /jellyfin, or a section that doesn't exist, canonicalises rather than rendering a default behind a
|
// Bare /jellyfin, or a section that doesn't exist, canonicalises rather than rendering a default behind a
|
||||||
// URL that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
|
// URL that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
|
||||||
@@ -54,7 +27,11 @@ export const JellyfinScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['jellyfin-nav', 'jellyfin-view'], fallback: 'jellyfin-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
@@ -7,38 +6,16 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
|
// /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser
|
||||||
// (music-browser) and the content/detail (music-detail) — coordinating via the 'music:cwd' channel.
|
// (music-browser) and the content/detail (music-detail) — coordinating via the 'music:cwd' channel.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['music-browser', 'music-detail', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'music-detail' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MusicScreen = () => {
|
export const MusicScreen = () => {
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/music', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['music-browser', 'music-detail'], fallback: 'music-detail' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { Navigate, useParams } from 'react-router';
|
import { Navigate, useParams } from 'react-router';
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView, DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from 'officerdev';
|
import { WorkspaceView, DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from 'officerdev';
|
||||||
@@ -12,35 +11,9 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// The open section is :section in the URL; deeper selection (which asset, album, person, search) is in the
|
// The open section is :section in the URL; deeper selection (which asset, album, person, search) is in the
|
||||||
// query string. Nothing about "what is open" lives in a panel channel.
|
// query string. Nothing about "what is open" lives in a panel channel.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['photos-nav', 'photos-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'photos-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PhotosScreen = () => {
|
export const PhotosScreen = () => {
|
||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/photos', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/photos', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
// Bare /photos, or a section that doesn't exist, canonicalises rather than rendering a default behind a URL
|
// Bare /photos, or a section that doesn't exist, canonicalises rather than rendering a default behind a URL
|
||||||
// that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
|
// that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
|
||||||
@@ -50,7 +23,11 @@ export const PhotosScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['photos-nav', 'photos-view'], fallback: 'photos-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
@@ -14,27 +13,14 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// Both panels are pure client-side: nothing about a transfer reaches the server, which is the point.
|
// Both panels are pure client-side: nothing about a transfer reaches the server, which is the point.
|
||||||
// The receiver needs a secure origin for camera access — over the tailnet with HTTPS that is satisfied.
|
// The receiver needs a secure origin for camera access — over the tailnet with HTTPS that is satisfied.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['qr-send', 'qr-receive', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'qr-send' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
return children.some((c, i) => c !== node.children[i]) ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QrTransferScreen = () => {
|
export const QrTransferScreen = () => {
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/qr-transfer', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/qr-transfer', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
return (
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
<WorkspaceView
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
workspace={workspace}
|
||||||
return { ...rawWorkspace, value: fixed };
|
locked
|
||||||
}, [rawWorkspace]);
|
appTypes={{ allowed: ['qr-send', 'qr-receive'], fallback: 'qr-send' }}
|
||||||
|
/>
|
||||||
return <WorkspaceView workspace={workspace} locked />;
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
@@ -8,40 +7,18 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section'
|
// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section'
|
||||||
// channel. Both talk to slskd through the /api/slskd auth proxy.
|
// channel. Both talk to slskd through the /api/slskd auth proxy.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['soulseek-nav', 'soulseek-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'soulseek-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SoulseekScreen = () => {
|
export const SoulseekScreen = () => {
|
||||||
// v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
|
// v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
|
||||||
// layout doesn't resurrect as two mismatched panels.
|
// layout doesn't resurrect as two mismatched panels.
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/soulseek-v2', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/soulseek-v2', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['soulseek-nav', 'soulseek-view'], fallback: 'soulseek-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
@@ -7,38 +6,16 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// /system-monitor uses the Workspace/Panel system (like /music): a horizontal split with an (empty for
|
// /system-monitor uses the Workspace/Panel system (like /music): a horizontal split with an (empty for
|
||||||
// now) left panel and the system snapshot on the right.
|
// now) left panel and the system snapshot on the right.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['monitor-side', 'monitor-main', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'monitor-main' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SystemMonitorScreen = () => {
|
export const SystemMonitorScreen = () => {
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/system-monitor', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/system-monitor', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['monitor-side', 'monitor-main'], fallback: 'monitor-main' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { Navigate, useParams } from 'react-router';
|
import { Navigate, useParams } from 'react-router';
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import {
|
import {
|
||||||
@@ -19,35 +18,9 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// backs both /transmission and /transmission/:section and is the single place that decides what an absent or
|
// backs both /transmission and /transmission/:section and is the single place that decides what an absent or
|
||||||
// bogus section means.
|
// bogus section means.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['transmission-nav', 'transmission-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'transmission-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TransmissionScreen = () => {
|
export const TransmissionScreen = () => {
|
||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/transmission', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/transmission', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
// Bare /transmission, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
// Bare /transmission, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||||
// default while the address bar says something else — the nav highlight is derived from the URL.
|
// default while the address bar says something else — the nav highlight is derived from the URL.
|
||||||
@@ -57,7 +30,11 @@ export const TransmissionScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['transmission-nav', 'transmission-view'], fallback: 'transmission-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { Navigate, useLocation, useParams } from 'react-router';
|
import { Navigate, useLocation, useParams } from 'react-router';
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection } from 'officerdev';
|
import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection } from 'officerdev';
|
||||||
@@ -15,36 +14,10 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
// themselves over a channel. This screen backs both /wallet and /wallet/:section and is the single place
|
// themselves over a channel. This screen backs both /wallet and /wallet/:section and is the single place
|
||||||
// that decides what an absent or bogus section means.
|
// that decides what an absent or bogus section means.
|
||||||
|
|
||||||
const ALLOWED_APP_TYPES = new Set<string | null>(['wallet-nav', 'wallet-view', null]);
|
|
||||||
|
|
||||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
||||||
if (node.type === 'panel') {
|
|
||||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'wallet-view' };
|
|
||||||
}
|
|
||||||
const children = node.children.map((c) => {
|
|
||||||
const fixed = normalizeLayout(c.node);
|
|
||||||
return fixed === c.node ? c : { ...c, node: fixed };
|
|
||||||
});
|
|
||||||
const changed = children.some((c, i) => c !== node.children[i]);
|
|
||||||
return changed ? { ...node, children } : node;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WalletScreen = () => {
|
export const WalletScreen = () => {
|
||||||
const { section } = useParams();
|
const { section } = useParams();
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/wallet', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/wallet', defaultLayout);
|
||||||
|
|
||||||
const workspace = useMemo(() => {
|
|
||||||
const fixed = normalizeLayout(rawWorkspace.value);
|
|
||||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
||||||
return { ...rawWorkspace, value: fixed };
|
|
||||||
}, [rawWorkspace]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
||||||
rawWorkspace.setValue(workspace.value);
|
|
||||||
}
|
|
||||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
||||||
|
|
||||||
// Bare /wallet, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
// Bare /wallet, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||||
// default while the address bar says something else — the nav highlight is derived from the URL. The
|
// default while the address bar says something else — the nav highlight is derived from the URL. The
|
||||||
@@ -56,7 +29,11 @@ export const WalletScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} locked />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['wallet-nav', 'wallet-view'], fallback: 'wallet-view' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useIsMobile } from 'hooks/useIsMobile';
|
|||||||
import { useSessionState } from 'hooks/useSessionState';
|
import { useSessionState } from 'hooks/useSessionState';
|
||||||
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
|
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
|
||||||
import type { DropPosition } from './layout-utils';
|
import type { DropPosition } from './layout-utils';
|
||||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs, findPanelApp } from './layout-utils';
|
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs, findPanelApp, normalizeLayout } from './layout-utils';
|
||||||
import { WorkspaceProvider } from './WorkspaceContext';
|
import { WorkspaceProvider } from './WorkspaceContext';
|
||||||
import { parseWorkspaceKey } from './workspace-identity';
|
import { parseWorkspaceKey } from './workspace-identity';
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
@@ -14,6 +14,17 @@ import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
|||||||
type WorkspaceViewProps = {
|
type WorkspaceViewProps = {
|
||||||
workspace: DashboardState;
|
workspace: DashboardState;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
/**
|
||||||
|
* Pin every panel to one of these app types, replacing anything else with `fallback` and writing the
|
||||||
|
* repair back. A locked screen should always pass this: its layout is persisted user state that
|
||||||
|
* outlives the code, and an appType the screen no longer renders becomes an empty box the user cannot
|
||||||
|
* get out of, because a locked screen has no app picker.
|
||||||
|
*
|
||||||
|
* This used to be fourteen character-identical `normalizeLayout` functions in fourteen screens, each
|
||||||
|
* with its own `useMemo` and persist-back `useEffect` — which is how nine screens ended up with no
|
||||||
|
* guard at all and one with a guard that re-ran forever because it never wrote the correction back.
|
||||||
|
*/
|
||||||
|
appTypes?: { allowed: readonly string[]; fallback: string };
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
root?: string;
|
root?: string;
|
||||||
components?: PanelComponents;
|
components?: PanelComponents;
|
||||||
@@ -24,12 +35,24 @@ type WorkspaceViewProps = {
|
|||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
export const WorkspaceView = ({ workspace, locked, cwd = '~', root, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
|
export const WorkspaceView = ({ workspace, locked, appTypes, cwd = '~', root, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
|
||||||
const { registry } = useAppRegistry();
|
const { registry } = useAppRegistry();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const layout = workspace.value;
|
// Deliberately not memoised. `normalizeLayout` returns its input by reference when nothing needed
|
||||||
|
// fixing, so the identity everything downstream depends on is already stable; a `useMemo` here would
|
||||||
|
// only add a dependency array to get wrong, and the callers pass a fresh object literal for `appTypes`
|
||||||
|
// anyway, which would defeat it. The walk is a handful of nodes.
|
||||||
|
const layout = appTypes ? normalizeLayout(workspace.value, appTypes.allowed, appTypes.fallback) : workspace.value;
|
||||||
const onLayoutChange = workspace.setValue;
|
const onLayoutChange = workspace.setValue;
|
||||||
|
|
||||||
|
// Write the repair back, so a layout is fixed once rather than re-fixed on every mount for the rest of
|
||||||
|
// its life. Terminates because the normalised tree normalises to itself.
|
||||||
|
const rawLayout = workspace.value;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspace.isLoaded || layout === rawLayout) return;
|
||||||
|
onLayoutChange(layout);
|
||||||
|
}, [workspace.isLoaded, layout, rawLayout, onLayoutChange]);
|
||||||
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
||||||
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
||||||
// Per tab and per dashboard: maximising the chat panel and refreshing should come back maximised, while
|
// Per tab and per dashboard: maximising the chat panel and refreshing should come back maximised, while
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export {
|
|||||||
setPanelConfig,
|
setPanelConfig,
|
||||||
collectPanelConfigs,
|
collectPanelConfigs,
|
||||||
findPanelApp,
|
findPanelApp,
|
||||||
|
normalizeLayout,
|
||||||
} from './layout-utils';
|
} from './layout-utils';
|
||||||
export type { WorkspaceIdentity } from './workspace-identity';
|
export type { WorkspaceIdentity } from './workspace-identity';
|
||||||
export { parseWorkspaceKey } from './workspace-identity';
|
export { parseWorkspaceKey } from './workspace-identity';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
findPanelApp,
|
findPanelApp,
|
||||||
hasAnyApp,
|
hasAnyApp,
|
||||||
movePanel,
|
movePanel,
|
||||||
|
normalizeLayout,
|
||||||
pruneEmptyPanels,
|
pruneEmptyPanels,
|
||||||
removePanel,
|
removePanel,
|
||||||
setApp,
|
setApp,
|
||||||
@@ -548,3 +549,67 @@ describe('findPanelApp', () => {
|
|||||||
expect(findPanelApp(panel('only', 'chat'), 'only')).toBe('chat');
|
expect(findPanelApp(panel('only', 'chat'), 'only')).toBe('chat');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The guard that keeps a locked screen renderable. It runs against persisted state that outlives the code
|
||||||
|
// that wrote it, so the reference-equality contract is the load-bearing part: it is what tells the caller
|
||||||
|
// whether there is a repair worth writing back, and a normaliser that always returned a new tree would
|
||||||
|
// PATCH the server on every mount of every screen.
|
||||||
|
describe('normalizeLayout', () => {
|
||||||
|
const ALLOWED = ['nav', 'view'];
|
||||||
|
|
||||||
|
test('returns the same tree by reference when nothing needs fixing', () => {
|
||||||
|
const root = group('g', 'horizontal', [panel('p1', 'nav'), panel('p2', 'view')]);
|
||||||
|
expect(normalizeLayout(root, ALLOWED, 'view')).toBe(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves empty panels alone — null is always allowed', () => {
|
||||||
|
const root = group('g', 'horizontal', [panel('p1'), panel('p2', 'view')]);
|
||||||
|
expect(normalizeLayout(root, ALLOWED, 'view')).toBe(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces an app the screen does not know with the fallback', () => {
|
||||||
|
const root = group('g', 'horizontal', [panel('p1', 'renamed-away'), panel('p2', 'view')]);
|
||||||
|
|
||||||
|
const next = normalizeLayout(root, ALLOWED, 'view');
|
||||||
|
|
||||||
|
expect(next).not.toBe(root);
|
||||||
|
expect(find(next, 'p1')!.appType).toBe('view');
|
||||||
|
// The panel keeps its id and its position; only the app it names changes.
|
||||||
|
expect(ids(next)).toEqual(['p1', 'p2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops the config with the app that owned it', () => {
|
||||||
|
// The same rule `setApp` follows: a config belongs to whichever app wrote it, and the fallback would
|
||||||
|
// read the previous occupant's settings as its own.
|
||||||
|
const root = group('g', 'horizontal', [panel('p1', 'gone', { config: { agentName: 'frontend' } })]);
|
||||||
|
|
||||||
|
const fixed = find(normalizeLayout(root, ALLOWED, 'view'), 'p1')!;
|
||||||
|
|
||||||
|
expect(fixed.appType).toBe('view');
|
||||||
|
expect('config' in fixed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixes nested panels and rebuilds only the branches that changed', () => {
|
||||||
|
const clean = group('h', 'vertical', [panel('p1', 'nav'), panel('p2', 'view')]);
|
||||||
|
const root = group('g', 'horizontal', [clean, group('k', 'vertical', [panel('p3', 'stale')])]);
|
||||||
|
|
||||||
|
const next = normalizeLayout(root, ALLOWED, 'view') as LayoutGroup;
|
||||||
|
|
||||||
|
expect(find(next, 'p3')!.appType).toBe('view');
|
||||||
|
// The untouched branch is carried through by reference, which is what keeps its panels mounted.
|
||||||
|
expect(next.children[0]!.node).toBe(clean);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a normalised tree normalises to itself — the persist-back cannot loop', () => {
|
||||||
|
const root = group('g', 'horizontal', [panel('p1', 'stale'), panel('p2', 'view')]);
|
||||||
|
|
||||||
|
const once = normalizeLayout(root, ALLOWED, 'view');
|
||||||
|
expect(normalizeLayout(once, ALLOWED, 'view')).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles a bare panel as the whole tree', () => {
|
||||||
|
expect((normalizeLayout(panel('only', 'stale'), ALLOWED, 'view') as LayoutPanel).appType).toBe('view');
|
||||||
|
const ok = panel('only', 'nav');
|
||||||
|
expect(normalizeLayout(ok, ALLOWED, 'view')).toBe(ok);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -132,6 +132,36 @@ export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]):
|
|||||||
return { ...root, children: newChildren };
|
return { ...root, children: newChildren };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin every panel in a tree to an app the screen actually knows how to render.
|
||||||
|
*
|
||||||
|
* A locked screen's layout is persisted user state, so it outlives the code that wrote it: rename an
|
||||||
|
* appType, drop a panel from a screen, restore an old row, and the stored tree still names something the
|
||||||
|
* screen has no answer for. `PanelSlot` renders that as an empty bordered box with no picker — and on a
|
||||||
|
* locked screen there is no way for the user to get out of it.
|
||||||
|
*
|
||||||
|
* Returns the input by reference when nothing needed fixing, which is what lets the caller tell "this
|
||||||
|
* layout was already fine" from "this layout was repaired and should be written back".
|
||||||
|
*
|
||||||
|
* `null` — an empty panel — is always allowed. It is a state of the framework rather than an app, every
|
||||||
|
* hand-rolled copy of this allowed it, and a screen that forgot to would rewrite each of its empty panels
|
||||||
|
* to the fallback on first load.
|
||||||
|
*/
|
||||||
|
export function normalizeLayout(node: LayoutNode, allowed: readonly string[], fallback: string): LayoutNode {
|
||||||
|
if (node.type === 'panel') {
|
||||||
|
if (node.appType === null || allowed.includes(node.appType)) return node;
|
||||||
|
// The config goes with the app that owned it. Whatever is in there was written by the app this panel
|
||||||
|
// is no longer running, and handing it to the fallback is the same mistake `setApp` avoids.
|
||||||
|
const { config: _drop, ...rest } = node;
|
||||||
|
return { ...rest, appType: fallback };
|
||||||
|
}
|
||||||
|
const children = node.children.map((child) => {
|
||||||
|
const fixed = normalizeLayout(child.node, allowed, fallback);
|
||||||
|
return fixed === child.node ? child : { ...child, node: fixed };
|
||||||
|
});
|
||||||
|
return children.some((c, i) => c !== node.children[i]) ? { ...node, children } : node;
|
||||||
|
}
|
||||||
|
|
||||||
export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
|
export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
|
||||||
if (root.type === 'panel') {
|
if (root.type === 'panel') {
|
||||||
return root.appType ? root : null;
|
return root.appType ? root : null;
|
||||||
|
|||||||
Reference in New Issue
Block a user