@mohou/panel 1.0.20
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/README.md +8 -0
- package/lib/types/chrome/hue.d.ts +3 -0
- package/lib/types/chrome/hue.d.ts.map +1 -0
- package/lib/types/gallery/card-style.d.ts +8 -0
- package/lib/types/gallery/card-style.d.ts.map +1 -0
- package/lib/types/gallery/client.d.ts +20 -0
- package/lib/types/gallery/client.d.ts.map +1 -0
- package/lib/types/gallery/desk.d.ts +16 -0
- package/lib/types/gallery/desk.d.ts.map +1 -0
- package/lib/types/gallery/list.d.ts +37 -0
- package/lib/types/gallery/list.d.ts.map +1 -0
- package/lib/types/gallery/sort.d.ts +24 -0
- package/lib/types/gallery/sort.d.ts.map +1 -0
- package/lib/types/gallery/state.d.ts +90 -0
- package/lib/types/gallery/state.d.ts.map +1 -0
- package/lib/types/gallery/tabs.d.ts +45 -0
- package/lib/types/gallery/tabs.d.ts.map +1 -0
- package/lib/types/gallery/view.d.ts +57 -0
- package/lib/types/gallery/view.d.ts.map +1 -0
- package/lib/types/history/client.d.ts +25 -0
- package/lib/types/history/client.d.ts.map +1 -0
- package/lib/types/history/state.d.ts +39 -0
- package/lib/types/history/state.d.ts.map +1 -0
- package/lib/types/history/view.d.ts +15 -0
- package/lib/types/history/view.d.ts.map +1 -0
- package/lib/types/index.d.ts +24 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/labels.d.ts +267 -0
- package/lib/types/labels.d.ts.map +1 -0
- package/lib/types/settings/author-mcp-view.d.ts +15 -0
- package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
- package/lib/types/settings/author-mcp.d.ts +4 -0
- package/lib/types/settings/author-mcp.d.ts.map +1 -0
- package/lib/types/settings/client.d.ts +198 -0
- package/lib/types/settings/client.d.ts.map +1 -0
- package/lib/types/settings/credentials-view.d.ts +15 -0
- package/lib/types/settings/credentials-view.d.ts.map +1 -0
- package/lib/types/settings/form.d.ts +27 -0
- package/lib/types/settings/form.d.ts.map +1 -0
- package/lib/types/settings/install-row.d.ts +10 -0
- package/lib/types/settings/install-row.d.ts.map +1 -0
- package/lib/types/settings/mcp-view.d.ts +14 -0
- package/lib/types/settings/mcp-view.d.ts.map +1 -0
- package/lib/types/settings/skill-dirs.d.ts +6 -0
- package/lib/types/settings/skill-dirs.d.ts.map +1 -0
- package/lib/types/settings/skill-install.d.ts +13 -0
- package/lib/types/settings/skill-install.d.ts.map +1 -0
- package/lib/types/settings/state.d.ts +94 -0
- package/lib/types/settings/state.d.ts.map +1 -0
- package/lib/types/settings/update-dialog.d.ts +56 -0
- package/lib/types/settings/update-dialog.d.ts.map +1 -0
- package/lib/types/settings/view.d.ts +26 -0
- package/lib/types/settings/view.d.ts.map +1 -0
- package/lib/types/storage/client.d.ts +15 -0
- package/lib/types/storage/client.d.ts.map +1 -0
- package/lib/types/storage/state.d.ts +42 -0
- package/lib/types/storage/state.d.ts.map +1 -0
- package/lib/types/storage/view.d.ts +16 -0
- package/lib/types/storage/view.d.ts.map +1 -0
- package/lib/types/surface/state.d.ts +42 -0
- package/lib/types/surface/state.d.ts.map +1 -0
- package/lib/types/surface/view.d.ts +34 -0
- package/lib/types/surface/view.d.ts.map +1 -0
- package/lib/types/theme/client.d.ts +45 -0
- package/lib/types/theme/client.d.ts.map +1 -0
- package/lib/types/theme/menu.d.ts +25 -0
- package/lib/types/theme/menu.d.ts.map +1 -0
- package/lib/types/theme/state.d.ts +46 -0
- package/lib/types/theme/state.d.ts.map +1 -0
- package/lib/types/theme/view.d.ts +16 -0
- package/lib/types/theme/view.d.ts.map +1 -0
- package/lib/types/ui/dialog.d.ts +12 -0
- package/lib/types/ui/dialog.d.ts.map +1 -0
- package/lib/types/ui/tooltip.d.ts +12 -0
- package/lib/types/ui/tooltip.d.ts.map +1 -0
- package/package.json +38 -0
- package/src/chrome/hue.ts +6 -0
- package/src/gallery/card-style.ts +27 -0
- package/src/gallery/client.ts +26 -0
- package/src/gallery/desk.tsx +54 -0
- package/src/gallery/list.ts +54 -0
- package/src/gallery/sort.ts +72 -0
- package/src/gallery/state.ts +229 -0
- package/src/gallery/tabs.ts +85 -0
- package/src/gallery/view.tsx +501 -0
- package/src/history/client.ts +27 -0
- package/src/history/state.ts +90 -0
- package/src/history/view.tsx +131 -0
- package/src/index.ts +61 -0
- package/src/labels.ts +536 -0
- package/src/settings/author-mcp-view.tsx +154 -0
- package/src/settings/author-mcp.ts +24 -0
- package/src/settings/client.ts +205 -0
- package/src/settings/credentials-view.tsx +195 -0
- package/src/settings/form.ts +27 -0
- package/src/settings/install-row.tsx +35 -0
- package/src/settings/mcp-view.tsx +793 -0
- package/src/settings/skill-dirs.ts +41 -0
- package/src/settings/skill-install.tsx +134 -0
- package/src/settings/state.ts +225 -0
- package/src/settings/update-dialog.tsx +207 -0
- package/src/settings/view.tsx +722 -0
- package/src/storage/client.ts +16 -0
- package/src/storage/state.ts +94 -0
- package/src/storage/view.tsx +190 -0
- package/src/surface/state.ts +72 -0
- package/src/surface/view.tsx +255 -0
- package/src/theme/client.ts +34 -0
- package/src/theme/menu.tsx +286 -0
- package/src/theme/state.ts +113 -0
- package/src/theme/view.tsx +73 -0
- package/src/ui/dialog.tsx +25 -0
- package/src/ui/tooltip.tsx +19 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { WorkbenchLibrary, workbenchEntries } from '@mohou/app-view'
|
|
2
|
+
import { ArrowUpRight, Palette, Pin, RefreshCw, Search, Settings, Trash2 } from 'lucide-react'
|
|
3
|
+
import { cloneElement, isValidElement, useEffect, useLayoutEffect, useReducer, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { PanelLabelMode, PanelLocale } from '../labels.ts'
|
|
6
|
+
import { panelText } from '../labels.ts'
|
|
7
|
+
import { Dialog } from '../ui/dialog.tsx'
|
|
8
|
+
import { Tooltip } from '../ui/tooltip.tsx'
|
|
9
|
+
import { readCardStyle, writeCardStyle } from './card-style.ts'
|
|
10
|
+
import { DeskBar } from './desk.tsx'
|
|
11
|
+
import type { PanelClient } from './client.ts'
|
|
12
|
+
import { galleryCardStyles, isGalleryCardStyle, type GalleryCardStyle, type GalleryKind } from './list.ts'
|
|
13
|
+
import {
|
|
14
|
+
loadGallery,
|
|
15
|
+
loadTrash,
|
|
16
|
+
galleryState,
|
|
17
|
+
reduceGallery,
|
|
18
|
+
viewKind,
|
|
19
|
+
visibleApps,
|
|
20
|
+
type GalleryAction,
|
|
21
|
+
type PanelShell,
|
|
22
|
+
type GalleryState,
|
|
23
|
+
} from './state.ts'
|
|
24
|
+
|
|
25
|
+
/** What settings needs from the gallery. Card style is panel-local. */
|
|
26
|
+
export interface GalleryChrome {
|
|
27
|
+
readonly cardStyle: GalleryCardStyle
|
|
28
|
+
setCard(style: GalleryCardStyle): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ThemeSlot = ReactNode | ((app: { readonly id: string; readonly title: string } | undefined) => ReactNode)
|
|
32
|
+
|
|
33
|
+
function themeNode(theme: ThemeSlot | undefined, app: { readonly id: string; readonly title: string } | undefined): ReactNode {
|
|
34
|
+
if (typeof theme === 'function') return theme(app)
|
|
35
|
+
return theme
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Gallery and tabs. The frame contents are injected. This package does not name a host route.
|
|
40
|
+
* @param props - client, chrome locale, and who embeds the panel
|
|
41
|
+
*/
|
|
42
|
+
export function PanelGallery(props: {
|
|
43
|
+
readonly client: PanelClient
|
|
44
|
+
readonly locale: PanelLocale
|
|
45
|
+
readonly mode: PanelLabelMode
|
|
46
|
+
readonly shell: PanelShell
|
|
47
|
+
readonly frame?: (appId: string) => ReactNode
|
|
48
|
+
readonly focus?: { readonly appId: string; readonly title?: string }
|
|
49
|
+
readonly onClosePanel?: () => void
|
|
50
|
+
readonly tools?: ReactNode
|
|
51
|
+
readonly themeOpen?: boolean
|
|
52
|
+
readonly onToggleTheme?: () => void
|
|
53
|
+
readonly theme?: ThemeSlot
|
|
54
|
+
readonly onToggleSettings?: () => void
|
|
55
|
+
readonly overlay?: (chrome: GalleryChrome) => ReactNode
|
|
56
|
+
readonly defaultWorkbenchId?: string
|
|
57
|
+
readonly onSetDefaultWorkbench?: (id: string) => void
|
|
58
|
+
}): ReactNode {
|
|
59
|
+
const [state, dispatch] = useReducer(reduceGallery, props.shell, (shell: PanelShell) => ({
|
|
60
|
+
...galleryState(shell),
|
|
61
|
+
cardStyle: readCardStyle(),
|
|
62
|
+
}))
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
void loadGallery(props.client, dispatch)
|
|
65
|
+
void loadTrash(props.client, dispatch)
|
|
66
|
+
}, [props.client])
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (props.focus === undefined) return
|
|
69
|
+
dispatch({ type: 'open', appId: props.focus.appId, ...props.focus.title === undefined ? {} : { title: props.focus.title } })
|
|
70
|
+
}, [props.focus])
|
|
71
|
+
return (
|
|
72
|
+
<GalleryBody
|
|
73
|
+
state={state}
|
|
74
|
+
dispatch={dispatch}
|
|
75
|
+
client={props.client}
|
|
76
|
+
locale={props.locale}
|
|
77
|
+
mode={props.mode}
|
|
78
|
+
{...props.frame === undefined ? {} : { frame: props.frame }}
|
|
79
|
+
{...props.onClosePanel === undefined ? {} : { onClosePanel: props.onClosePanel }}
|
|
80
|
+
{...props.tools === undefined ? {} : { tools: props.tools }}
|
|
81
|
+
{...props.themeOpen === undefined ? {} : { themeOpen: props.themeOpen }}
|
|
82
|
+
{...props.onToggleTheme === undefined ? {} : { onToggleTheme: props.onToggleTheme }}
|
|
83
|
+
{...props.theme === undefined ? {} : { theme: props.theme }}
|
|
84
|
+
{...props.onToggleSettings === undefined ? {} : { onToggleSettings: props.onToggleSettings }}
|
|
85
|
+
{...props.defaultWorkbenchId === undefined ? {} : { defaultWorkbenchId: props.defaultWorkbenchId }}
|
|
86
|
+
{...props.onSetDefaultWorkbench === undefined ? {} : { onSetDefaultWorkbench: props.onSetDefaultWorkbench }}
|
|
87
|
+
{...props.overlay === undefined ? {} : { overlay: props.overlay({ cardStyle: state.cardStyle, setCard: (style) => { writeCardStyle(style); dispatch({ type: 'card', cardStyle: style }) } }) }}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render one view state. Tests pass a state instead of waiting for an effect. */
|
|
93
|
+
export function GalleryBody(props: {
|
|
94
|
+
readonly state: GalleryState
|
|
95
|
+
readonly dispatch: (action: GalleryAction) => void
|
|
96
|
+
readonly client: PanelClient
|
|
97
|
+
readonly locale: PanelLocale
|
|
98
|
+
readonly mode: PanelLabelMode
|
|
99
|
+
readonly frame?: (appId: string) => ReactNode
|
|
100
|
+
readonly onClosePanel?: () => void
|
|
101
|
+
readonly tools?: ReactNode
|
|
102
|
+
readonly themeOpen?: boolean
|
|
103
|
+
readonly onToggleTheme?: () => void
|
|
104
|
+
readonly theme?: ThemeSlot
|
|
105
|
+
readonly onToggleSettings?: () => void
|
|
106
|
+
readonly overlay?: ReactNode
|
|
107
|
+
readonly defaultWorkbenchId?: string
|
|
108
|
+
readonly onSetDefaultWorkbench?: (id: string) => void
|
|
109
|
+
}): ReactNode {
|
|
110
|
+
const { state, dispatch } = props
|
|
111
|
+
const label = (key: string) => panelText(props.locale, key, props.mode)
|
|
112
|
+
const active = state.tabs.tabs[state.tabs.active]
|
|
113
|
+
const kind = viewKind(state)
|
|
114
|
+
const appTab = active?.kind === 'app'
|
|
115
|
+
const openRecord = appTab ? state.apps.find(app => app.id === active.appId) : undefined
|
|
116
|
+
const workbenchTab = openRecord?.kind === 'workbench'
|
|
117
|
+
const homePinned = openRecord?.kind === 'workbench' && props.defaultWorkbenchId === openRecord.id
|
|
118
|
+
const desk = state.apps.find(app => app.id === props.defaultWorkbenchId && app.kind === 'workbench')
|
|
119
|
+
const desks = workbenchEntries(state.apps, props.defaultWorkbenchId, label('workbench-builtin'))
|
|
120
|
+
const deskId = desks.find(entry => entry.default)?.id ?? 'default'
|
|
121
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
122
|
+
const rail = useRef<HTMLDivElement>(null)
|
|
123
|
+
const [glide, setGlide] = useState<{ left: number; width: number } | undefined>(undefined)
|
|
124
|
+
const tabKey = state.tabs.tabs.map(tab => tab.kind === 'gallery' ? 'gallery' : `${tab.appId}:${tab.title ?? ''}`).join('|')
|
|
125
|
+
useLayoutEffect(() => {
|
|
126
|
+
const root = rail.current
|
|
127
|
+
if (root === null) return
|
|
128
|
+
const current = root.querySelector('[data-active="1"]')
|
|
129
|
+
if (!(current instanceof HTMLElement)) return
|
|
130
|
+
setGlide({ left: current.offsetLeft, width: current.offsetWidth })
|
|
131
|
+
}, [state.tabs.active, tabKey])
|
|
132
|
+
return (
|
|
133
|
+
<section id="mma-host" className="mma-command relative flex h-full min-h-full flex-col text-foreground" data-shell={state.shell} data-card={state.cardStyle} data-cardstyle={state.cardStyle}>
|
|
134
|
+
<header className="mma-chrome">
|
|
135
|
+
<div ref={rail} className="mma-pill-rail">
|
|
136
|
+
{glide === undefined ? null : (
|
|
137
|
+
<span className="mma-pill-glide" style={{ width: glide.width, transform: `translateX(${glide.left}px)` }} />
|
|
138
|
+
)}
|
|
139
|
+
{state.tabs.tabs.map((tab, index) => (
|
|
140
|
+
<button
|
|
141
|
+
key={tab.kind === 'gallery' ? 'gallery' : tab.appId}
|
|
142
|
+
type="button"
|
|
143
|
+
className="mma-pill"
|
|
144
|
+
data-active={index === state.tabs.active ? '1' : '0'}
|
|
145
|
+
onClick={() => dispatch({ type: 'switch', index })}
|
|
146
|
+
>
|
|
147
|
+
<span>{tab.kind === 'gallery' ? label('gallery') : tab.title ?? tab.appId}</span>
|
|
148
|
+
{tab.kind === 'app' ? (
|
|
149
|
+
<span
|
|
150
|
+
className="ml-1.5 text-[13px] leading-none text-muted-foreground hover:text-foreground"
|
|
151
|
+
role="button"
|
|
152
|
+
aria-label={label('close-tab')}
|
|
153
|
+
onClick={(event) => {
|
|
154
|
+
event.stopPropagation()
|
|
155
|
+
dispatch({ type: 'close', index })
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
×
|
|
159
|
+
</span>
|
|
160
|
+
) : null}
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
<div className="flex-1" />
|
|
165
|
+
{appTab ? null : (
|
|
166
|
+
<DeskBar
|
|
167
|
+
label={label('desk-switch')}
|
|
168
|
+
choices={desks.map((entry) => {
|
|
169
|
+
if (entry.builtin) return { id: entry.id, name: entry.name, icon: 'library' as const }
|
|
170
|
+
const app = state.apps.find(item => item.id === entry.id)
|
|
171
|
+
return { id: entry.id, name: entry.name, mark: app?.acronym ?? entry.name.slice(0, 2) }
|
|
172
|
+
})}
|
|
173
|
+
selected={deskId}
|
|
174
|
+
onSelect={(id) => {
|
|
175
|
+
if (id === deskId) return
|
|
176
|
+
props.onSetDefaultWorkbench?.(id)
|
|
177
|
+
}}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
{appTab ? (
|
|
181
|
+
<div className="mma-toolbar">
|
|
182
|
+
{workbenchTab ? (
|
|
183
|
+
<Tooltip text={homePinned ? label('clear-default-workbench') : label('set-default-workbench')}>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
className="inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[13px] leading-none font-semibold hover:bg-muted"
|
|
187
|
+
data-default-workbench={active.appId}
|
|
188
|
+
data-current={homePinned ? '1' : '0'}
|
|
189
|
+
aria-label={homePinned ? label('clear-default-workbench') : label('set-default-workbench')}
|
|
190
|
+
onClick={() => props.onSetDefaultWorkbench?.(homePinned ? 'default' : active.appId)}
|
|
191
|
+
>
|
|
192
|
+
<Pin size={14} strokeWidth={2} className="shrink-0" fill={homePinned ? 'currentColor' : 'none'} />
|
|
193
|
+
<span className="leading-none">{homePinned ? label('current-default-workbench') : label('set-default-workbench')}</span>
|
|
194
|
+
</button>
|
|
195
|
+
</Tooltip>
|
|
196
|
+
) : null}
|
|
197
|
+
{workbenchTab ? <span className="mma-toolbar-rule" aria-hidden="true" /> : null}
|
|
198
|
+
<Tooltip text={label('delete')}>
|
|
199
|
+
<button type="button" className="inline-flex size-8 items-center justify-center rounded-lg text-destructive transition-colors duration-150 hover:bg-muted" aria-label={label('delete')} onClick={() => dispatch({ type: 'ask-delete', appId: active.appId })}>
|
|
200
|
+
<Trash2 size={16} strokeWidth={2} />
|
|
201
|
+
</button>
|
|
202
|
+
</Tooltip>
|
|
203
|
+
</div>
|
|
204
|
+
) : null}
|
|
205
|
+
<div className="mma-toolbar">
|
|
206
|
+
<Tooltip text={appTab ? label('reload') : label('refresh')}>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
className="inline-flex size-8 items-center justify-center rounded-lg transition-colors duration-150 hover:bg-muted"
|
|
210
|
+
aria-label={appTab ? label('reload') : label('refresh')}
|
|
211
|
+
onClick={() => {
|
|
212
|
+
void refreshList(props.client, appTab && active?.kind === 'app' ? active.appId : undefined, !appTab ? desk?.id : undefined, dispatch, setRefreshing)
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<span className="sr-only">{appTab ? label('reload') : label('refresh')}</span>
|
|
216
|
+
<RefreshCw size={16} strokeWidth={2} className={refreshing ? 'animate-spin' : ''} />
|
|
217
|
+
</button>
|
|
218
|
+
</Tooltip>
|
|
219
|
+
{props.onToggleTheme === undefined ? null : (
|
|
220
|
+
<div className="relative z-30">
|
|
221
|
+
<Tooltip text={label('theme')} align="end">
|
|
222
|
+
<button type="button" className="inline-flex size-8 items-center justify-center rounded-lg transition-colors duration-150 hover:bg-muted" aria-label={label('theme')} onClick={props.onToggleTheme}>
|
|
223
|
+
<span className="sr-only">{label('theme')}</span>
|
|
224
|
+
<Palette size={18} strokeWidth={1.75} />
|
|
225
|
+
</button>
|
|
226
|
+
</Tooltip>
|
|
227
|
+
<div className={props.themeOpen === true ? 'absolute top-11 right-0 z-30 max-h-[70vh] w-72 origin-top-right animate-in overflow-auto rounded-xl border bg-card p-3 shadow-lg duration-150 fade-in-0 zoom-in-95' : 'hidden'} onPointerDown={event => event.stopPropagation()}>{themeNode(props.theme, active?.kind === 'app' ? { id: active.appId, title: active.title ?? active.appId } : undefined)}</div>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
{appTab ? props.tools : null}
|
|
232
|
+
{props.onToggleSettings === undefined ? null : (
|
|
233
|
+
<Tooltip text={label('settings')} align="end">
|
|
234
|
+
<button type="button" className="inline-flex size-8 items-center justify-center rounded-lg transition-colors duration-150 hover:bg-muted" aria-label={label('settings')} onClick={props.onToggleSettings}>
|
|
235
|
+
<span className="sr-only">{label('settings')}</span>
|
|
236
|
+
<Settings size={16} strokeWidth={2} />
|
|
237
|
+
</button>
|
|
238
|
+
</Tooltip>
|
|
239
|
+
)}
|
|
240
|
+
{state.shell === 'overlay' && props.onClosePanel !== undefined ? (
|
|
241
|
+
<button type="button" className="inline-flex size-8 items-center justify-center rounded-lg hover:bg-muted" onClick={() => props.onClosePanel?.()}>{label('close-panel')}</button>
|
|
242
|
+
) : null}
|
|
243
|
+
</div>
|
|
244
|
+
</header>
|
|
245
|
+
{props.themeOpen === true && props.onToggleTheme !== undefined ? (
|
|
246
|
+
<button type="button" aria-label={label('pane-close')} data-theme-scrim="" className="fixed inset-0 z-30 cursor-default bg-transparent" onPointerDown={(event) => { event.preventDefault(); props.onToggleTheme?.() }} />
|
|
247
|
+
) : null}
|
|
248
|
+
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
249
|
+
{appTab ? null : (
|
|
250
|
+
<div className="mma-status">
|
|
251
|
+
<span className="mma-dot" data-status={statusDot(kind)} />
|
|
252
|
+
<span>{kind === 'unreachable' || kind === 'failed' ? label('host-unreachable') : label('status-ready')}</span>
|
|
253
|
+
<span>{label('gallery-count').replaceAll('{n}', String(state.apps.length))}</span>
|
|
254
|
+
{desk === undefined ? null : (
|
|
255
|
+
<Tooltip text={label('open-workbench-tab-hint')} align="end">
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
className="mma-status-action"
|
|
259
|
+
data-open-workbench={desk.id}
|
|
260
|
+
aria-label={label('open-workbench-tab')}
|
|
261
|
+
onClick={() => {
|
|
262
|
+
const open = state.tabs.tabs.some(tab => tab.kind === 'app' && tab.appId === desk.id)
|
|
263
|
+
void openApp(props.client, desk.id, desk.name, dispatch, open)
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
<span>{label('open-workbench-tab-now')}</span>
|
|
267
|
+
<span className="mma-status-split" aria-hidden="true" />
|
|
268
|
+
<span className="mma-status-more">{label('open-workbench-tab-more')}</span>
|
|
269
|
+
<span className="mma-status-go" aria-hidden="true"><ArrowUpRight size={12} strokeWidth={2} /></span>
|
|
270
|
+
</button>
|
|
271
|
+
</Tooltip>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
<div className="relative min-h-0 flex-1 overflow-hidden">
|
|
276
|
+
{state.tabs.tabs.map(tab => tab.kind === 'app' ? (
|
|
277
|
+
<div key={tab.appId} className={stageClass(active?.kind === 'app' && active.appId === tab.appId, 'flex flex-col')}>
|
|
278
|
+
{active?.kind === 'app' && active.appId === tab.appId && state.frameError !== undefined ? <p className="px-6 py-2 text-sm text-destructive">{state.frameError.length > 0 ? state.frameError : label('reload-failed')}</p> : null}
|
|
279
|
+
<div className="mma-stage">
|
|
280
|
+
<AppFrame appId={tab.appId} pending={label('frame-pending')} {...props.frame === undefined ? {} : { frame: props.frame }} />
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
) : null)}
|
|
284
|
+
<div className={stageClass(!appTab, 'flex flex-col')}>
|
|
285
|
+
<div className="mma-stage mma-app-frame">
|
|
286
|
+
{desk !== undefined ? (
|
|
287
|
+
<div className="mma-frame [&_iframe]:block [&_iframe]:size-full [&_iframe]:border-0">
|
|
288
|
+
{props.frame?.(desk.id) ?? <div data-app-id={desk.id} data-workbench-slot="" />}
|
|
289
|
+
</div>
|
|
290
|
+
) : (
|
|
291
|
+
<div className="mma-frame">
|
|
292
|
+
<div className="mma-library">
|
|
293
|
+
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
|
294
|
+
<div className="min-w-0">
|
|
295
|
+
<h2 className="m-0 text-3xl font-extrabold tracking-tight">{label('gallery-title')}</h2>
|
|
296
|
+
<div className="mt-3 w-fit">
|
|
297
|
+
<DeskBar
|
|
298
|
+
label={label('card-style')}
|
|
299
|
+
fit
|
|
300
|
+
choices={galleryCardStyles.map(style => ({ id: style, name: label(cardLabel(style)) }))}
|
|
301
|
+
selected={state.cardStyle}
|
|
302
|
+
onSelect={(id) => {
|
|
303
|
+
if (!isGalleryCardStyle(id)) return
|
|
304
|
+
writeCardStyle(id)
|
|
305
|
+
dispatch({ type: 'card', cardStyle: id })
|
|
306
|
+
}}
|
|
307
|
+
/>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
<div className="mma-search relative w-full max-w-xs">
|
|
311
|
+
<Search size={18} strokeWidth={2} className="pointer-events-none absolute top-1/2 left-3.5 z-10 -translate-y-1/2 text-muted-foreground" />
|
|
312
|
+
<input
|
|
313
|
+
className="h-11 w-full rounded-[14px] border border-foreground/10 bg-card/60 pr-4 pl-10 text-sm text-foreground outline-none backdrop-blur-md placeholder:text-muted-foreground focus:border-primary focus:bg-card"
|
|
314
|
+
type="search"
|
|
315
|
+
aria-label={label('search')}
|
|
316
|
+
placeholder={label('search-placeholder')}
|
|
317
|
+
value={state.query}
|
|
318
|
+
onChange={event => dispatch({ type: 'search', query: event.target.value })}
|
|
319
|
+
/>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
{state.openError !== undefined ? <p className="px-6 py-2 text-sm text-destructive">{state.openError.length > 0 ? state.openError : label('open-failed')}</p> : null}
|
|
323
|
+
{kind === 'unreachable' ? <p className="px-6 py-2 text-sm text-destructive">{label('host-unreachable')}</p> : null}
|
|
324
|
+
{kind === 'failed' ? <p className="px-6 py-2 text-sm text-destructive">{state.failed !== undefined && state.failed.length > 0 ? state.failed : label('list-failed')}</p> : null}
|
|
325
|
+
{kind === 'empty' ? <p className="px-6 py-4 text-sm text-muted-foreground">{label('gallery-empty')}</p> : null}
|
|
326
|
+
{kind === 'none' ? <p className="px-6 py-4 text-sm text-muted-foreground">{label('search-empty')}</p> : null}
|
|
327
|
+
{kind === 'ready' ? (
|
|
328
|
+
<WorkbenchLibrary
|
|
329
|
+
apps={visibleApps(state)}
|
|
330
|
+
cardStyle={state.cardStyle}
|
|
331
|
+
openLabel={label('open')}
|
|
332
|
+
openAppIds={state.tabs.tabs.flatMap(tab => tab.kind === 'app' ? [tab.appId] : [])}
|
|
333
|
+
openApp={(id, title) => {
|
|
334
|
+
const open = state.tabs.tabs.some(tab => tab.kind === 'app' && tab.appId === id)
|
|
335
|
+
void openApp(props.client, id, title ?? id, dispatch, open)
|
|
336
|
+
}}
|
|
337
|
+
/>
|
|
338
|
+
) : null}
|
|
339
|
+
{state.trashFailed ? <p className="px-6 py-2 text-sm text-destructive">{label('trash-failed')}</p> : null}
|
|
340
|
+
{state.trash.length === 0 ? null : (
|
|
341
|
+
<div className="px-6 py-4 text-sm text-muted-foreground">
|
|
342
|
+
<p>{label('trash')}</p>
|
|
343
|
+
{state.trash.map(app => (
|
|
344
|
+
<button key={app.id} type="button" className="h-8 rounded-md px-2 text-sm hover:bg-muted" data-trash={app.id} onClick={() => { void restoreApp(props.client, app.id, dispatch) }}>{app.name}</button>
|
|
345
|
+
))}
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
{state.deletePrompt === 'confirm' ? (
|
|
354
|
+
<Dialog width="sm" onClose={() => dispatch({ type: 'cancel-delete' })}>
|
|
355
|
+
<h3>{label('delete')}</h3>
|
|
356
|
+
<p>{label('delete-confirm')}</p>
|
|
357
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
358
|
+
<button type="button" onClick={() => dispatch({ type: 'cancel-delete' })}>{label('cancel')}</button>
|
|
359
|
+
<button type="button" className="go" onClick={() => { void removeApp(props.client, state.pendingDeleteId, dispatch) }}>{label('delete')}</button>
|
|
360
|
+
</div>
|
|
361
|
+
</Dialog>
|
|
362
|
+
) : null}
|
|
363
|
+
{state.deletePrompt === 'failed' ? <p className="px-6 py-2 text-sm text-destructive">{state.deleteError !== undefined && state.deleteError.length > 0 ? state.deleteError : label('delete-failed')}</p> : null}
|
|
364
|
+
</div>
|
|
365
|
+
{props.overlay}
|
|
366
|
+
</div>
|
|
367
|
+
</section>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function openApp(
|
|
372
|
+
client: PanelClient,
|
|
373
|
+
appId: string,
|
|
374
|
+
title: string,
|
|
375
|
+
dispatch: (action: GalleryAction) => void,
|
|
376
|
+
open: boolean,
|
|
377
|
+
): void {
|
|
378
|
+
dispatch({ type: 'open', appId, title })
|
|
379
|
+
void notifyOpen(client, appId, title, dispatch, open)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function notifyOpen(
|
|
383
|
+
client: PanelClient,
|
|
384
|
+
appId: string,
|
|
385
|
+
title: string,
|
|
386
|
+
dispatch: (action: GalleryAction) => void,
|
|
387
|
+
open: boolean,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
try {
|
|
390
|
+
await client.open(appId, title)
|
|
391
|
+
if (!open && client.reload !== undefined) await client.reload(appId)
|
|
392
|
+
} catch (error) {
|
|
393
|
+
dispatch({ type: 'drop-app', appId })
|
|
394
|
+
dispatch({ type: 'open-failed', ...thrown(error) })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function AppFrame(props: {
|
|
399
|
+
readonly appId: string
|
|
400
|
+
readonly frame?: (appId: string) => ReactNode
|
|
401
|
+
readonly pending: string
|
|
402
|
+
}): ReactNode {
|
|
403
|
+
const [ready, setReady] = useState(false)
|
|
404
|
+
const node = props.frame?.(props.appId)
|
|
405
|
+
const iframe = isValidElement(node) && node.type === 'iframe'
|
|
406
|
+
const framed = iframe
|
|
407
|
+
? cloneElement(node as ReactElement<{ onLoad?: () => void }>, { onLoad: () => setReady(true) })
|
|
408
|
+
: node
|
|
409
|
+
return (
|
|
410
|
+
<div className="relative size-full">
|
|
411
|
+
<div className="mma-frame size-full [&_iframe]:block [&_iframe]:size-full [&_iframe]:border-0">
|
|
412
|
+
{framed ?? <div data-app-id={props.appId} />}
|
|
413
|
+
</div>
|
|
414
|
+
{iframe && !ready ? (
|
|
415
|
+
<div className="absolute inset-0 flex items-center justify-center bg-background text-sm text-muted-foreground" data-frame-pending={props.appId}>
|
|
416
|
+
{props.pending}
|
|
417
|
+
</div>
|
|
418
|
+
) : null}
|
|
419
|
+
</div>
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function reloadApp(client: PanelClient, appId: string, dispatch: (action: GalleryAction) => void): Promise<void> {
|
|
424
|
+
if (client.reload === undefined) return
|
|
425
|
+
try {
|
|
426
|
+
await client.reload(appId)
|
|
427
|
+
} catch (error) {
|
|
428
|
+
dispatch({ type: 'frame-error', message: error instanceof Error ? error.message : '' })
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function restoreApp(client: PanelClient, appId: string, dispatch: (action: GalleryAction) => void): Promise<void> {
|
|
433
|
+
if (client.undeleteApp === undefined) return
|
|
434
|
+
try {
|
|
435
|
+
await client.undeleteApp(appId)
|
|
436
|
+
} catch (error) {
|
|
437
|
+
dispatch({ type: 'delete-failed', ...thrown(error) })
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
dispatch({ type: 'undeleted', appId })
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function removeApp(client: PanelClient, appId: string | undefined, dispatch: (action: GalleryAction) => void): Promise<void> {
|
|
444
|
+
if (appId === undefined) return
|
|
445
|
+
try {
|
|
446
|
+
await client.deleteApp(appId)
|
|
447
|
+
} catch (error) {
|
|
448
|
+
dispatch({ type: 'delete-failed', ...thrown(error) })
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
dispatch({ type: 'deleted', appId })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function refreshList(
|
|
455
|
+
client: PanelClient,
|
|
456
|
+
appId: string | undefined,
|
|
457
|
+
workbenchId: string | undefined,
|
|
458
|
+
dispatch: (action: GalleryAction) => void,
|
|
459
|
+
setRefreshing: (value: boolean) => void,
|
|
460
|
+
): Promise<void> {
|
|
461
|
+
setRefreshing(true)
|
|
462
|
+
const started = Date.now()
|
|
463
|
+
try {
|
|
464
|
+
if (appId !== undefined && client.reload !== undefined) await reloadApp(client, appId, dispatch)
|
|
465
|
+
else {
|
|
466
|
+
await loadGallery(client, dispatch)
|
|
467
|
+
if (workbenchId !== undefined) await reloadApp(client, workbenchId, dispatch)
|
|
468
|
+
}
|
|
469
|
+
} finally {
|
|
470
|
+
const wait = 400 - (Date.now() - started)
|
|
471
|
+
if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait))
|
|
472
|
+
setRefreshing(false)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function statusDot(kind: GalleryKind): 'ready' | 'failed' | 'down' {
|
|
477
|
+
if (kind === 'unreachable') return 'down'
|
|
478
|
+
if (kind === 'failed') return 'failed'
|
|
479
|
+
return 'ready'
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function stageClass(shown: boolean, extra: string): string {
|
|
483
|
+
const place = shown
|
|
484
|
+
? 'absolute inset-0 z-10 opacity-100'
|
|
485
|
+
: 'pointer-events-none absolute inset-0 z-0 opacity-0'
|
|
486
|
+
return `${place} transition-opacity duration-200 ease-out ${extra}`
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function cardLabel(style: GalleryCardStyle): 'card-hero' | 'card-stamp' | 'card-etch' | 'card-pulse' | 'card-list' | 'card-glass' {
|
|
490
|
+
if (style === 'stamp') return 'card-stamp'
|
|
491
|
+
if (style === 'etch') return 'card-etch'
|
|
492
|
+
if (style === 'pulse') return 'card-pulse'
|
|
493
|
+
if (style === 'list') return 'card-list'
|
|
494
|
+
if (style === 'glass') return 'card-glass'
|
|
495
|
+
return 'card-hero'
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function thrown(error: unknown): { message: string } | Record<string, never> {
|
|
499
|
+
const message = error instanceof Error ? error.message : ''
|
|
500
|
+
return message.length > 0 ? { message } : {}
|
|
501
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** One commit the history view can list. No route string lives here. */
|
|
2
|
+
export interface HistoryCommit {
|
|
3
|
+
readonly id: string
|
|
4
|
+
readonly message: string
|
|
5
|
+
readonly time: string
|
|
6
|
+
readonly parentIds: readonly string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface HistoryFile {
|
|
10
|
+
readonly path: string
|
|
11
|
+
readonly add: number
|
|
12
|
+
readonly del: number
|
|
13
|
+
readonly preview: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HistoryDetail {
|
|
17
|
+
readonly message: string
|
|
18
|
+
readonly time: string
|
|
19
|
+
readonly parentIds: readonly string[]
|
|
20
|
+
readonly files: readonly HistoryFile[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** History reads. Absent means the control is hidden. */
|
|
24
|
+
export interface HistoryClient {
|
|
25
|
+
readHistory(appId: string): Promise<readonly HistoryCommit[]>
|
|
26
|
+
readCommit(appId: string, commitId: string): Promise<HistoryDetail>
|
|
27
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { HistoryClient, HistoryCommit, HistoryDetail } from './client.ts'
|
|
2
|
+
|
|
3
|
+
export interface HistoryState {
|
|
4
|
+
readonly commits: readonly HistoryCommit[]
|
|
5
|
+
readonly failed: boolean
|
|
6
|
+
readonly selected?: string
|
|
7
|
+
readonly detail?: HistoryDetail
|
|
8
|
+
readonly detailFailed: boolean
|
|
9
|
+
readonly error?: string
|
|
10
|
+
readonly detailError?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type HistoryAction =
|
|
14
|
+
| { readonly type: 'loaded'; readonly commits: readonly HistoryCommit[] }
|
|
15
|
+
| { readonly type: 'failed'; readonly message?: string }
|
|
16
|
+
| { readonly type: 'select'; readonly commitId: string }
|
|
17
|
+
| { readonly type: 'detail'; readonly detail: HistoryDetail }
|
|
18
|
+
| { readonly type: 'detail-failed'; readonly message?: string }
|
|
19
|
+
|
|
20
|
+
/** Empty history before the load. */
|
|
21
|
+
export function historyState(): HistoryState {
|
|
22
|
+
return { commits: [], failed: false, detailFailed: false }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* History view transition. Read-only. Reset is not a panel action.
|
|
27
|
+
* @param state - current history
|
|
28
|
+
* @param action - one client result
|
|
29
|
+
*/
|
|
30
|
+
export function reduceHistory(state: HistoryState, action: HistoryAction): HistoryState {
|
|
31
|
+
switch (action.type) {
|
|
32
|
+
case 'loaded':
|
|
33
|
+
return { commits: action.commits, failed: false, detailFailed: false }
|
|
34
|
+
case 'failed':
|
|
35
|
+
return { ...omitDetailError(omitError(state)), failed: true, commits: [], ...action.message ? { error: action.message } : {} }
|
|
36
|
+
case 'select':
|
|
37
|
+
return { ...state, selected: action.commitId, detailFailed: false }
|
|
38
|
+
case 'detail':
|
|
39
|
+
return { ...omitDetailError(state), detail: action.detail, detailFailed: false }
|
|
40
|
+
case 'detail-failed':
|
|
41
|
+
return { ...omitDetailError(state), detailFailed: true, ...action.message ? { detailError: action.message } : {} }
|
|
42
|
+
default:
|
|
43
|
+
return action satisfies never
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Load the commit list. A thrown error is a failed pane, not an empty history. */
|
|
48
|
+
export async function loadHistory(
|
|
49
|
+
client: HistoryClient,
|
|
50
|
+
appId: string,
|
|
51
|
+
dispatch: (action: HistoryAction) => void,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
dispatch({ type: 'loaded', commits: await client.readHistory(appId) })
|
|
55
|
+
} catch (error) {
|
|
56
|
+
dispatch({ type: 'failed', ...thrown(error) })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Load one commit preview. */
|
|
61
|
+
export async function loadCommit(
|
|
62
|
+
client: HistoryClient,
|
|
63
|
+
appId: string,
|
|
64
|
+
commitId: string,
|
|
65
|
+
dispatch: (action: HistoryAction) => void,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
dispatch({ type: 'select', commitId })
|
|
68
|
+
try {
|
|
69
|
+
dispatch({ type: 'detail', detail: await client.readCommit(appId, commitId) })
|
|
70
|
+
} catch (error) {
|
|
71
|
+
dispatch({ type: 'detail-failed', ...thrown(error) })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function thrown(error: unknown): { message: string } | Record<string, never> {
|
|
76
|
+
const message = error instanceof Error ? error.message : ''
|
|
77
|
+
return message.length > 0 ? { message } : {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function omitError(state: HistoryState): Omit<HistoryState, 'error'> {
|
|
81
|
+
const { error: gone, ...rest } = state
|
|
82
|
+
void gone
|
|
83
|
+
return rest
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function omitDetailError(state: HistoryState): Omit<HistoryState, 'detailError'> {
|
|
87
|
+
const { detailError: gone, ...rest } = state
|
|
88
|
+
void gone
|
|
89
|
+
return rest
|
|
90
|
+
}
|