@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.
Files changed (113) hide show
  1. package/README.md +8 -0
  2. package/lib/types/chrome/hue.d.ts +3 -0
  3. package/lib/types/chrome/hue.d.ts.map +1 -0
  4. package/lib/types/gallery/card-style.d.ts +8 -0
  5. package/lib/types/gallery/card-style.d.ts.map +1 -0
  6. package/lib/types/gallery/client.d.ts +20 -0
  7. package/lib/types/gallery/client.d.ts.map +1 -0
  8. package/lib/types/gallery/desk.d.ts +16 -0
  9. package/lib/types/gallery/desk.d.ts.map +1 -0
  10. package/lib/types/gallery/list.d.ts +37 -0
  11. package/lib/types/gallery/list.d.ts.map +1 -0
  12. package/lib/types/gallery/sort.d.ts +24 -0
  13. package/lib/types/gallery/sort.d.ts.map +1 -0
  14. package/lib/types/gallery/state.d.ts +90 -0
  15. package/lib/types/gallery/state.d.ts.map +1 -0
  16. package/lib/types/gallery/tabs.d.ts +45 -0
  17. package/lib/types/gallery/tabs.d.ts.map +1 -0
  18. package/lib/types/gallery/view.d.ts +57 -0
  19. package/lib/types/gallery/view.d.ts.map +1 -0
  20. package/lib/types/history/client.d.ts +25 -0
  21. package/lib/types/history/client.d.ts.map +1 -0
  22. package/lib/types/history/state.d.ts +39 -0
  23. package/lib/types/history/state.d.ts.map +1 -0
  24. package/lib/types/history/view.d.ts +15 -0
  25. package/lib/types/history/view.d.ts.map +1 -0
  26. package/lib/types/index.d.ts +24 -0
  27. package/lib/types/index.d.ts.map +1 -0
  28. package/lib/types/labels.d.ts +267 -0
  29. package/lib/types/labels.d.ts.map +1 -0
  30. package/lib/types/settings/author-mcp-view.d.ts +15 -0
  31. package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
  32. package/lib/types/settings/author-mcp.d.ts +4 -0
  33. package/lib/types/settings/author-mcp.d.ts.map +1 -0
  34. package/lib/types/settings/client.d.ts +198 -0
  35. package/lib/types/settings/client.d.ts.map +1 -0
  36. package/lib/types/settings/credentials-view.d.ts +15 -0
  37. package/lib/types/settings/credentials-view.d.ts.map +1 -0
  38. package/lib/types/settings/form.d.ts +27 -0
  39. package/lib/types/settings/form.d.ts.map +1 -0
  40. package/lib/types/settings/install-row.d.ts +10 -0
  41. package/lib/types/settings/install-row.d.ts.map +1 -0
  42. package/lib/types/settings/mcp-view.d.ts +14 -0
  43. package/lib/types/settings/mcp-view.d.ts.map +1 -0
  44. package/lib/types/settings/skill-dirs.d.ts +6 -0
  45. package/lib/types/settings/skill-dirs.d.ts.map +1 -0
  46. package/lib/types/settings/skill-install.d.ts +13 -0
  47. package/lib/types/settings/skill-install.d.ts.map +1 -0
  48. package/lib/types/settings/state.d.ts +94 -0
  49. package/lib/types/settings/state.d.ts.map +1 -0
  50. package/lib/types/settings/update-dialog.d.ts +56 -0
  51. package/lib/types/settings/update-dialog.d.ts.map +1 -0
  52. package/lib/types/settings/view.d.ts +26 -0
  53. package/lib/types/settings/view.d.ts.map +1 -0
  54. package/lib/types/storage/client.d.ts +15 -0
  55. package/lib/types/storage/client.d.ts.map +1 -0
  56. package/lib/types/storage/state.d.ts +42 -0
  57. package/lib/types/storage/state.d.ts.map +1 -0
  58. package/lib/types/storage/view.d.ts +16 -0
  59. package/lib/types/storage/view.d.ts.map +1 -0
  60. package/lib/types/surface/state.d.ts +42 -0
  61. package/lib/types/surface/state.d.ts.map +1 -0
  62. package/lib/types/surface/view.d.ts +34 -0
  63. package/lib/types/surface/view.d.ts.map +1 -0
  64. package/lib/types/theme/client.d.ts +45 -0
  65. package/lib/types/theme/client.d.ts.map +1 -0
  66. package/lib/types/theme/menu.d.ts +25 -0
  67. package/lib/types/theme/menu.d.ts.map +1 -0
  68. package/lib/types/theme/state.d.ts +46 -0
  69. package/lib/types/theme/state.d.ts.map +1 -0
  70. package/lib/types/theme/view.d.ts +16 -0
  71. package/lib/types/theme/view.d.ts.map +1 -0
  72. package/lib/types/ui/dialog.d.ts +12 -0
  73. package/lib/types/ui/dialog.d.ts.map +1 -0
  74. package/lib/types/ui/tooltip.d.ts +12 -0
  75. package/lib/types/ui/tooltip.d.ts.map +1 -0
  76. package/package.json +38 -0
  77. package/src/chrome/hue.ts +6 -0
  78. package/src/gallery/card-style.ts +27 -0
  79. package/src/gallery/client.ts +26 -0
  80. package/src/gallery/desk.tsx +54 -0
  81. package/src/gallery/list.ts +54 -0
  82. package/src/gallery/sort.ts +72 -0
  83. package/src/gallery/state.ts +229 -0
  84. package/src/gallery/tabs.ts +85 -0
  85. package/src/gallery/view.tsx +501 -0
  86. package/src/history/client.ts +27 -0
  87. package/src/history/state.ts +90 -0
  88. package/src/history/view.tsx +131 -0
  89. package/src/index.ts +61 -0
  90. package/src/labels.ts +536 -0
  91. package/src/settings/author-mcp-view.tsx +154 -0
  92. package/src/settings/author-mcp.ts +24 -0
  93. package/src/settings/client.ts +205 -0
  94. package/src/settings/credentials-view.tsx +195 -0
  95. package/src/settings/form.ts +27 -0
  96. package/src/settings/install-row.tsx +35 -0
  97. package/src/settings/mcp-view.tsx +793 -0
  98. package/src/settings/skill-dirs.ts +41 -0
  99. package/src/settings/skill-install.tsx +134 -0
  100. package/src/settings/state.ts +225 -0
  101. package/src/settings/update-dialog.tsx +207 -0
  102. package/src/settings/view.tsx +722 -0
  103. package/src/storage/client.ts +16 -0
  104. package/src/storage/state.ts +94 -0
  105. package/src/storage/view.tsx +190 -0
  106. package/src/surface/state.ts +72 -0
  107. package/src/surface/view.tsx +255 -0
  108. package/src/theme/client.ts +34 -0
  109. package/src/theme/menu.tsx +286 -0
  110. package/src/theme/state.ts +113 -0
  111. package/src/theme/view.tsx +73 -0
  112. package/src/ui/dialog.tsx +25 -0
  113. 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
+ }