main
ts 218 lines 9.76 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { apiCall, useApiList } from './api'
4 import { createElement as h, Fragment, useEffect, useState } from 'react'
5 import { Box, Link, Table, TableBody, TableCell, TableRow, useTheme } from '@mui/material'
6 import type { Breakpoint } from '@mui/material/styles'
7 import { DataTable, DataTableColumn } from './DataTable'
8 import {
9 Delete, Error as ErrorIcon, FormatPaint as ThemeIcon, ListAlt, PlayCircle, Settings, StopCircle, Upgrade
10 } from '@mui/icons-material'
11 import {
12 CFG, HTTP_FAILED_DEPENDENCY, md, prefix, with_, xlate, tryJson, NBSP, isPrimitive, HIDE_IN_TESTS, wait
13 } from './misc'
14 import { alertDialog, confirmDialog, toast } from './dialog'
15 import _ from 'lodash'
16 import { PLUGIN_ERRORS } from './PluginsPage'
17 import { Btn, hTooltip, IconBtn, iconTooltip, usePauseButton } from './mui'
18 import { showPluginOptions, evalWrapper } from './pluginOptions'
19
20 // updates=true will show the "check updates" version of the page
21 export default function InstalledPlugins({ updates }: { updates?: true }) {
22 const { list, error, setList, initializing } = useApiList<any>(updates ? 'get_plugin_updates' : 'get_plugins', {}, {
23 map(x: any) { x.config &&= tryJson(x.config, s => evalWrapper('()=>('+s+')')()) }
24 })
25 const [sortAgain, setSortAgain] = useState(0)
26 useEffect(() => {
27 setList(list =>
28 _.sortBy(list, x => (x.error ? 0 : x.started ? 1 : x.badApi ? 2 : 3) + treatPluginName(x.repo?.split('/').reverse().join('/') || x.id).toLowerCase()))
29 }, [list.length, sortAgain])
30 const size = 'small'
31 const { pause, pauseButton } = usePauseButton("plugins", () => getSingleConfig(CFG.suspend_plugins).then(x => !x), {
32 async onClick() {
33 await apiCall('set_config', { values: { [CFG.suspend_plugins]: !pause } })
34 if (!pause) return
35 await wait(2000)
36 setSortAgain(Date.now())
37 }
38 })
39 const theme = useTheme()
40 return h(DataTable, {
41 error: isPrimitive(error) ? xlate(error, PLUGIN_ERRORS)
42 : _.map(error, (v, k) => `Error ${k} for: ${v.join(', ')}`).join('; '), // complex error for updates
43 rows: list.length ? list : [], // workaround for DataGrid's bug causing 'no rows' message to be not displayed after 'loading' was also used
44 fillFlex: true,
45 initializing,
46 disableColumnSelector: true,
47 quickFilter: !updates,
48 actionsHeader: !updates && pauseButton,
49 getRowHeight: updates && (({ model }) => model.changelog ? 'auto' as const : 50),
50 noRows: updates && `No updates available. Only plugins available on "search online" are checked.`,
51 columns: [
52 {
53 field: 'id',
54 headerName: "name",
55 flex: .3,
56 minWidth: 150,
57 renderCell: renderName,
58 valueGetter(_value: any, row: any) { return row.repo || row.id },
59 mergeRender: { [updates ? 'changelog' : 'description']: { sx: { fontSize: 'x-small' } } }
60 },
61 {
62 field: 'version',
63 width: 70,
64 hideUnder: 'sm',
65 cellInnerProps: { className: HIDE_IN_TESTS },
66 mergeRender: { installedVersion: { sx: { fontSize: 'x-small' } } }
67 },
68 themeField,
69 {
70 ...descriptionField,
71 flex: 1,
72 hideUnder: 'sm',
73 },
74 {
75 field: 'installedVersion',
76 hideUnder: true,
77 dialogHidden: true,
78 renderCell: ({ value }) => value && `Yours ${value}`
79 },
80 {
81 field: 'changelog',
82 headerName: "Change log",
83 flex: 2,
84 hideUnder: !updates || 'sm',
85 sx: { flexDirection: 'column', alignItems: 'flex-start' },
86 renderCell({ value, row }) {
87 if (!Array.isArray(value)) return null
88 return h(Table, { sx: { td: { p: 0 } } },
89 h(TableBody, {},
90 _.uniq(_.sortBy(value, 'version').filter(x => _.isString(x.message) && x.message && x.version > row.installedVersion))
91 .map((x, i) => h(TableRow, { key: i },
92 h(TableCell, { sx: { whiteSpace: 'pre', verticalAlign: 'top' } }, `• ${x.version}: `),
93 h(TableCell, {}, md(x.message, { html: false }))
94 ))
95 )
96 )
97 }
98 }
99 ],
100 actions: ({ row, id }) => updates ? [
101 h(IconBtn, {
102 icon: Upgrade,
103 title: row.downloading ? "Downloading" : row.updated ? "Already updated" : "Update",
104 disabled: row.updated,
105 progress: row.downloading,
106 size,
107 async onClick() {
108 await apiCall('update_plugin', { id }, { timeout: false }).catch(e => {
109 throw e.code !== HTTP_FAILED_DEPENDENCY ? e
110 : Error("Failed dependencies: " + e.cause?.map((x: any) => prefix(`plugin "`, x.id || x.repo, `" `) + x.error).join('; '))
111 })
112 toast("Plugin updated")
113 }
114 })
115 ] : [
116 h(IconBtn, row.started ? {
117 icon: StopCircle,
118 title: h(Box, { 'aria-hidden': true }, `Stop ${id}`, h('br'), `Started ` + new Date(row.started as string).toLocaleString()),
119 'aria-label': `Stop ${id}`,
120 size,
121 color: 'success',
122 doneAnimation: true,
123 onClick: () => apiCall('stop_plugin', { id }),
124 } : {
125 icon: PlayCircle,
126 title: `Start ${id}`,
127 disabled: pause && "All plugins are paused – Click the Resume button below",
128 size,
129 onClick: () => startPlugin(id),
130 }),
131 h(IconBtn, {
132 icon: row.config || !row.started || !row.log ? Settings : ListAlt,
133 title: row.config || !row.log ? "Options" : "Log",
134 size,
135 disabled: !row.started && "Start plugin to access options"
136 || !row.config && !row.log && "No options and no log for this plugin",
137 onClick() {
138 const cd = row.configDialog
139 // support css values for maxWidth without having to wrap in sx, as in DialogProps it only supports breakpoints
140 let maxWidth = theme.breakpoints.values[cd?.maxWidth as Breakpoint] || cd?.sx?.maxWidth || xlate(cd?.maxWidth, { xs: 0 }) || 432
141 if (typeof maxWidth === 'number') // @ts-ignore
142 maxWidth += 'px'
143 return showPluginOptions(row, maxWidth)
144 }
145 }),
146 h(IconBtn, {
147 icon: Delete,
148 title: "Uninstall",
149 size,
150 async onClick() {
151 const res = await confirmDialog(`${id}: delete configuration too?`, {
152 trueText: "Yes",
153 falseText: "No",
154 after: ({ onClick }) => h(Btn, { variant: 'outlined', onClick(){ onClick(undefined) } }, "Abort")
155 })
156 if (res === undefined) return
157 await apiCall('uninstall_plugin', { id, deleteConfig: res })
158 toast("Plugin uninstalled")
159 }
160 }),
161 ]
162 })
163 }
164
165 function getSingleConfig(k: string) {
166 return apiCall('get_config', { only: [k] }).then(x => x[k])
167 }
168
169 // hide the hfs- prefix, as one may want to use it for its repository, because github is the context, but in the hfs context the prefix it's not only redundant, but also ruins the sorting
170 function treatPluginName(name: string) {
171 return name.replace(/hfs-/, '')
172 }
173
174 export function renderName({ row, value }: any) {
175 const { repo } = row
176 return h(Fragment, {},
177 row.downgrade && errorIcon("This version is older than the one you installed. It is possible that the author found a problem with your version and decided to retire it.", true),
178 errorIcon(row.error || row.badApi, !row.error),
179 repo?.includes('//') ? h(Link, { href: repo, target: 'plugin' }, value)
180 : with_(repo?.split('/'), arr => arr?.length !== 2 ? value
181 : h(Fragment, {},
182 h(Link, { href: 'https://github.com/' + repo, target: 'plugin', onClick(ev) { ev.stopPropagation() } }, treatPluginName(arr[1])),
183 NBSP + 'by ', arr[0]
184 ))
185 )
186
187 function errorIcon(msg: string, warning=false) {
188 return msg && hTooltip(msg, msg, h(ErrorIcon, { fontSize: 'small', color: warning ? 'warning' : 'error', sx: { ml: -.5, mr: .5 } }))
189 }
190 }
191
192 export async function startPlugin(id: string) {
193 try {
194 await apiCall('start_plugin', { id })
195 toast("Plugin started", h(PlayCircle, { color: 'success' }))
196 return true
197 }
198 catch(e: any) {
199 alertDialog(`Plugin ${id} didn't start, with error: ${String(e?.message || e)}`, 'error')
200 }
201 }
202
203 export const descriptionField: DataTableColumn = {
204 field: 'description',
205 mergeRender: { isTheme: {} } ,
206 mergeRenderSx: { float: 'left' },
207 }
208
209 export const themeField: DataTableColumn = {
210 field: 'isTheme',
211 headerName: "is theme",
212 hideUnder: true,
213 dialogHidden: true,
214 type: 'boolean',
215 renderCell({ value }) {
216 return value && iconTooltip(ThemeIcon, _.isString(value) ? `${value} theme` : "theme", { fontSize: '1.2rem', mr: '.3em' })
217 }
218 }