@kabmo-labs/kabmo 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-9js9mrmj.js → index-aq0e78gv.js} +704 -704
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +5 -8
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- package/src/server/lib/updates/android-release.ts +0 -183
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The navbar entries plugins contribute, resolved once for both surfaces that
|
|
8
|
+
* can draw them.
|
|
9
|
+
*
|
|
10
|
+
* A phone has ONE row, and it is already shared between the wordmark, the
|
|
11
|
+
* session title and four controls that are used mid-conversation. A plugin
|
|
12
|
+
* entry is therefore the first thing that has to give way — and it gives way by
|
|
13
|
+
* moving into the `⋯` menu, not by shrinking, because a readout squeezed under
|
|
14
|
+
* a control is a readout nobody can read.
|
|
15
|
+
*
|
|
16
|
+
* Two facts decide where an entry goes, and both live here rather than at a
|
|
17
|
+
* call site:
|
|
18
|
+
*
|
|
19
|
+
* - **An ACTION entry (`opensPanel`) can fold.** It has one click and no UI of
|
|
20
|
+
* its own, so a menu row is the same action in words.
|
|
21
|
+
* - **A dropdown or static readout cannot.** A dropdown needs the panel it opens
|
|
22
|
+
* anchored to a trigger, and a static readout has no action at all; folding
|
|
23
|
+
* either would be a different control rather than the same one moved.
|
|
24
|
+
*
|
|
25
|
+
* The threshold is a WIDTH, not a device class — the same rule the header's own
|
|
26
|
+
* reload/sign-out fold follows. Measured on a 390px phone with a `CPU 18% /
|
|
27
|
+
* RAM 2.5G` readout (73px): the session title kept 48px of its 74px and was
|
|
28
|
+
* clipped, while at 430px it kept all of it. 430 is therefore where a plugin
|
|
29
|
+
* action may stand in the row.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { usePanelRegistry, usePanelSlots } from '@/client/hooks/workspace/panel-registry';
|
|
33
|
+
import { useViewportAtLeast } from '@/client/hooks/ui/media-query';
|
|
34
|
+
import { headerDropdownOf, headerOpensPanelOf, headerTriggerOf } from '@/client/lib/plugins/slots';
|
|
35
|
+
import { pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
|
|
36
|
+
import type { ComponentType } from 'preact';
|
|
37
|
+
import type { HeaderDropdownProps, HeaderTriggerProps } from '@kabmo-labs/kabmo-sdk/app';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The narrowest phone row that still holds a plugin action beside the session
|
|
41
|
+
* title. See this module's own doc for the measurement behind it.
|
|
42
|
+
*/
|
|
43
|
+
export const MOBILE_HEADER_ACTION_PX = 430;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The widest a plugin entry may be on a phone.
|
|
47
|
+
*
|
|
48
|
+
* A plugin owns its trigger's markup, so without a ceiling one readout could
|
|
49
|
+
* take the whole row. The cap is the width a two-column CPU/RAM readout needs
|
|
50
|
+
* plus room, and anything past it is the plugin's own problem to shorten — the
|
|
51
|
+
* alternative is the host clipping a control the user cannot then identify.
|
|
52
|
+
*/
|
|
53
|
+
export const MOBILE_HEADER_ENTRY_MAX_PX = 96;
|
|
54
|
+
|
|
55
|
+
export interface HeaderEntry {
|
|
56
|
+
pluginId: string;
|
|
57
|
+
title: string;
|
|
58
|
+
/** What the navbar entry reads. */
|
|
59
|
+
Trigger: ComponentType<HeaderTriggerProps>;
|
|
60
|
+
/** What opening it shows, when the plugin declared one. */
|
|
61
|
+
Dropdown?: ComponentType<HeaderDropdownProps>;
|
|
62
|
+
/** The panel instance key the trigger opens, when it declared `opensPanel`. */
|
|
63
|
+
opensPanelKey?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface HeaderEntries {
|
|
67
|
+
/** Entries that may stand in the row. */
|
|
68
|
+
inline: HeaderEntry[];
|
|
69
|
+
/** ACTION entries the row could not hold, for the `⋯` menu. */
|
|
70
|
+
folded: HeaderEntry[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolve what the row draws, given the layout and the width it has. */
|
|
74
|
+
export function useHeaderEntries(layout: 'desktop' | 'mobile'): HeaderEntries {
|
|
75
|
+
const { panels } = usePanelRegistry();
|
|
76
|
+
const { slots } = usePanelSlots();
|
|
77
|
+
// Hooks are unconditional: a desktop never asks the media query's answer, but
|
|
78
|
+
// calling it only on one branch would change the hook order per layout.
|
|
79
|
+
const wideEnough = useViewportAtLeast(MOBILE_HEADER_ACTION_PX);
|
|
80
|
+
|
|
81
|
+
const all: HeaderEntry[] = [];
|
|
82
|
+
for (const panel of panels) {
|
|
83
|
+
const entry = slots.get(panel.pluginId);
|
|
84
|
+
const Trigger = entry ? headerTriggerOf(entry) : undefined;
|
|
85
|
+
// A plugin whose bundle has not loaded yet has no component; it appears
|
|
86
|
+
// once the import settles rather than as an empty entry now.
|
|
87
|
+
if (!Trigger) continue;
|
|
88
|
+
const Dropdown = entry ? headerDropdownOf(entry) : undefined;
|
|
89
|
+
const opensSlot = entry ? headerOpensPanelOf(entry) : undefined;
|
|
90
|
+
all.push({
|
|
91
|
+
pluginId: panel.pluginId,
|
|
92
|
+
title: entry?.titles.headerPanel ?? panel.name,
|
|
93
|
+
Trigger,
|
|
94
|
+
...(Dropdown ? { Dropdown } : {}),
|
|
95
|
+
// The panel is named by its INSTANCE key, so the click lands on the
|
|
96
|
+
// plugin's own `rightPanel` or `panel` registration — the same key the
|
|
97
|
+
// activity bar, the width map and the dock's placement list use. The
|
|
98
|
+
// plugin never names a column: where it is drawn is the user's choice.
|
|
99
|
+
...(opensSlot ? { opensPanelKey: pluginPanelKey(panel.pluginId, opensSlot) } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (layout === 'desktop' || wideEnough) return { inline: all, folded: [] };
|
|
104
|
+
// A narrow phone: the row keeps only what cannot be expressed as a menu row.
|
|
105
|
+
return {
|
|
106
|
+
inline: all.filter((entry) => !entry.opensPanelKey),
|
|
107
|
+
folded: all.filter((entry) => entry.opensPanelKey),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import type { ComponentType } from 'preact';
|
|
3
|
-
import type { HeaderDropdownProps, HeaderTriggerProps } from '@kabmo-labs/kabmo-sdk/app';
|
|
4
|
-
import { usePanelRegistry, usePanelSlots } from '@/client/hooks/workspace/panel-registry';
|
|
5
|
-
import { headerDropdownOf, headerTriggerOf } from '@/client/lib/plugins/slots';
|
|
6
2
|
import { PluginIdProvider } from '@kabmo-labs/kabmo-ui';
|
|
7
3
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
8
4
|
import { useSessionStateContext } from '@/client/hooks/workspace/session-state/context';
|
|
5
|
+
import { requestOpenPanel } from '@/shared/lib/workspace/panel-open';
|
|
6
|
+
import {
|
|
7
|
+
MOBILE_HEADER_ENTRY_MAX_PX,
|
|
8
|
+
type HeaderEntry,
|
|
9
|
+
} from '@/client/components/common/header-panel-buttons/entries';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
pluginId: string;
|
|
12
|
-
title: string;
|
|
13
|
-
/** What the navbar entry reads. */
|
|
14
|
-
Trigger: ComponentType<HeaderTriggerProps>;
|
|
15
|
-
/** What opening it shows, when the plugin declared one. */
|
|
16
|
-
Dropdown?: ComponentType<HeaderDropdownProps>;
|
|
17
|
-
}
|
|
11
|
+
export { MOBILE_HEADER_ACTION_PX, useHeaderEntries, type HeaderEntry } from '@/client/components/common/header-panel-buttons/entries';
|
|
18
12
|
|
|
19
13
|
/**
|
|
20
14
|
* The navbar entries a plugin's `header` panel contributes — on BOTH layouts.
|
|
@@ -37,39 +31,39 @@ interface HeaderEntry {
|
|
|
37
31
|
* row) and the second would cover the first, so the open one is held here as one
|
|
38
32
|
* id rather than as a flag per entry.
|
|
39
33
|
*/
|
|
40
|
-
export function HeaderPanelButtons({
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
export function HeaderPanelButtons({
|
|
35
|
+
layout = 'desktop',
|
|
36
|
+
entries,
|
|
37
|
+
}: {
|
|
38
|
+
layout?: 'desktop' | 'mobile';
|
|
39
|
+
/**
|
|
40
|
+
* What stands in the row, already resolved.
|
|
41
|
+
*
|
|
42
|
+
* The header resolves the split ONCE (`useHeaderEntries`) because the `⋯`
|
|
43
|
+
* menu draws the other half of the same answer — resolving here as well would
|
|
44
|
+
* be two media queries and two registry reads deciding one row.
|
|
45
|
+
*/
|
|
46
|
+
entries: HeaderEntry[];
|
|
47
|
+
}) {
|
|
48
|
+
const inline = entries;
|
|
43
49
|
const { sessionId } = useSessionStateContext();
|
|
44
50
|
const [openKey, setOpenKey] = useState<string | null>(null);
|
|
45
51
|
|
|
46
|
-
const entries: HeaderEntry[] = [];
|
|
47
|
-
for (const panel of panels) {
|
|
48
|
-
const entry = slots.get(panel.pluginId);
|
|
49
|
-
const Trigger = entry ? headerTriggerOf(entry) : undefined;
|
|
50
|
-
// A plugin whose bundle has not loaded yet has no component; it appears
|
|
51
|
-
// once the import settles rather than as an empty entry now.
|
|
52
|
-
if (!Trigger) continue;
|
|
53
|
-
const Dropdown = entry ? headerDropdownOf(entry) : undefined;
|
|
54
|
-
entries.push({
|
|
55
|
-
pluginId: panel.pluginId,
|
|
56
|
-
title: entry?.titles.headerPanel ?? panel.name,
|
|
57
|
-
Trigger,
|
|
58
|
-
...(Dropdown ? { Dropdown } : {}),
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
52
|
// A plugin that is uninstalled or switched off while its dropdown is open
|
|
63
53
|
// would leave an empty box on screen; closing is the honest answer.
|
|
64
54
|
useEffect(() => {
|
|
65
|
-
if (openKey && !
|
|
66
|
-
}, [openKey,
|
|
55
|
+
if (openKey && !inline.some((entry) => entry.pluginId === openKey)) setOpenKey(null);
|
|
56
|
+
}, [openKey, inline]);
|
|
67
57
|
|
|
68
|
-
|
|
58
|
+
// An entry the row could not hold is NOT lost: it is a row of the `⋯` menu,
|
|
59
|
+
// which `HeaderControls` draws from the same resolver. This component draws
|
|
60
|
+
// only what stands in the row, so nothing here reserves space for a control
|
|
61
|
+
// that moved.
|
|
62
|
+
if (inline.length === 0) return null;
|
|
69
63
|
|
|
70
64
|
return (
|
|
71
65
|
<div className="flex items-center space-x-1">
|
|
72
|
-
{
|
|
66
|
+
{inline.map((entry) => (
|
|
73
67
|
<HeaderPanelEntry
|
|
74
68
|
key={entry.pluginId}
|
|
75
69
|
entry={entry}
|
|
@@ -123,6 +117,48 @@ function HeaderPanelEntry({ entry, layout, sessionId, open, onToggle, onClose }:
|
|
|
123
117
|
</PluginIdProvider>
|
|
124
118
|
);
|
|
125
119
|
|
|
120
|
+
const triggerClass = layout === 'mobile'
|
|
121
|
+
? 'flex items-center gap-1 w-8 h-8 justify-center rounded-lg hover:bg-ink/5 active:bg-ink/10 transition-colors cursor-pointer'
|
|
122
|
+
: 'flex items-center gap-1 px-1.5 py-1 rounded hover:bg-ink/10 transition-colors cursor-pointer';
|
|
123
|
+
/**
|
|
124
|
+
* An ACTION entry's hit area grows with its readout.
|
|
125
|
+
*
|
|
126
|
+
* The fixed 32px square is right for an icon and wrong for two columns of
|
|
127
|
+
* numbers: measured on a 390px phone, the readout inside `w-8 h-8` reported
|
|
128
|
+
* `scrollWidth > clientWidth`, so `1.4G` was clipped by the button that was
|
|
129
|
+
* supposed to present it. The height stays 32px (the phone's tool row) and
|
|
130
|
+
* the width comes from the content, with padding as the only host-imposed
|
|
131
|
+
* number.
|
|
132
|
+
*/
|
|
133
|
+
const actionClass = layout === 'mobile'
|
|
134
|
+
? 'flex items-center gap-1 h-8 px-1.5 justify-center rounded-lg hover:bg-ink/5 active:bg-ink/10 transition-colors cursor-pointer'
|
|
135
|
+
: 'flex items-center gap-1 px-1.5 py-1 rounded hover:bg-ink/10 transition-colors cursor-pointer';
|
|
136
|
+
|
|
137
|
+
// An ACTION entry: the readout is a button that asks the layout to show the
|
|
138
|
+
// plugin's own panel. The event carries the panel key and nothing else —
|
|
139
|
+
// where that panel lives (left dock, right column, editor column, a phone
|
|
140
|
+
// drawer) is the layout's answer, because it is the user's placement.
|
|
141
|
+
if (entry.opensPanelKey) {
|
|
142
|
+
const panelKey = entry.opensPanelKey;
|
|
143
|
+
return (
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() => requestOpenPanel(panelKey)}
|
|
147
|
+
className={`${actionClass} ${open ? 'text-ink' : 'text-ink/70 hover:text-ink'}`}
|
|
148
|
+
// A plugin owns its trigger's markup, so the row needs a ceiling: a
|
|
149
|
+
// readout that grew without one would push the session title and the
|
|
150
|
+
// controls beside it out of a phone header. The cap is generous for a
|
|
151
|
+
// two-column readout and clips anything past it rather than letting the
|
|
152
|
+
// row overflow.
|
|
153
|
+
style={layout === 'mobile' ? { maxWidth: `${MOBILE_HEADER_ENTRY_MAX_PX}px` } : undefined}
|
|
154
|
+
title={entry.title}
|
|
155
|
+
aria-label={entry.title}
|
|
156
|
+
>
|
|
157
|
+
{trigger}
|
|
158
|
+
</button>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
126
162
|
if (!entry.Dropdown) {
|
|
127
163
|
return (
|
|
128
164
|
<div className="flex items-center px-1.5 text-ink/70" title={entry.title}>
|
|
@@ -144,9 +180,6 @@ function HeaderPanelEntry({ entry, layout, sessionId, open, onToggle, onClose }:
|
|
|
144
180
|
right: 'max(0.75rem, env(safe-area-inset-right, 0px))',
|
|
145
181
|
}
|
|
146
182
|
: undefined;
|
|
147
|
-
const triggerClass = layout === 'mobile'
|
|
148
|
-
? 'flex items-center gap-1 w-8 h-8 justify-center rounded-lg hover:bg-ink/5 active:bg-ink/10 transition-colors cursor-pointer'
|
|
149
|
-
: 'flex items-center gap-1 px-1.5 py-1 rounded hover:bg-ink/10 transition-colors cursor-pointer';
|
|
150
183
|
|
|
151
184
|
return (
|
|
152
185
|
<div className="relative" ref={ref}>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The server panel, rendered against a real shell bridge.
|
|
8
|
+
*
|
|
9
|
+
* Three rules are what this suite exists for, and each was a decision rather
|
|
10
|
+
* than a style:
|
|
11
|
+
*
|
|
12
|
+
* 1. **In a browser the control does not exist.** There is no shell to control,
|
|
13
|
+
* and a button that opened onto "not available" would be a button that never
|
|
14
|
+
* works. The panel is rendered with no bridge at all and must produce
|
|
15
|
+
* nothing.
|
|
16
|
+
* 2. **A stopped server cannot be connected to.** The shell probes every URL
|
|
17
|
+
* before it answers, so `running: false` is a fact; the row says `Stopped`
|
|
18
|
+
* and its button is disabled rather than letting a click land on a blank
|
|
19
|
+
* window.
|
|
20
|
+
* 3. **Only a server this shell started offers Stop.** A server found on the
|
|
21
|
+
* machine belongs to whoever started it — `kabmo serve` in a terminal — and a
|
|
22
|
+
* Stop here would kill a process somebody else is watching.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
26
|
+
import { Window } from 'happy-dom';
|
|
27
|
+
import { h, render } from 'preact';
|
|
28
|
+
import { act } from 'preact/test-utils';
|
|
29
|
+
import { ServerSwitcher } from '@/client/components/common/server-switcher';
|
|
30
|
+
import type { DesktopServersState } from '@/client/lib/desktop/bridge';
|
|
31
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
32
|
+
|
|
33
|
+
const state = (overrides: Partial<DesktopServersState> = {}): DesktopServersState => ({
|
|
34
|
+
entries: [],
|
|
35
|
+
detected: [],
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const LOCAL: DesktopServersState = state({
|
|
40
|
+
entries: [
|
|
41
|
+
{ id: 'local:3001', label: 'Local :3001', url: 'http://127.0.0.1:3001', kind: 'managed', port: 3001, running: true, owned: true, version: '0.5.3', mode: 'prod', pid: 42, source: 'stored' },
|
|
42
|
+
{ id: 'remote:https://nas:8443', label: 'NAS', url: 'https://nas:8443', kind: 'remote', running: false, owned: false, source: 'stored' },
|
|
43
|
+
],
|
|
44
|
+
detected: [
|
|
45
|
+
{ id: 'local:3000', label: 'Local :3000', url: 'http://127.0.0.1:3000', kind: 'managed', port: 3000, running: true, owned: false, version: '0.5.3', mode: 'dev', pid: 7, source: 'record' },
|
|
46
|
+
],
|
|
47
|
+
activeId: 'local:3001',
|
|
48
|
+
activeUrl: 'http://127.0.0.1:3001',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let win: Window;
|
|
52
|
+
let container: HTMLElement;
|
|
53
|
+
let calls: Array<{ command: string; args?: Record<string, unknown> }>;
|
|
54
|
+
|
|
55
|
+
function mount(next: DesktopServersState | Error) {
|
|
56
|
+
calls = [];
|
|
57
|
+
win.document.body.innerHTML = '';
|
|
58
|
+
container = win.document.createElement('div') as unknown as HTMLElement;
|
|
59
|
+
win.document.body.appendChild(container as never);
|
|
60
|
+
// The transport the macOS shell installs: `__kabmoCall` answers through the
|
|
61
|
+
// same door the shell uses, which is what makes this a test of the bridge
|
|
62
|
+
// rather than of a stub only this suite knows about.
|
|
63
|
+
const shellWindow = win as unknown as {
|
|
64
|
+
__kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
|
65
|
+
};
|
|
66
|
+
shellWindow.__kabmoCall = async (method, params) => {
|
|
67
|
+
calls.push({ command: method, args: params });
|
|
68
|
+
if (next instanceof Error) throw next;
|
|
69
|
+
return next;
|
|
70
|
+
};
|
|
71
|
+
act(() => {
|
|
72
|
+
render(h(ServerSwitcher, { open: true, onClose: () => {} }), container as never);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function settle() {
|
|
77
|
+
await act(async () => {
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const text = () => (container.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
win = new Window({ url: 'http://127.0.0.1:3001/' });
|
|
87
|
+
installDomGlobals(win);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
if (container) act(() => render(null, container as never));
|
|
92
|
+
restoreDomGlobals();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('ServerSwitcher', () => {
|
|
96
|
+
test('lists the stored servers, the detected ones, and marks the active row', async () => {
|
|
97
|
+
mount(LOCAL);
|
|
98
|
+
await settle();
|
|
99
|
+
expect(text()).toContain('This window');
|
|
100
|
+
expect(text()).toContain('Local :3001');
|
|
101
|
+
expect(text()).toContain('NAS');
|
|
102
|
+
expect(text()).toContain('Found on this machine');
|
|
103
|
+
expect(text()).toContain('Local :3000');
|
|
104
|
+
// The active row carries its own marker rather than a colour alone.
|
|
105
|
+
expect(container.querySelector('[aria-label="Active"]')).not.toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a stopped server is offered as Stopped and cannot be connected to', async () => {
|
|
109
|
+
mount(LOCAL);
|
|
110
|
+
await settle();
|
|
111
|
+
expect(text()).toContain('Stopped');
|
|
112
|
+
const nas = [...container.querySelectorAll('button')].find((button) =>
|
|
113
|
+
(button.textContent ?? '').includes('NAS'),
|
|
114
|
+
);
|
|
115
|
+
expect(nas).toBeDefined();
|
|
116
|
+
expect((nas as HTMLButtonElement).disabled).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('Stop is offered only for a server this shell started', async () => {
|
|
120
|
+
mount(LOCAL);
|
|
121
|
+
await settle();
|
|
122
|
+
const stops = [...container.querySelectorAll('button')].filter((button) =>
|
|
123
|
+
(button.getAttribute('aria-label') ?? '').startsWith('Stop '),
|
|
124
|
+
);
|
|
125
|
+
expect(stops.map((button) => button.getAttribute('aria-label'))).toEqual(['Stop Local :3001']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a refused add shows the shell’s own sentence', async () => {
|
|
129
|
+
mount(new Error("Use the server's root address — a base path is not supported."));
|
|
130
|
+
await settle();
|
|
131
|
+
expect(text()).toContain('root address');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a browser renders nothing, because there is no shell to control', async () => {
|
|
135
|
+
mount(LOCAL);
|
|
136
|
+
delete (win as unknown as { __kabmoCall?: unknown }).__kabmoCall;
|
|
137
|
+
calls = [];
|
|
138
|
+
win.document.body.innerHTML = '';
|
|
139
|
+
const bare = win.document.createElement('div');
|
|
140
|
+
win.document.body.appendChild(bare as never);
|
|
141
|
+
act(() => {
|
|
142
|
+
render(h(ServerSwitcher, { open: true, onClose: () => {} }), bare as never);
|
|
143
|
+
});
|
|
144
|
+
await settle();
|
|
145
|
+
// The panel still draws (it is a component with an explicit `open`), but it
|
|
146
|
+
// must not have issued a single bridge call — that is what would throw in a
|
|
147
|
+
// browser. The navbar is what hides the control entirely.
|
|
148
|
+
expect(calls).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which server the window is on, and how to change it.
|
|
8
|
+
*
|
|
9
|
+
* The panel lists three groups, and the split is the whole design:
|
|
10
|
+
*
|
|
11
|
+
* - **This window** — the servers the shell remembers, with the active one
|
|
12
|
+
* marked. A remote entry is a URL somebody typed; a managed entry is a local
|
|
13
|
+
* server this shell started, which is why it can be stopped here.
|
|
14
|
+
* - **Found on this machine** — servers the shell did NOT start: `kabmo serve`
|
|
15
|
+
* in a terminal, `bun run dev`, a login entry. They are read from the
|
|
16
|
+
* per-port records the servers themselves write, so a server that appeared
|
|
17
|
+
* after the panel opened shows up on the next refresh. They can be connected
|
|
18
|
+
* to but not stopped — this window did not start them, and a Stop here would
|
|
19
|
+
* kill a process somebody else is watching.
|
|
20
|
+
* - **Add a server** — a URL and a label.
|
|
21
|
+
*
|
|
22
|
+
* A server is only offered as connectable while it ANSWERS: the shell probes
|
|
23
|
+
* every URL before answering, so `running: false` is a real fact rather than a
|
|
24
|
+
* guess, and the row says so instead of letting a click fail on a blank window.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useState } from 'preact/hooks';
|
|
28
|
+
import { Check, Loader2, Play, Plus, RefreshCw, Server, Square, Trash2 } from 'lucide-preact';
|
|
29
|
+
import { useDesktopServers } from '@/client/hooks/desktop/servers';
|
|
30
|
+
import type { DesktopServer } from '@/client/lib/desktop/bridge';
|
|
31
|
+
|
|
32
|
+
interface ServerSwitcherProps {
|
|
33
|
+
open: boolean;
|
|
34
|
+
onClose: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function statusLine(server: DesktopServer): string {
|
|
38
|
+
if (!server.running) return 'Stopped';
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
if (server.version) parts.push(`v${server.version}`);
|
|
41
|
+
if (server.mode) parts.push(server.mode);
|
|
42
|
+
if (server.pid) parts.push(`pid ${server.pid}`);
|
|
43
|
+
return parts.length > 0 ? parts.join(' · ') : 'Running';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Row({
|
|
47
|
+
server,
|
|
48
|
+
active,
|
|
49
|
+
busy,
|
|
50
|
+
onActivate,
|
|
51
|
+
onStop,
|
|
52
|
+
onRemove,
|
|
53
|
+
}: {
|
|
54
|
+
server: DesktopServer;
|
|
55
|
+
active: boolean;
|
|
56
|
+
busy: boolean;
|
|
57
|
+
onActivate: () => void;
|
|
58
|
+
onStop?: () => void;
|
|
59
|
+
onRemove?: () => void;
|
|
60
|
+
}) {
|
|
61
|
+
const canConnect = server.running && !active;
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
className={`flex items-center gap-2 rounded-lg border px-2.5 py-2 ${
|
|
65
|
+
active ? 'border-ink/30 bg-ink/[0.04]' : 'border-ink/10'
|
|
66
|
+
}`}
|
|
67
|
+
>
|
|
68
|
+
<span
|
|
69
|
+
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
70
|
+
server.running ? 'bg-success' : 'bg-ink/25'
|
|
71
|
+
}`}
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
/>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={onActivate}
|
|
77
|
+
disabled={busy || !canConnect}
|
|
78
|
+
title={canConnect ? `Switch to ${server.label}` : statusLine(server)}
|
|
79
|
+
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default group"
|
|
80
|
+
>
|
|
81
|
+
<span className="flex items-center gap-1.5">
|
|
82
|
+
<span className="text-xs font-medium text-ink truncate">{server.label}</span>
|
|
83
|
+
{active && <Check size={12} className="text-success flex-shrink-0" aria-label="Active" />}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="block text-[10px] text-ink/50 truncate font-mono">{server.url}</span>
|
|
86
|
+
<span className="block text-[10px] text-ink/40 truncate">{statusLine(server)}</span>
|
|
87
|
+
</button>
|
|
88
|
+
{onStop && server.owned && server.running && (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={onStop}
|
|
92
|
+
disabled={busy}
|
|
93
|
+
title="Stop this local server"
|
|
94
|
+
aria-label={`Stop ${server.label}`}
|
|
95
|
+
className="p-1 rounded text-ink/40 hover:text-error hover:bg-error/10 cursor-pointer disabled:opacity-40"
|
|
96
|
+
>
|
|
97
|
+
<Square size={12} />
|
|
98
|
+
</button>
|
|
99
|
+
)}
|
|
100
|
+
{onRemove && !server.owned && (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={onRemove}
|
|
104
|
+
disabled={busy}
|
|
105
|
+
title="Remove from the list"
|
|
106
|
+
aria-label={`Remove ${server.label}`}
|
|
107
|
+
className="p-1 rounded text-ink/40 hover:text-error hover:bg-error/10 cursor-pointer disabled:opacity-40"
|
|
108
|
+
>
|
|
109
|
+
<Trash2 size={12} />
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function ServerSwitcher({ open, onClose }: ServerSwitcherProps) {
|
|
117
|
+
const store = useDesktopServers(open);
|
|
118
|
+
const [label, setLabel] = useState('');
|
|
119
|
+
const [url, setUrl] = useState('');
|
|
120
|
+
const [adding, setAdding] = useState(false);
|
|
121
|
+
|
|
122
|
+
if (!open) return null;
|
|
123
|
+
|
|
124
|
+
const state = store.state;
|
|
125
|
+
const entries = state?.entries ?? [];
|
|
126
|
+
const detected = state?.detected ?? [];
|
|
127
|
+
const activeId = state?.activeId;
|
|
128
|
+
|
|
129
|
+
const submit = async () => {
|
|
130
|
+
if (!url.trim()) return;
|
|
131
|
+
const ok = await store.add(label.trim(), url.trim());
|
|
132
|
+
if (ok) {
|
|
133
|
+
setLabel('');
|
|
134
|
+
setUrl('');
|
|
135
|
+
setAdding(false);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
role="dialog"
|
|
142
|
+
aria-label="Servers"
|
|
143
|
+
className="absolute right-2 top-full mt-1 z-50 w-[340px] rounded-xl border border-ink/15 bg-paper shadow-xl overflow-hidden"
|
|
144
|
+
onKeyDown={(event) => {
|
|
145
|
+
if (event.key === 'Escape') {
|
|
146
|
+
event.stopPropagation();
|
|
147
|
+
onClose();
|
|
148
|
+
}
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-ink/10">
|
|
152
|
+
<span className="flex items-center gap-1.5 text-xs font-semibold text-ink">
|
|
153
|
+
<Server size={13} /> Servers
|
|
154
|
+
</span>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={() => void store.reload()}
|
|
158
|
+
disabled={store.busy}
|
|
159
|
+
title="Refresh — re-probe every server and look for new local ones"
|
|
160
|
+
aria-label="Refresh servers"
|
|
161
|
+
className="p-1 rounded text-ink/40 hover:text-ink hover:bg-ink/10 cursor-pointer disabled:opacity-40"
|
|
162
|
+
>
|
|
163
|
+
<RefreshCw size={12} className={store.busy ? 'animate-spin' : ''} />
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div className="max-h-[320px] overflow-y-auto scrollbar-overlay-container px-2 py-2 space-y-1.5">
|
|
168
|
+
{!state && (
|
|
169
|
+
<p className="text-[11px] text-ink/50 px-1 py-2 flex items-center gap-1.5">
|
|
170
|
+
<Loader2 size={12} className="animate-spin" /> Reading the server list…
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{entries.length > 0 && (
|
|
175
|
+
<>
|
|
176
|
+
<p className="px-1 text-[9px] uppercase tracking-wide text-ink/35">This window</p>
|
|
177
|
+
{entries.map((server) => (
|
|
178
|
+
<Row
|
|
179
|
+
key={server.id}
|
|
180
|
+
server={server}
|
|
181
|
+
active={server.id === activeId}
|
|
182
|
+
busy={store.busy}
|
|
183
|
+
onActivate={() => void store.activate(server.id)}
|
|
184
|
+
onStop={() => void store.stop(server.id)}
|
|
185
|
+
onRemove={() => void store.remove(server.id)}
|
|
186
|
+
/>
|
|
187
|
+
))}
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{detected.length > 0 && (
|
|
192
|
+
<>
|
|
193
|
+
<p className="px-1 pt-1 text-[9px] uppercase tracking-wide text-ink/35">
|
|
194
|
+
Found on this machine
|
|
195
|
+
</p>
|
|
196
|
+
{detected.map((server) => (
|
|
197
|
+
<Row
|
|
198
|
+
key={server.id}
|
|
199
|
+
server={server}
|
|
200
|
+
active={server.url === state?.activeUrl}
|
|
201
|
+
busy={store.busy}
|
|
202
|
+
onActivate={() => void store.activate(server.id)}
|
|
203
|
+
/>
|
|
204
|
+
))}
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{state && entries.length === 0 && detected.length === 0 && (
|
|
209
|
+
<p className="px-1 py-2 text-[11px] text-ink/50">
|
|
210
|
+
Nothing running yet. Start a local server, or add one by address.
|
|
211
|
+
</p>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{store.error && (
|
|
216
|
+
<p className="px-3 py-1.5 text-[10px] text-error border-t border-ink/10">{store.error}</p>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
<div className="border-t border-ink/10 px-2 py-2 space-y-1.5">
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
onClick={() => void store.start()}
|
|
223
|
+
disabled={store.busy}
|
|
224
|
+
className="w-full flex items-center gap-1.5 rounded-lg border border-ink/15 px-2.5 py-1.5 text-xs text-ink hover:bg-ink/5 cursor-pointer disabled:opacity-40"
|
|
225
|
+
>
|
|
226
|
+
<Play size={12} /> Start a local server
|
|
227
|
+
</button>
|
|
228
|
+
|
|
229
|
+
{adding ? (
|
|
230
|
+
<form
|
|
231
|
+
className="space-y-1.5"
|
|
232
|
+
onSubmit={(event) => {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
void submit();
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
<input
|
|
238
|
+
autoFocus
|
|
239
|
+
value={url}
|
|
240
|
+
onInput={(event) => setUrl(event.currentTarget.value)}
|
|
241
|
+
placeholder="192.168.1.10:3000"
|
|
242
|
+
aria-label="Server address"
|
|
243
|
+
className="w-full rounded-lg border border-ink/20 bg-paper px-2 py-1.5 text-xs text-ink outline-none focus:border-ink/45 font-mono"
|
|
244
|
+
/>
|
|
245
|
+
<input
|
|
246
|
+
value={label}
|
|
247
|
+
onInput={(event) => setLabel(event.currentTarget.value)}
|
|
248
|
+
placeholder="Label (optional)"
|
|
249
|
+
aria-label="Server label"
|
|
250
|
+
className="w-full rounded-lg border border-ink/20 bg-paper px-2 py-1.5 text-xs text-ink outline-none focus:border-ink/45"
|
|
251
|
+
/>
|
|
252
|
+
<div className="flex gap-1.5">
|
|
253
|
+
<button
|
|
254
|
+
type="submit"
|
|
255
|
+
disabled={store.busy || !url.trim()}
|
|
256
|
+
className="flex-1 rounded-lg bg-ink text-paper px-2 py-1.5 text-xs cursor-pointer disabled:opacity-40"
|
|
257
|
+
>
|
|
258
|
+
Add
|
|
259
|
+
</button>
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
onClick={() => setAdding(false)}
|
|
263
|
+
className="rounded-lg border border-ink/15 px-2 py-1.5 text-xs text-ink/70 hover:bg-ink/5 cursor-pointer"
|
|
264
|
+
>
|
|
265
|
+
Cancel
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
</form>
|
|
269
|
+
) : (
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
onClick={() => setAdding(true)}
|
|
273
|
+
className="w-full flex items-center gap-1.5 rounded-lg border border-ink/15 px-2.5 py-1.5 text-xs text-ink hover:bg-ink/5 cursor-pointer"
|
|
274
|
+
>
|
|
275
|
+
<Plus size={12} /> Add a server
|
|
276
|
+
</button>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|