@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,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
|
+
}
|