| 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 | } |