@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,26 @@
1
+ import type { GalleryApp } from './list.ts'
2
+
3
+ /** Host calls the view may make. No route string lives here. */
4
+ export interface PanelClient {
5
+ list(): Promise<readonly GalleryApp[]>
6
+ open(appId: string, title?: string): Promise<void>
7
+ deleteApp(appId: string): Promise<void>
8
+ reload?(appId: string): Promise<void>
9
+ listTrash?(): Promise<readonly GalleryApp[]>
10
+ undeleteApp?(appId: string): Promise<void>
11
+ }
12
+
13
+ /** A client failure the view can tell from an empty list. */
14
+ export class PanelClientError extends Error {
15
+ readonly code: 'unreachable' | 'failed'
16
+
17
+ /**
18
+ * @param code - `unreachable` is not an empty gallery
19
+ * @param message - human text
20
+ */
21
+ constructor(code: 'unreachable' | 'failed', message: string) {
22
+ super(message)
23
+ this.name = 'PanelClientError'
24
+ this.code = code
25
+ }
26
+ }
@@ -0,0 +1,54 @@
1
+ import { LayoutGrid } from 'lucide-react'
2
+ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
3
+
4
+ export interface DeskChoice {
5
+ readonly id: string
6
+ readonly name: string
7
+ readonly icon?: 'library'
8
+ readonly mark?: string
9
+ }
10
+
11
+ /** Glass switch for the home slot. It does not write the stored id. */
12
+ export function DeskBar(props: {
13
+ readonly label: string
14
+ readonly choices: readonly DeskChoice[]
15
+ readonly selected: string
16
+ readonly fit?: boolean
17
+ onSelect(id: string): void
18
+ }): ReactNode {
19
+ const row = useRef<HTMLDivElement>(null)
20
+ const [pill, setPill] = useState<{ left: number; width: number } | undefined>(undefined)
21
+ useLayoutEffect(() => {
22
+ const root = row.current
23
+ if (root === null) return
24
+ const current = root.querySelector('[data-on="1"]')
25
+ if (!(current instanceof HTMLElement)) return
26
+ setPill({ left: current.offsetLeft, width: current.offsetWidth })
27
+ }, [props.selected, props.choices])
28
+ return (
29
+ <div ref={row} className={props.fit === true ? 'mma-desk mma-desk-fit' : 'mma-desk'} role="tablist" aria-label={props.label}>
30
+ {pill === undefined ? null : (
31
+ <span className="mma-desk-pill" style={{ width: pill.width, transform: `translateX(${pill.left}px)` }} />
32
+ )}
33
+ {props.choices.map(choice => (
34
+ <button
35
+ key={choice.id}
36
+ type="button"
37
+ role="tab"
38
+ className="mma-desk-item"
39
+ data-desk={choice.id}
40
+ data-on={choice.id === props.selected ? '1' : '0'}
41
+ aria-selected={choice.id === props.selected}
42
+ onClick={() => {
43
+ if (choice.id === props.selected) return
44
+ props.onSelect(choice.id)
45
+ }}
46
+ >
47
+ {choice.icon === 'library' ? <LayoutGrid size={14} strokeWidth={2} /> : null}
48
+ {choice.mark === undefined ? null : <span className="mma-desk-mark">{choice.mark}</span>}
49
+ <span className="mma-desk-name">{choice.name}</span>
50
+ </button>
51
+ ))}
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,54 @@
1
+ /** Card styles. Panel-local. Not a host field. */
2
+ export const galleryCardStyles = ['glass', 'stamp', 'etch', 'hero', 'pulse', 'list'] as const
3
+
4
+ export type GalleryCardStyle = (typeof galleryCardStyles)[number]
5
+
6
+ export interface GalleryApp {
7
+ readonly id: string
8
+ readonly name: string
9
+ readonly description: string
10
+ readonly version: string
11
+ readonly acronym: string
12
+ readonly tags?: readonly string[]
13
+ readonly createdAt?: string
14
+ readonly updatedAt?: string
15
+ readonly activity?: { readonly openCount: number; readonly lastOpenedAt: string }
16
+ readonly kind?: 'workbench'
17
+ }
18
+
19
+ export type GalleryKind = 'unreachable' | 'failed' | 'empty' | 'none' | 'ready'
20
+
21
+ /**
22
+ * Search the visible card fields. An empty query returns the list unchanged.
23
+ * @param apps - host list
24
+ * @param query - panel-local search
25
+ */
26
+ export function filterGallery(apps: readonly GalleryApp[], query: string): GalleryApp[] {
27
+ const needle = query.trim().toLowerCase()
28
+ if (needle.length === 0) return [...apps]
29
+ return apps.filter(app =>
30
+ app.name.toLowerCase().includes(needle)
31
+ || app.description.toLowerCase().includes(needle)
32
+ || app.acronym.toLowerCase().includes(needle)
33
+ || app.version.toLowerCase().includes(needle))
34
+ }
35
+
36
+ /**
37
+ * Host unreachable is not an empty gallery. An empty ready list is the empty state.
38
+ * @param input - the list call outcome
39
+ */
40
+ export function galleryKind(input: {
41
+ readonly reachable: boolean
42
+ readonly failed?: string
43
+ readonly apps: readonly GalleryApp[]
44
+ readonly query?: string
45
+ }): GalleryKind {
46
+ if (!input.reachable) return 'unreachable'
47
+ if (input.failed !== undefined) return 'failed'
48
+ if (input.apps.length === 0) return input.query !== undefined && input.query.trim().length > 0 ? 'none' : 'empty'
49
+ return 'ready'
50
+ }
51
+
52
+ export function isGalleryCardStyle(value: string): value is GalleryCardStyle {
53
+ return (galleryCardStyles as readonly string[]).includes(value)
54
+ }
@@ -0,0 +1,72 @@
1
+ import type { GalleryApp } from './list.ts'
2
+
3
+ /** Built-in orders for the workbench app list. */
4
+ export const appSortings = {
5
+ createdTime: 'created-time',
6
+ updatedTime: 'updated-time',
7
+ heat: 'heat',
8
+ } as const
9
+
10
+ export type AppSorting = typeof appSortings[keyof typeof appSortings]
11
+
12
+ export type SortDirection = 'asc' | 'desc'
13
+
14
+ /** The heat fields this sort reads. An id that is absent counts as zero opens. */
15
+ export interface SortHeat {
16
+ readonly lastOpenedAt: string
17
+ readonly openCount: number
18
+ }
19
+
20
+ /**
21
+ * Return a new array. Does not write the source.
22
+ * Heat uses `openCount`, then `lastOpenedAt`. A missing time sorts last in both directions.
23
+ * @param apps - the list to order
24
+ * @param sorting - created time, updated time, or heat
25
+ * @param direction - `asc` or `desc`
26
+ * @param heat - heat snapshot; ignored by the time sorts
27
+ */
28
+ export function sortApps(
29
+ apps: readonly GalleryApp[],
30
+ sorting: AppSorting,
31
+ direction: SortDirection,
32
+ heat: Readonly<Record<string, SortHeat>>,
33
+ ): GalleryApp[] {
34
+ return apps.map((app, index) => ({ app, index })).sort((left, right) => {
35
+ const compared = sorting === appSortings.heat
36
+ ? compareHeat(left.app.id, right.app.id, heat, direction)
37
+ : compareTime(timeOf(left.app, sorting), timeOf(right.app, sorting), direction)
38
+ return compared === 0 ? left.index - right.index : compared
39
+ }).map(row => row.app)
40
+ }
41
+
42
+ function timeOf(app: GalleryApp, sorting: AppSorting): string | undefined {
43
+ return sorting === appSortings.createdTime ? app.createdAt : app.updatedAt
44
+ }
45
+
46
+ function compareHeat(
47
+ leftId: string,
48
+ rightId: string,
49
+ heat: Readonly<Record<string, SortHeat>>,
50
+ direction: SortDirection,
51
+ ): number {
52
+ const left = heat[leftId]
53
+ const right = heat[rightId]
54
+ const byCount = compareNumber(left?.openCount ?? 0, right?.openCount ?? 0, direction)
55
+ if (byCount !== 0) return byCount
56
+ return compareTime(left?.lastOpenedAt, right?.lastOpenedAt, direction)
57
+ }
58
+
59
+ function compareNumber(left: number, right: number, direction: SortDirection): number {
60
+ if (left === right) return 0
61
+ const order = left < right ? -1 : 1
62
+ return direction === 'asc' ? order : -order
63
+ }
64
+
65
+ function compareTime(left: string | undefined, right: string | undefined, direction: SortDirection): number {
66
+ if (left === undefined && right === undefined) return 0
67
+ if (left === undefined) return 1
68
+ if (right === undefined) return -1
69
+ if (left === right) return 0
70
+ const order = left < right ? -1 : 1
71
+ return direction === 'asc' ? order : -order
72
+ }
@@ -0,0 +1,229 @@
1
+ import type { PanelClient, PanelClientError } from './client.ts'
2
+ import {
3
+ type GalleryApp,
4
+ type GalleryCardStyle,
5
+ galleryKind,
6
+ type GalleryKind,
7
+ filterGallery,
8
+ } from './list.ts'
9
+ import { appSortings, sortApps } from './sort.ts'
10
+ import {
11
+ closeTab,
12
+ dropAppTab,
13
+ deletePrompt,
14
+ initialTabs,
15
+ openAppTab,
16
+ type PanelTabs,
17
+ switchTab,
18
+ type DeletePrompt,
19
+ } from './tabs.ts'
20
+
21
+ export type PanelShell = 'standalone' | 'overlay'
22
+
23
+ export interface GalleryState {
24
+ readonly shell: PanelShell
25
+ readonly cardStyle: GalleryCardStyle
26
+ readonly query: string
27
+ readonly reachable: boolean
28
+ readonly failed?: string
29
+ readonly apps: readonly GalleryApp[]
30
+ readonly tabs: PanelTabs
31
+ readonly deletePrompt: DeletePrompt
32
+ readonly deleteError?: string
33
+ readonly openError?: string
34
+ readonly pendingDeleteId?: string
35
+ readonly trash: readonly GalleryApp[]
36
+ readonly trashFailed: boolean
37
+ readonly frameError?: string
38
+ }
39
+
40
+ export type GalleryAction =
41
+ | { readonly type: 'unreachable' }
42
+ | { readonly type: 'list-failed'; readonly message: string }
43
+ | { readonly type: 'listed'; readonly apps: readonly GalleryApp[] }
44
+ | { readonly type: 'search'; readonly query: string }
45
+ | { readonly type: 'open'; readonly appId: string; readonly title?: string }
46
+ | { readonly type: 'open-failed'; readonly message?: string }
47
+ | { readonly type: 'drop-app'; readonly appId: string }
48
+ | { readonly type: 'switch'; readonly index: number }
49
+ | { readonly type: 'close'; readonly index: number }
50
+ | { readonly type: 'ask-delete'; readonly appId: string }
51
+ | { readonly type: 'cancel-delete' }
52
+ | { readonly type: 'delete-failed'; readonly message?: string }
53
+ | { readonly type: 'deleted'; readonly appId: string }
54
+ | { readonly type: 'card'; readonly cardStyle: GalleryCardStyle }
55
+ | { readonly type: 'trash'; readonly apps: readonly GalleryApp[] }
56
+ | { readonly type: 'trash-failed' }
57
+ | { readonly type: 'undeleted'; readonly appId: string }
58
+ | { readonly type: 'frame-error'; readonly message: string }
59
+
60
+ /** First view state. The gallery tab is open. */
61
+ export function galleryState(shell: PanelShell): GalleryState {
62
+ return {
63
+ shell,
64
+ cardStyle: 'glass',
65
+ query: '',
66
+ reachable: true,
67
+ apps: [],
68
+ tabs: initialTabs(),
69
+ deletePrompt: 'idle',
70
+ trash: [],
71
+ trashFailed: false,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Panel view transition. Search, tabs, and delete confirmation stay in the existing functions.
77
+ * @param state - current view
78
+ * @param action - one user or client result
79
+ */
80
+ export function reduceGallery(state: GalleryState, action: GalleryAction): GalleryState {
81
+ switch (action.type) {
82
+ case 'unreachable':
83
+ return { ...state, reachable: false, apps: [] }
84
+ case 'list-failed':
85
+ return { ...state, reachable: true, failed: action.message, apps: [] }
86
+ case 'listed':
87
+ return {
88
+ shell: state.shell,
89
+ cardStyle: state.cardStyle,
90
+ query: state.query,
91
+ reachable: true,
92
+ apps: action.apps,
93
+ tabs: state.tabs,
94
+ deletePrompt: state.deletePrompt,
95
+ trash: state.trash,
96
+ trashFailed: state.trashFailed,
97
+ ...state.pendingDeleteId === undefined ? {} : { pendingDeleteId: state.pendingDeleteId },
98
+ }
99
+ case 'search':
100
+ return { ...state, query: action.query }
101
+ case 'open':
102
+ return { ...omitOpenError(state), tabs: openAppTab(state.tabs, action.appId, action.title).tabs }
103
+ case 'open-failed':
104
+ return { ...omitOpenError(state), ...action.message ? { openError: action.message } : { openError: '' } }
105
+ case 'drop-app':
106
+ return { ...state, tabs: dropAppTab(state.tabs, action.appId) }
107
+ case 'switch':
108
+ return { ...state, tabs: switchTab(state.tabs, action.index).tabs }
109
+ case 'close':
110
+ return { ...state, tabs: closeTab(state.tabs, action.index) }
111
+ case 'ask-delete':
112
+ return { ...omitDeleteError(state), pendingDeleteId: action.appId, deletePrompt: deletePrompt(state.deletePrompt, 'ask') }
113
+ case 'cancel-delete':
114
+ return { ...omitDeleteError(omitPending(state)), deletePrompt: deletePrompt(state.deletePrompt, 'cancel') }
115
+ case 'delete-failed':
116
+ return {
117
+ ...omitDeleteError(state),
118
+ deletePrompt: deletePrompt(state.deletePrompt, 'fail'),
119
+ ...action.message ? { deleteError: action.message } : {},
120
+ }
121
+ case 'deleted':
122
+ return {
123
+ ...omitPending(state),
124
+ apps: state.apps.filter(app => app.id !== action.appId),
125
+ tabs: closeApp(state.tabs, action.appId),
126
+ deletePrompt: 'idle',
127
+ }
128
+ case 'card':
129
+ return { ...state, cardStyle: action.cardStyle }
130
+ case 'trash':
131
+ return { ...state, trash: action.apps, trashFailed: false }
132
+ case 'trash-failed':
133
+ return { ...state, trashFailed: true }
134
+ case 'undeleted': {
135
+ const restored = state.trash.find(app => app.id === action.appId)
136
+ return {
137
+ ...state,
138
+ trash: state.trash.filter(app => app.id !== action.appId),
139
+ apps: restored === undefined ? state.apps : [...state.apps, restored],
140
+ }
141
+ }
142
+ case 'frame-error':
143
+ return { ...state, frameError: action.message }
144
+ default:
145
+ return action satisfies never
146
+ }
147
+ }
148
+
149
+ /** Cards the gallery should show. Search does not change the host list. */
150
+ export function visibleApps(state: GalleryState): GalleryApp[] {
151
+ return filterGallery(state.apps, state.query)
152
+ }
153
+
154
+ /** Gallery chrome state. Unreachable is not empty. */
155
+ export function viewKind(state: GalleryState): GalleryKind {
156
+ return galleryKind({
157
+ reachable: state.reachable,
158
+ ...state.failed === undefined ? {} : { failed: state.failed },
159
+ apps: visibleApps(state),
160
+ ...state.query.trim().length === 0 ? {} : { query: state.query },
161
+ })
162
+ }
163
+
164
+ /** Load the gallery. A thrown unreachable error is not an empty list. */
165
+ export async function loadGallery(
166
+ client: PanelClient,
167
+ dispatch: (action: GalleryAction) => void,
168
+ ): Promise<void> {
169
+ try {
170
+ const apps = await client.list()
171
+ dispatch({ type: 'listed', apps: await orderByHeat(client, apps) })
172
+ } catch (error) {
173
+ if (isUnreachable(error)) {
174
+ dispatch({ type: 'unreachable' })
175
+ return
176
+ }
177
+ dispatch({ type: 'list-failed', message: error instanceof Error ? error.message : '' })
178
+ }
179
+ }
180
+
181
+ /** Open count is on each app. An app with no activity counts as zero. */
182
+ function orderByHeat(_client: PanelClient, apps: readonly GalleryApp[]): GalleryApp[] {
183
+ const heat: Record<string, { lastOpenedAt: string; openCount: number }> = {}
184
+ for (const app of apps) {
185
+ const row = app.activity
186
+ if (row !== undefined) heat[app.id] = row
187
+ }
188
+ return sortApps([...apps], appSortings.heat, 'desc', heat)
189
+ }
190
+
191
+ function omitOpenError(state: GalleryState): Omit<GalleryState, 'openError'> {
192
+ const { openError: gone, ...rest } = state
193
+ void gone
194
+ return rest
195
+ }
196
+
197
+ function omitDeleteError(state: GalleryState): Omit<GalleryState, 'deleteError'> {
198
+ const { deleteError: gone, ...rest } = state
199
+ void gone
200
+ return rest
201
+ }
202
+
203
+ function omitPending(state: GalleryState): Omit<GalleryState, 'pendingDeleteId'> {
204
+ const { pendingDeleteId: _pending, ...rest } = state
205
+ void _pending
206
+ return rest
207
+ }
208
+
209
+ /** Load trash when the client can list it. */
210
+ export async function loadTrash(
211
+ client: PanelClient,
212
+ dispatch: (action: GalleryAction) => void,
213
+ ): Promise<void> {
214
+ if (client.listTrash === undefined) return
215
+ try {
216
+ dispatch({ type: 'trash', apps: await client.listTrash() })
217
+ } catch {
218
+ dispatch({ type: 'trash-failed' })
219
+ }
220
+ }
221
+
222
+ function closeApp(tabs: PanelTabs, appId: string): PanelTabs {
223
+ const index = tabs.tabs.findIndex(tab => tab.kind === 'app' && tab.appId === appId)
224
+ return index < 0 ? tabs : closeTab(tabs, index)
225
+ }
226
+
227
+ function isUnreachable(error: unknown): error is PanelClientError {
228
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'unreachable'
229
+ }
@@ -0,0 +1,85 @@
1
+ export interface AppTab {
2
+ readonly kind: 'app'
3
+ readonly appId: string
4
+ readonly title?: string
5
+ }
6
+
7
+ export interface GalleryTab {
8
+ readonly kind: 'gallery'
9
+ }
10
+
11
+ export type PanelTab = GalleryTab | AppTab
12
+
13
+ export interface PanelTabs {
14
+ readonly tabs: readonly PanelTab[]
15
+ readonly active: number
16
+ }
17
+
18
+ export interface TabChange {
19
+ readonly tabs: PanelTabs
20
+ /** Tab switch never reloads the document. Opening an already open tab does not either. */
21
+ readonly reload: false
22
+ }
23
+
24
+ /** The gallery tab is always present and is not closable. */
25
+ export function initialTabs(): PanelTabs {
26
+ return { tabs: [{ kind: 'gallery' }], active: 0 }
27
+ }
28
+
29
+ /**
30
+ * Open or focus an app tab. Does not reload a document that is already open.
31
+ * @param state - current tabs
32
+ * @param appId - app to show
33
+ * @param title - optional tab title
34
+ */
35
+ export function openAppTab(state: PanelTabs, appId: string, title?: string): TabChange {
36
+ const existing = state.tabs.findIndex(tab => tab.kind === 'app' && tab.appId === appId)
37
+ if (existing >= 0) {
38
+ const next = title === undefined
39
+ ? state.tabs
40
+ : state.tabs.map((tab, index) => index === existing && tab.kind === 'app' ? { ...tab, title } : tab)
41
+ return { tabs: { tabs: next, active: existing }, reload: false }
42
+ }
43
+ const tab: AppTab = title === undefined ? { kind: 'app', appId } : { kind: 'app', appId, title }
44
+ return { tabs: { tabs: [...state.tabs, tab], active: state.tabs.length }, reload: false }
45
+ }
46
+
47
+ /**
48
+ * Switch tabs. This never reloads the document.
49
+ * @param state - current tabs
50
+ * @param index - tab index
51
+ */
52
+ export function switchTab(state: PanelTabs, index: number): TabChange {
53
+ if (index < 0 || index >= state.tabs.length) return { tabs: state, reload: false }
54
+ return { tabs: { tabs: state.tabs, active: index }, reload: false }
55
+ }
56
+
57
+ /** Remove one app tab after a failed open. The gallery tab stays. */
58
+ export function dropAppTab(state: PanelTabs, appId: string): PanelTabs {
59
+ const index = state.tabs.findIndex(tab => tab.kind === 'app' && tab.appId === appId)
60
+ if (index < 0) return state
61
+ return closeTab(state, index)
62
+ }
63
+
64
+ /**
65
+ * Close an app tab. The gallery tab stays.
66
+ * @param state - current tabs
67
+ * @param index - tab index
68
+ */
69
+ export function closeTab(state: PanelTabs, index: number): PanelTabs {
70
+ const tab = state.tabs[index]
71
+ if (tab === undefined || tab.kind === 'gallery') return state
72
+ const tabs = state.tabs.filter((_, item) => item !== index)
73
+ const active = Math.min(state.active, tabs.length - 1)
74
+ return { tabs, active }
75
+ }
76
+
77
+ export type DeletePrompt = 'idle' | 'confirm' | 'failed'
78
+
79
+ /** Delete asks before the operation. Cancel returns to idle. A failure stays visible. */
80
+ export function deletePrompt(current: DeletePrompt, action: 'ask' | 'cancel' | 'fail'): DeletePrompt {
81
+ if (action === 'ask') return 'confirm'
82
+ if (action === 'cancel') return 'idle'
83
+ if (current === 'confirm') return 'failed'
84
+ return current
85
+ }