@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,16 @@
1
+ /** Storage summary the browse view can show. No route string lives here. */
2
+ export interface StorageSummary {
3
+ readonly bytes: number
4
+ readonly tables: readonly string[]
5
+ }
6
+
7
+ export interface StorageTable {
8
+ readonly rows: readonly unknown[]
9
+ }
10
+
11
+ /** Storage reads. Absent means the control is hidden. */
12
+ export interface StorageClient {
13
+ readStorage(appId: string): Promise<StorageSummary>
14
+ readTable(appId: string, table: string): Promise<StorageTable>
15
+ restoreStorage?(appId: string): Promise<void>
16
+ }
@@ -0,0 +1,94 @@
1
+ import type { StorageClient, StorageSummary, StorageTable } from './client.ts'
2
+
3
+ export interface StorageState {
4
+ readonly summary?: StorageSummary
5
+ readonly failed: boolean
6
+ readonly table?: string
7
+ readonly rows?: StorageTable
8
+ readonly tableFailed: boolean
9
+ readonly noticeDismissed: boolean
10
+ readonly error?: string
11
+ readonly tableError?: string
12
+ }
13
+
14
+ export type StorageAction =
15
+ | { readonly type: 'loaded'; readonly summary: StorageSummary }
16
+ | { readonly type: 'failed'; readonly message?: string }
17
+ | { readonly type: 'select'; readonly table: string }
18
+ | { readonly type: 'table'; readonly rows: StorageTable }
19
+ | { readonly type: 'table-failed'; readonly message?: string }
20
+ | { readonly type: 'dismiss-notice' }
21
+
22
+ /** Empty browse before the load. */
23
+ export function storageState(): StorageState {
24
+ return { failed: false, tableFailed: false, noticeDismissed: false }
25
+ }
26
+
27
+ /**
28
+ * Storage browse transition. Read-only. A size notice does not block the app.
29
+ * @param state - current browse
30
+ * @param action - one client result
31
+ */
32
+ export function reduceStorage(state: StorageState, action: StorageAction): StorageState {
33
+ switch (action.type) {
34
+ case 'loaded':
35
+ return { ...state, summary: action.summary, failed: false }
36
+ case 'failed':
37
+ return { ...omitTableError(omitError(state)), failed: true, ...action.message ? { error: action.message } : {} }
38
+ case 'select':
39
+ return { ...state, table: action.table, tableFailed: false }
40
+ case 'table':
41
+ return { ...omitTableError(state), rows: action.rows, tableFailed: false }
42
+ case 'table-failed':
43
+ return { ...omitTableError(state), tableFailed: true, ...action.message ? { tableError: action.message } : {} }
44
+ case 'dismiss-notice':
45
+ return { ...state, noticeDismissed: true }
46
+ default:
47
+ return action satisfies never
48
+ }
49
+ }
50
+
51
+ /** Load the file size and table names. A thrown error is not an empty database. */
52
+ export async function loadStorage(
53
+ client: StorageClient,
54
+ appId: string,
55
+ dispatch: (action: StorageAction) => void,
56
+ ): Promise<void> {
57
+ try {
58
+ dispatch({ type: 'loaded', summary: await client.readStorage(appId) })
59
+ } catch (error) {
60
+ dispatch({ type: 'failed', ...thrown(error) })
61
+ }
62
+ }
63
+
64
+ /** Export one table. */
65
+ export async function loadTable(
66
+ client: StorageClient,
67
+ appId: string,
68
+ table: string,
69
+ dispatch: (action: StorageAction) => void,
70
+ ): Promise<void> {
71
+ dispatch({ type: 'select', table })
72
+ try {
73
+ dispatch({ type: 'table', rows: await client.readTable(appId, table) })
74
+ } catch (error) {
75
+ dispatch({ type: 'table-failed', ...thrown(error) })
76
+ }
77
+ }
78
+
79
+ function thrown(error: unknown): { message: string } | Record<string, never> {
80
+ const message = error instanceof Error ? error.message : ''
81
+ return message.length > 0 ? { message } : {}
82
+ }
83
+
84
+ function omitError(state: StorageState): Omit<StorageState, 'error'> {
85
+ const { error: gone, ...rest } = state
86
+ void gone
87
+ return rest
88
+ }
89
+
90
+ function omitTableError(state: StorageState): Omit<StorageState, 'tableError'> {
91
+ const { tableError: gone, ...rest } = state
92
+ void gone
93
+ return rest
94
+ }
@@ -0,0 +1,190 @@
1
+ import { useEffect, useReducer, useState, type ReactNode } from 'react'
2
+
3
+ import { panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
4
+ import { Dialog } from '../ui/dialog.tsx'
5
+ import type { StorageClient } from './client.ts'
6
+ import { loadStorage, loadTable, reduceStorage, storageState } from './state.ts'
7
+
8
+ /**
9
+ * Read-only storage browse. Hidden when Host exposes no storage client. No route string lives here.
10
+ * @param props - client, app, chrome locale, the pane close control, and an optional size notice
11
+ */
12
+ export function PanelStorage(props: {
13
+ readonly client?: StorageClient
14
+ readonly appId: string
15
+ readonly locale: PanelLocale
16
+ readonly mode: PanelLabelMode
17
+ readonly onClose?: () => void
18
+ readonly notice?: string
19
+ }): ReactNode {
20
+ const [state, dispatch] = useReducer(reduceStorage, undefined, storageState)
21
+ useEffect(() => {
22
+ if (props.client === undefined) return
23
+ void loadStorage(props.client, props.appId, dispatch)
24
+ }, [props.client, props.appId])
25
+ if (props.client === undefined) return null
26
+ const label = (key: string) => panelText(props.locale, key, props.mode)
27
+ const client = props.client
28
+ const rows = state.rows?.rows
29
+ const [raw, setRaw] = useState(false)
30
+ const [askRestore, setAskRestore] = useState(false)
31
+ return (
32
+ <section className="flex min-h-0 flex-1 flex-col bg-background text-foreground">
33
+ <header className="flex items-center justify-between gap-3 border-b bg-card px-4 py-3">
34
+ <div className="min-w-0">
35
+ <h3 className="text-[15px] font-semibold">{label('storage')}</h3>
36
+ {state.summary === undefined ? null : (
37
+ <p className="text-xs text-muted-foreground">{`${formatBytes(state.summary.bytes)} · ${state.summary.tables.length}`}</p>
38
+ )}
39
+ </div>
40
+ <div className="flex shrink-0 items-center gap-2">
41
+ {state.summary === undefined || client.restoreStorage === undefined ? null : (
42
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted" onClick={() => setAskRestore(true)}>{label('restore-storage')}</button>
43
+ )}
44
+ <PaneClose label={label('pane-close')} onClose={props.onClose} />
45
+ </div>
46
+ </header>
47
+ <div className="min-h-0 flex-1 overflow-auto">
48
+ {props.notice !== undefined && !state.noticeDismissed ? (
49
+ <div className="mx-4 mt-4 flex items-start justify-between gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 px-3 py-2">
50
+ <p className="min-w-0 flex-1 text-sm">{props.notice}</p>
51
+ <button type="button" className="h-8 shrink-0 rounded-lg px-2 text-sm hover:bg-muted" onClick={() => dispatch({ type: 'dismiss-notice' })}>{label('dismiss')}</button>
52
+ </div>
53
+ ) : null}
54
+ {state.failed ? <p className="px-4 py-3 text-sm text-destructive">{state.error !== undefined && state.error.length > 0 ? state.error : label('storage-failed')}</p> : null}
55
+ <div className="grid min-h-0 grid-cols-1 min-[720px]:grid-cols-[14rem_minmax(0,1fr)]">
56
+ <ul className="flex flex-col gap-2 p-3">
57
+ {(state.summary?.tables ?? []).map(table => (
58
+ <li key={table}>
59
+ <button
60
+ type="button"
61
+ className="w-full rounded-xl border bg-card px-3 py-2 text-left font-mono text-sm data-[on=1]:border-primary"
62
+ aria-pressed={state.table === table}
63
+ data-on={state.table === table ? '1' : '0'}
64
+ onClick={() => { void loadTable(client, props.appId, table, dispatch) }}
65
+ >{table}</button>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ <div className="min-w-0 p-4">
70
+ {state.tableFailed ? <p className="text-sm text-destructive">{state.tableError !== undefined && state.tableError.length > 0 ? state.tableError : label('storage-failed')}</p> : null}
71
+ {state.table === undefined ? null : (
72
+ <div>
73
+ <div className="mb-3 flex items-center justify-between gap-3">
74
+ <h4 className="font-mono text-sm">{state.table}</h4>
75
+ {rows === undefined || rows.length === 0 ? null : <ShapeSwitch raw={raw} label={label} onRaw={setRaw} />}
76
+ </div>
77
+ {rows === undefined ? null : rows.length === 0 ? <p className="text-sm text-muted-foreground">{label('storage-empty-rows')}</p> : raw ? (
78
+ <pre className="overflow-auto rounded-xl border bg-card p-3 font-mono text-[12px] leading-5 whitespace-pre-wrap">{JSON.stringify(rows, null, 2)}</pre>
79
+ ) : (
80
+ <div className="flex flex-col gap-3">
81
+ {rows.map((row, index) => (
82
+ <RecordCard key={recordKey(row, index)} row={row} />
83
+ ))}
84
+ </div>
85
+ )}
86
+ </div>
87
+ )}
88
+ </div>
89
+ </div>
90
+ </div>
91
+ {askRestore ? (
92
+ <Dialog width="sm" onClose={() => setAskRestore(false)}>
93
+ <p className="m-0 text-sm">{label('restore-storage-confirm')}</p>
94
+ <div className="mt-4 flex justify-end gap-2">
95
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={() => setAskRestore(false)}>{label('cancel')}</button>
96
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => {
97
+ setAskRestore(false)
98
+ void client.restoreStorage?.(props.appId)
99
+ }}>{label('restore')}</button>
100
+ </div>
101
+ </Dialog>
102
+ ) : null}
103
+ </section>
104
+ )
105
+ }
106
+
107
+ function RecordCard(props: { readonly row: unknown }): ReactNode {
108
+ if (isRecord(props.row) && typeof props.row.key === 'string' && 'value' in props.row) {
109
+ return (
110
+ <article className="overflow-hidden rounded-xl border bg-card">
111
+ <header className="border-b px-3 py-2 font-mono text-xs">{props.row.key}</header>
112
+ <div className="p-3"><ValueView value={props.row.value} /></div>
113
+ </article>
114
+ )
115
+ }
116
+ return (
117
+ <pre className="overflow-auto rounded-xl border bg-card p-3 font-mono text-[12px] leading-5 whitespace-pre-wrap">{JSON.stringify(props.row, null, 2)}</pre>
118
+ )
119
+ }
120
+
121
+ function ValueView(props: { readonly value: unknown }): ReactNode {
122
+ const items = titledItems(props.value)
123
+ if (items !== undefined) {
124
+ return (
125
+ <ul className="flex flex-col gap-1.5">
126
+ {items.map(item => (
127
+ <li key={item.key} className="flex items-baseline justify-between gap-3 text-sm">
128
+ <span className={item.done ? 'text-muted-foreground line-through' : ''}>{item.title}</span>
129
+ {item.extra.length === 0 ? null : <span className="shrink-0 text-[11px] text-muted-foreground">{item.extra}</span>}
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ )
134
+ }
135
+ if (props.value === null || typeof props.value === 'string' || typeof props.value === 'number' || typeof props.value === 'boolean') {
136
+ return <span className="text-sm">{props.value === null ? '—' : String(props.value)}</span>
137
+ }
138
+ return <pre className="overflow-auto font-mono text-[12px] leading-5 whitespace-pre-wrap">{JSON.stringify(props.value, null, 2)}</pre>
139
+ }
140
+
141
+ function ShapeSwitch(props: {
142
+ readonly raw: boolean
143
+ readonly label: (key: string) => string
144
+ readonly onRaw: (raw: boolean) => void
145
+ }): ReactNode {
146
+ return (
147
+ <div className="flex gap-1 rounded-lg bg-muted p-1">
148
+ <button type="button" className="h-7 rounded-md px-2.5 text-xs data-[on=1]:bg-card data-[on=1]:font-semibold" data-on={props.raw ? '0' : '1'} aria-pressed={!props.raw} onClick={() => props.onRaw(false)}>{props.label('storage-preview')}</button>
149
+ <button type="button" className="h-7 rounded-md px-2.5 text-xs data-[on=1]:bg-card data-[on=1]:font-semibold" data-on={props.raw ? '1' : '0'} aria-pressed={props.raw} onClick={() => props.onRaw(true)}>{props.label('storage-raw')}</button>
150
+ </div>
151
+ )
152
+ }
153
+
154
+ function PaneClose(props: { readonly label: string; readonly onClose?: (() => void) | undefined }): ReactNode {
155
+ if (props.onClose === undefined) return null
156
+ return (
157
+ <button type="button" className="h-8 rounded-lg border bg-background px-3 text-sm hover:bg-muted" onClick={props.onClose}>{props.label}</button>
158
+ )
159
+ }
160
+
161
+ function titledItems(value: unknown): readonly { key: string; title: string; done: boolean; extra: string }[] | undefined {
162
+ if (!Array.isArray(value) || value.length === 0 || !value.every(isRecord)) return undefined
163
+ if (!value.every(item => typeof item.title === 'string')) return undefined
164
+ return value.map((item, index) => ({
165
+ key: typeof item.id === 'string' ? item.id : String(index),
166
+ title: item.title as string,
167
+ done: item.done === true,
168
+ extra: [typeof item.due === 'string' ? item.due : '', Array.isArray(item.tags) ? item.tags.filter(tag => typeof tag === 'string').join(' ') : ''].filter(part => part.length > 0).join(' · '),
169
+ }))
170
+ }
171
+
172
+ function recordKey(row: unknown, index: number): string {
173
+ if (isRecord(row) && typeof row.key === 'string') return row.key
174
+ return String(index)
175
+ }
176
+
177
+ function isRecord(value: unknown): value is Record<string, unknown> {
178
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
179
+ }
180
+
181
+ function formatBytes(bytes: number): string {
182
+ if (bytes < 1024) return `${bytes} B`
183
+ const kb = bytes / 1024
184
+ if (kb < 1024) return `${trim(kb)} KB`
185
+ return `${trim(kb / 1024)} MB`
186
+ }
187
+
188
+ function trim(value: number): string {
189
+ return value >= 10 ? String(Math.round(value)) : String(Math.round(value * 10) / 10)
190
+ }
@@ -0,0 +1,72 @@
1
+ export const panelSections = ['gallery', 'settings', 'history', 'storage'] as const
2
+
3
+ export type PanelSection = (typeof panelSections)[number]
4
+
5
+ export interface SurfaceState {
6
+ readonly section: PanelSection
7
+ /** Palette popover is independent of the main pane so settings stays open. */
8
+ readonly themeOpen: boolean
9
+ readonly focus?: { readonly appId: string; readonly title?: string }
10
+ readonly notice?: string
11
+ readonly unavailable?: string
12
+ }
13
+
14
+ export type SurfaceAction =
15
+ | { readonly type: 'section'; readonly section: PanelSection }
16
+ | { readonly type: 'toggle-theme' }
17
+ | { readonly type: 'close-theme' }
18
+ | { readonly type: 'show-app'; readonly appId: string; readonly title?: string }
19
+ | { readonly type: 'show-notice'; readonly appId: string; readonly table: string }
20
+ | { readonly type: 'unavailable'; readonly appId: string }
21
+
22
+ /** The gallery section is first. */
23
+ export function surfaceState(): SurfaceState {
24
+ return { section: 'gallery', themeOpen: false }
25
+ }
26
+
27
+ /**
28
+ * Which pane is visible, and which app the host asked to show.
29
+ * Theme popover does not replace settings / history / storage.
30
+ * @param state - current surface
31
+ * @param action - a section change or a host event applied by Shell
32
+ */
33
+ export function reduceSurface(state: SurfaceState, action: SurfaceAction): SurfaceState {
34
+ switch (action.type) {
35
+ case 'section':
36
+ return {
37
+ ...state,
38
+ section: action.section,
39
+ // Opening another full pane dismisses the palette popover.
40
+ themeOpen: action.section === 'gallery' ? state.themeOpen : false,
41
+ }
42
+ case 'toggle-theme':
43
+ return { ...state, themeOpen: !state.themeOpen }
44
+ case 'close-theme':
45
+ return { ...state, themeOpen: false }
46
+ case 'show-app':
47
+ return {
48
+ ...withoutUnavailable(state),
49
+ section: 'gallery',
50
+ themeOpen: false,
51
+ focus: action.title === undefined ? { appId: action.appId } : { appId: action.appId, title: action.title },
52
+ }
53
+ case 'show-notice':
54
+ return {
55
+ ...state,
56
+ section: 'storage',
57
+ themeOpen: false,
58
+ notice: action.table,
59
+ focus: state.focus?.appId === action.appId ? state.focus : { appId: action.appId },
60
+ }
61
+ case 'unavailable':
62
+ return { ...state, section: 'gallery', themeOpen: false, unavailable: action.appId }
63
+ default:
64
+ return action satisfies never
65
+ }
66
+ }
67
+
68
+ function withoutUnavailable(state: SurfaceState): Omit<SurfaceState, 'unavailable'> {
69
+ const { unavailable: gone, ...rest } = state
70
+ void gone
71
+ return rest
72
+ }
@@ -0,0 +1,255 @@
1
+ import { useEffect, useReducer, useState, type ReactNode } from 'react'
2
+ import { Clock, Database } from 'lucide-react'
3
+
4
+ import type { PanelClient } from '../gallery/client.ts'
5
+ import { PanelGallery } from '../gallery/view.tsx'
6
+ import { PanelHistory } from '../history/view.tsx'
7
+ import type { HistoryClient } from '../history/client.ts'
8
+ import { isPanelLocale, panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
9
+ import type { PanelPolicy, PanelSettingsClient, PanelUpdateCheck } from '../settings/client.ts'
10
+ import { attemptCard, UpdateDialog, type UpdateCard } from '../settings/update-dialog.tsx'
11
+ import { Tooltip } from '../ui/tooltip.tsx'
12
+ import { PanelSettings } from '../settings/view.tsx'
13
+ import type { StorageClient } from '../storage/client.ts'
14
+ import { PanelStorage } from '../storage/view.tsx'
15
+ import type { PaletteChip, ThemeClient } from '../theme/client.ts'
16
+ import { ThemeMenu } from '../theme/menu.tsx'
17
+ import { reduceSurface, surfaceState, type PanelSection } from './state.ts'
18
+
19
+ /** Commands Shell can call. Same shape as the shell bridge. No route string lives here. */
20
+ export interface PanelControls {
21
+ showApp(appId: string, title?: string): void
22
+ showNotice(appId: string, table: string): void
23
+ unavailable(appId: string): void
24
+ setWorkbench(appId: string): void
25
+ }
26
+
27
+ /**
28
+ * The panel as one surface. A missing client hides that section.
29
+ * @param props - injected clients and chrome locale
30
+ */
31
+ export function PanelSurface(props: {
32
+ readonly client: PanelClient
33
+ readonly settings?: PanelSettingsClient
34
+ readonly history?: HistoryClient
35
+ readonly storage?: StorageClient
36
+ readonly theme?: ThemeClient
37
+ readonly appId?: string
38
+ readonly locale: PanelLocale
39
+ readonly mode: PanelLabelMode
40
+ readonly shell: 'standalone' | 'overlay'
41
+ readonly frame?: (appId: string) => ReactNode
42
+ readonly onClosePanel?: () => void
43
+ readonly versions?: string
44
+ readonly onControls?: (controls: PanelControls) => void
45
+ }): ReactNode {
46
+ const [state, dispatch] = useReducer(reduceSurface, undefined, surfaceState)
47
+ const [palettes, setPalettes] = useState<readonly PaletteChip[]>([])
48
+ const [desk, setDesk] = useState<string | undefined>(undefined)
49
+ const [hostPolicy, setHostPolicy] = useState<PanelPolicy | undefined>(undefined)
50
+ const [chromeLocale, setChromeLocale] = useState<PanelLocale>(props.locale)
51
+ const [updateCard, setUpdateCard] = useState<UpdateCard | undefined>(undefined)
52
+ useEffect(() => {
53
+ const check = props.settings?.checkUpdate
54
+ if (check === undefined) return
55
+ void check().then((result) => {
56
+ const card = receiveUpdate(result, props.settings)
57
+ if (card !== undefined) setUpdateCard(card)
58
+ }, () => undefined)
59
+ }, [props.settings])
60
+ useEffect(() => {
61
+ setChromeLocale(props.locale)
62
+ }, [props.locale])
63
+ useEffect(() => {
64
+ props.onControls?.({
65
+ showApp: (appId, title) => dispatch({ type: 'show-app', appId, ...title === undefined ? {} : { title } }),
66
+ showNotice: (appId, table) => dispatch({ type: 'show-notice', appId, table }),
67
+ unavailable: appId => dispatch({ type: 'unavailable', appId }),
68
+ setWorkbench: appId => setDesk(appId === 'default' ? undefined : appId),
69
+ })
70
+ }, [props.onControls])
71
+ useEffect(() => {
72
+ if (props.settings === undefined) return
73
+ void props.settings.readPolicy().then(
74
+ policy => setDesk(policy.defaultWorkbenchId),
75
+ () => setDesk(undefined),
76
+ )
77
+ }, [props.settings])
78
+ useEffect(() => {
79
+ if (props.theme === undefined) return
80
+ void props.theme.listPalettes().then(
81
+ listed => setPalettes(listed.palettes),
82
+ () => setPalettes([]),
83
+ )
84
+ }, [props.theme])
85
+ const label = (key: string) => panelText(chromeLocale, key, props.mode)
86
+ useEffect(() => {
87
+ document.title = panelText(chromeLocale, 'product-name', props.mode)
88
+ }, [chromeLocale, props.mode])
89
+ const appId = state.focus?.appId ?? props.appId
90
+ const toggle = (section: PanelSection) => dispatch({ type: 'section', section: state.section === section ? 'gallery' : section })
91
+ const tool = (section: PanelSection, title: string, icon: ReactNode) => (
92
+ <Tooltip key={section} text={title}>
93
+ <button type="button" className="inline-flex size-8 items-center justify-center rounded-lg transition-colors duration-150 hover:bg-muted" aria-label={title} onClick={() => toggle(section)}>
94
+ <span className="sr-only">{title}</span>
95
+ {icon}
96
+ </button>
97
+ </Tooltip>
98
+ )
99
+ return (
100
+ <PanelGallery
101
+ client={props.client}
102
+ locale={chromeLocale}
103
+ mode={props.mode}
104
+ shell={props.shell}
105
+ {...desk === undefined ? {} : { defaultWorkbenchId: desk }}
106
+ {...props.settings === undefined ? {} : { onSetDefaultWorkbench: (id: string) => { void writeDefault(props.settings, id, setDesk) } }}
107
+ {...props.theme === undefined ? {} : {
108
+ themeOpen: state.themeOpen,
109
+ onToggleTheme: () => dispatch({ type: 'toggle-theme' }),
110
+ }}
111
+ {...props.settings === undefined ? {} : { onToggleSettings: () => toggle('settings') }}
112
+ theme={app => (
113
+ <ThemeMenu
114
+ locale={chromeLocale}
115
+ mode={props.mode}
116
+ {...props.theme === undefined ? {} : { theme: props.theme }}
117
+ {...props.settings === undefined ? {} : { settings: props.settings }}
118
+ {...app === undefined ? {} : { appId: app.id, appTitle: app.title }}
119
+ {...hostPolicy === undefined ? {} : { hostPolicy }}
120
+ onHostPolicy={setHostPolicy}
121
+ />
122
+ )}
123
+ tools={(
124
+ <>
125
+ {props.history !== undefined && appId !== undefined ? tool('history', label('history'), <Clock size={16} />) : null}
126
+ {props.storage !== undefined && appId !== undefined ? tool('storage', label('storage'), <Database size={16} />) : null}
127
+ </>
128
+ )}
129
+ overlay={chrome => (
130
+ <>
131
+ {state.unavailable === undefined ? null : <p className="px-6 py-2 text-sm text-destructive">{label('host-unreachable')}</p>}
132
+ {props.settings === undefined || state.section !== 'settings' ? null : (
133
+ <div className="mma-pane absolute inset-0 z-30 flex flex-col bg-background">
134
+ <PanelSettings
135
+ locale={chromeLocale}
136
+ mode={props.mode}
137
+ palettes={palettes}
138
+ cardStyle={chrome.cardStyle}
139
+ onCardStyle={chrome.setCard}
140
+ client={props.settings}
141
+ onPreviewLocale={setChromeLocale}
142
+ onUpdateOffer={(result) => {
143
+ const card = receiveUpdate(result, props.settings)
144
+ if (card !== undefined) setUpdateCard(card)
145
+ }}
146
+ onPolicy={(policy) => {
147
+ setDesk(policy.defaultWorkbenchId)
148
+ setHostPolicy(policy)
149
+ if (isPanelLocale(policy.locale)) setChromeLocale(policy.locale)
150
+ }}
151
+ {...hostPolicy === undefined ? {} : { hostPolicy }}
152
+ onClose={() => dispatch({ type: 'section', section: 'gallery' })}
153
+ {...props.versions === undefined ? {} : { versions: props.versions }}
154
+ />
155
+ </div>
156
+ )}
157
+ {state.section === 'history' && appId !== undefined ? (
158
+ <div className="mma-pane absolute inset-0 z-20 flex min-h-0 flex-col bg-background" data-open="1">
159
+ <PanelHistory
160
+ key={appId}
161
+ appId={appId}
162
+ locale={chromeLocale}
163
+ mode={props.mode}
164
+ onClose={() => dispatch({ type: 'section', section: 'gallery' })}
165
+ {...props.history === undefined ? {} : { client: props.history }}
166
+ />
167
+ </div>
168
+ ) : null}
169
+ {state.section === 'storage' && appId !== undefined ? (
170
+ <div className="mma-pane absolute inset-0 z-20 flex min-h-0 flex-col bg-background" data-open="1">
171
+ <PanelStorage
172
+ key={appId}
173
+ appId={appId}
174
+ locale={chromeLocale}
175
+ mode={props.mode}
176
+ onClose={() => dispatch({ type: 'section', section: 'gallery' })}
177
+ {...props.storage === undefined ? {} : { client: props.storage }}
178
+ {...state.notice === undefined ? {} : { notice: label('storage-size-notice').replaceAll('{table}', state.notice) }}
179
+ />
180
+ </div>
181
+ ) : null}
182
+ {updateCard === undefined ? null : (
183
+ <UpdateDialog
184
+ locale={chromeLocale}
185
+ mode={props.mode}
186
+ card={updateCard}
187
+ onClose={() => {
188
+ if (updateCard.kind === 'offer') sessionStorage.setItem(updateDismissedKey, updateCard.latest)
189
+ setUpdateCard(undefined)
190
+ }}
191
+ onInstall={(version) => {
192
+ const install = props.settings?.installUpdate
193
+ if (install === undefined) return
194
+ setUpdateCard({ kind: 'installing', version, startedAt: Date.now() })
195
+ void install(version).then(() => undefined, (error: unknown) => {
196
+ const detail = error instanceof Error ? error.message : ''
197
+ setUpdateCard({
198
+ kind: 'failed',
199
+ reason: detail.length === 0 ? { text: label('update-install-failed') } : { text: detail },
200
+ target: version,
201
+ })
202
+ })
203
+ }}
204
+ />
205
+ )}
206
+ </>
207
+ )}
208
+ {...state.focus === undefined ? {} : { focus: state.focus }}
209
+ {...props.frame === undefined ? {} : { frame: props.frame }}
210
+ {...props.onClosePanel === undefined ? {} : { onClosePanel: props.onClosePanel }}
211
+ />
212
+ )
213
+ }
214
+
215
+ async function writeDefault(
216
+ settings: PanelSettingsClient | undefined,
217
+ id: string,
218
+ setDesk: (value: string | undefined) => void,
219
+ ): Promise<void> {
220
+ if (settings === undefined) return
221
+ const current = await settings.readPolicy()
222
+ const { defaultWorkbenchId: gone, ...rest } = current
223
+ void gone
224
+ const next: PanelPolicy = { ...rest, defaultWorkbenchId: id }
225
+ const written = await settings.writePolicy(next)
226
+ setDesk(written.policy.defaultWorkbenchId)
227
+ }
228
+
229
+ /** An offer the owner put away stays away for this window. */
230
+ const updateDismissedKey = 'mini-app.update-dismissed'
231
+
232
+ /**
233
+ * The card one check opens, if any. A recorded outcome outranks a fresh offer, and the host is told
234
+ * it was shown, so the next check does not open it again.
235
+ * @param check - the registry check, with the launcher's last attempt when one is on disk
236
+ * @param settings - the client the acknowledgement goes to
237
+ */
238
+ function receiveUpdate(check: PanelUpdateCheck, settings: PanelSettingsClient | undefined): UpdateCard | undefined {
239
+ const attempt = check.lastAttempt
240
+ if (attempt === undefined) return offerCard(check)
241
+ const ack = settings?.ackUpdate
242
+ if (ack !== undefined) void ack(attempt.at).catch(() => undefined)
243
+ return attemptCard(attempt, check.current)
244
+ }
245
+
246
+ function offerCard(check: PanelUpdateCheck): UpdateCard | undefined {
247
+ if (!check.updateAvailable || check.installable !== true || check.latest === null) return undefined
248
+ if (sessionStorage.getItem(updateDismissedKey) === check.latest) return undefined
249
+ return {
250
+ kind: 'offer',
251
+ current: check.current,
252
+ latest: check.latest,
253
+ ...check.channel === undefined ? {} : { channel: check.channel },
254
+ }
255
+ }
@@ -0,0 +1,34 @@
1
+ /** One palette chip. Ignored files are not chips. */
2
+ export interface PaletteChip {
3
+ readonly id: string
4
+ /** English (or sole) display name. */
5
+ readonly name: string
6
+ /** Chinese display name when the theme file provides one. */
7
+ readonly nameZh?: string
8
+ readonly swatch?: string
9
+ readonly style?: string
10
+ /** Shipped file, or a file from the themes directory. A user file that replaces a shipped id is custom. */
11
+ readonly origin?: 'builtin' | 'custom'
12
+ }
13
+
14
+ export interface IgnoredPalette {
15
+ readonly file: string
16
+ readonly reason: string
17
+ }
18
+
19
+ /** Pin the owner can set. Clear is `default`, not deletion of `theme.css`. */
20
+ export type ThemePin =
21
+ | { readonly kind: 'default' }
22
+ | { readonly kind: 'follow-host' }
23
+ | { readonly kind: 'app-file' }
24
+ | { readonly kind: 'palette'; readonly id: string }
25
+
26
+ /** Theme reads and pin writes. Absent means the picker is hidden. No route string lives here. */
27
+ export interface ThemeClient {
28
+ listPalettes(): Promise<{ palettes: readonly PaletteChip[]; ignored: readonly IgnoredPalette[] }>
29
+ readPin?(appId: string): Promise<ThemePin>
30
+ setPin(appId: string, pin: ThemePin): Promise<ThemePin>
31
+ appFile?(appId: string): Promise<boolean>
32
+ /** Parsed `theme.css` for this app. Null when the file is missing or ignored. */
33
+ readAppTheme?(appId: string): Promise<{ name?: string; nameZh?: string; swatch?: string; style?: string } | null>
34
+ }