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