feat: plugin file drag & drop

This commit is contained in:
ezequielnick
2026-07-05 00:43:25 +08:00
parent 368b232d6d
commit 38d51e0a38
8 changed files with 139 additions and 21 deletions

View File

@@ -22,12 +22,14 @@ const checks = [
{ {
name: 'Format Check', name: 'Format Check',
command: 'run format:check', command: 'run format:check',
help: 'Formatting issues were reported above. Review the listed files and fix them before committing again.' fixCommand: 'run format',
help: 'Formatting issues were reported above and could not be auto-fixed. Review the listed files and fix them before committing again.'
}, },
{ {
name: 'Lint Check', name: 'Lint Check',
command: 'run lint:check', command: 'run lint:check',
help: 'Lint errors were reported above. Review them and fix the affected code before committing again.' fixCommand: 'run lint',
help: 'Lint errors were reported above and could not be auto-fixed. Review them and fix the affected code before committing again.'
}, },
{ {
name: 'Type Check', name: 'Type Check',
@@ -56,6 +58,45 @@ function commandExists(command) {
return !result.error && result.status === 0 return !result.error && result.status === 0
} }
function gitCommand(args, { stdio = 'inherit' } = {}) {
return spawnSync('git', args, {
cwd: process.cwd(),
stdio
})
}
function getStagedFiles() {
const result = gitCommand(['diff', '--name-only', '--cached', '--diff-filter=ACMR', '-z'], {
stdio: 'pipe'
})
if (result.error || result.status !== 0) {
console.error('[pre-commit] Failed to read staged files for auto-fix restaging.')
if (result.error) {
console.error(result.error.message)
}
process.exit(result.status ?? 1)
}
return result.stdout.toString('utf8').split('\0').filter(Boolean)
}
function restageFiles(files) {
if (files.length === 0) {
return
}
const result = gitCommand(['add', '--', ...files])
if (result.error || result.status !== 0) {
console.error('[pre-commit] Auto-fix completed, but failed to restage fixed files.')
if (result.error) {
console.error(result.error.message)
}
process.exit(result.status ?? 1)
}
}
function printDivider() { function printDivider() {
console.log('========================================') console.log('========================================')
} }
@@ -73,7 +114,7 @@ printDivider()
for (const check of checks) { for (const check of checks) {
console.log(`\n[pre-commit] ${check.name}`) console.log(`\n[pre-commit] ${check.name}`)
const result = spawnCommand(runner.run(check.command)) let result = spawnCommand(runner.run(check.command))
if (result.error) { if (result.error) {
console.error(`\n[pre-commit] Failed to run "${check.command}".`) console.error(`\n[pre-commit] Failed to run "${check.command}".`)
@@ -81,6 +122,34 @@ for (const check of checks) {
process.exit(1) process.exit(1)
} }
if (result.status !== 0 && check.fixCommand) {
console.log(`\n[pre-commit] ${check.name} failed. Running auto-fix...`)
const fixResult = spawnCommand(runner.run(check.fixCommand))
if (fixResult.error) {
console.error(`\n[pre-commit] Failed to run "${check.fixCommand}".`)
console.error(fixResult.error.message)
process.exit(1)
}
if (fixResult.status !== 0) {
console.error(`\n[pre-commit] Auto-fix command "${check.fixCommand}" failed.`)
console.error(`[pre-commit] ${check.help}`)
console.error('[pre-commit] Commit aborted.')
process.exit(fixResult.status ?? 1)
}
restageFiles(getStagedFiles())
console.log(`[pre-commit] Auto-fix completed. Re-running ${check.name}.`)
result = spawnCommand(runner.run(check.command))
if (result.error) {
console.error(`\n[pre-commit] Failed to run "${check.command}".`)
console.error(result.error.message)
process.exit(1)
}
}
if (result.status !== 0) { if (result.status !== 0) {
console.error(`\n[pre-commit] ${check.name.toUpperCase()} FAILED`) console.error(`\n[pre-commit] ${check.name.toUpperCase()} FAILED`)
console.error(`[pre-commit] ${check.help}`) console.error(`[pre-commit] ${check.help}`)

View File

@@ -1,13 +1,16 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
import React, { useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from '@renderer/components/base/toast' import { toast } from '@renderer/components/base/toast'
import { previewPlugin, installPlugin } from '@renderer/utils/ipc' import { previewPlugin, installPlugin } from '@renderer/utils/ipc'
interface Props { interface Props {
onClose: () => void onClose: () => void
initialFile?: File // dropped file: auto-load + preview on open
} }
const MAX_CPX_BYTES = 10 * 1024 * 1024 // guard against a huge mis-dropped file freezing the renderer
function abToBase64(buf: ArrayBuffer): string { function abToBase64(buf: ArrayBuffer): string {
let binary = '' let binary = ''
const bytes = new Uint8Array(buf) const bytes = new Uint8Array(buf)
@@ -23,7 +26,7 @@ function hostOf(url: string): string {
} }
} }
const PluginInstallModal: React.FC<Props> = ({ onClose }) => { const PluginInstallModal: React.FC<Props> = ({ onClose, initialFile }) => {
const { t } = useTranslation() const { t } = useTranslation()
const fileInput = useRef<HTMLInputElement>(null) const fileInput = useRef<HTMLInputElement>(null)
const [fileName, setFileName] = useState('') const [fileName, setFileName] = useState('')
@@ -31,18 +34,29 @@ const PluginInstallModal: React.FC<Props> = ({ onClose }) => {
const [preview, setPreview] = useState<IPluginDescriptorPreview | null>(null) const [preview, setPreview] = useState<IPluginDescriptorPreview | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => { // file -> base64, or null if rejected/unreadable (toasts here)
const f = e.target.files?.[0] const loadFile = async (f: File): Promise<string | null> => {
if (!f) return if (f.size > MAX_CPX_BYTES) {
setFileName(f.name) toast.error(t('plugins.fileTooLarge'))
setFileB64(abToBase64(await f.arrayBuffer())) return null
setPreview(null) }
try {
const b64 = abToBase64(await f.arrayBuffer())
setFileName(f.name)
setFileB64(b64)
setPreview(null)
return b64
} catch {
toast.error(t('plugins.previewFailed'))
return null
}
} }
const doPreview = async (): Promise<void> => { // preview by explicit b64 (state may not be flushed yet)
const previewB64 = async (b64: string): Promise<void> => {
setBusy(true) setBusy(true)
try { try {
setPreview(await previewPlugin(fileB64)) setPreview(await previewPlugin(b64))
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '' const msg = e instanceof Error ? e.message : ''
toast.error(msg.includes('v1') ? t('plugins.outdatedFile') : t('plugins.previewFailed')) toast.error(msg.includes('v1') ? t('plugins.outdatedFile') : t('plugins.previewFailed'))
@@ -51,6 +65,24 @@ const PluginInstallModal: React.FC<Props> = ({ onClose }) => {
} }
} }
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const f = e.target.files?.[0]
if (!f) return
await loadFile(f)
}
const doPreview = async (): Promise<void> => {
await previewB64(fileB64)
}
useEffect(() => {
if (!initialFile) return
loadFile(initialFile).then((b64) => {
if (b64) previewB64(b64)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const doInstall = async (): Promise<void> => { const doInstall = async (): Promise<void> => {
setBusy(true) setBusy(true)
try { try {
@@ -78,7 +110,7 @@ const PluginInstallModal: React.FC<Props> = ({ onClose }) => {
className="hidden" className="hidden"
onChange={onPickFile} onChange={onPickFile}
/> />
<Button variant="flat" onPress={() => fileInput.current?.click()}> <Button variant="flat" isDisabled={busy} onPress={() => fileInput.current?.click()}>
{fileName || t('plugins.chooseFile')} {fileName || t('plugins.chooseFile')}
</Button> </Button>
</div> </div>

View File

@@ -836,6 +836,7 @@
"installed": "Installed", "installed": "Installed",
"installFailed": "Install failed", "installFailed": "Install failed",
"previewFailed": "Invalid plugin file", "previewFailed": "Invalid plugin file",
"outdatedFile": "This plugin file format is outdated; please obtain the new one from your provider" "outdatedFile": "This plugin file format is outdated; please obtain the new one from your provider",
"fileTooLarge": "Plugin file is too large"
} }
} }

View File

@@ -800,6 +800,7 @@
"installed": "نصب شد", "installed": "نصب شد",
"installFailed": "نصب ناموفق بود", "installFailed": "نصب ناموفق بود",
"previewFailed": "فایل افزونه نامعتبر است", "previewFailed": "فایل افزونه نامعتبر است",
"outdatedFile": "قالب فایل افزونه قدیمی است؛ لطفاً نسخه جدید را از سرویس‌دهنده دریافت کنید" "outdatedFile": "قالب فایل افزونه قدیمی است؛ لطفاً نسخه جدید را از سرویس‌دهنده دریافت کنید",
"fileTooLarge": "فایل افزونه بیش از حد بزرگ است"
} }
} }

View File

@@ -808,6 +808,7 @@
"installed": "Установлено", "installed": "Установлено",
"installFailed": "Ошибка установки", "installFailed": "Ошибка установки",
"previewFailed": "Недействительный файл плагина", "previewFailed": "Недействительный файл плагина",
"outdatedFile": "Формат файла плагина устарел; получите новый у провайдера" "outdatedFile": "Формат файла плагина устарел; получите новый у провайдера",
"fileTooLarge": "Файл плагина слишком большой"
} }
} }

View File

@@ -832,6 +832,7 @@
"installed": "已安装", "installed": "已安装",
"installFailed": "安装失败", "installFailed": "安装失败",
"previewFailed": "插件文件无效", "previewFailed": "插件文件无效",
"outdatedFile": "该插件文件格式已过期,请从机场重新获取" "outdatedFile": "该插件文件格式已过期,请从机场重新获取",
"fileTooLarge": "插件文件过大"
} }
} }

View File

@@ -832,6 +832,7 @@
"installed": "已安裝", "installed": "已安裝",
"installFailed": "安裝失敗", "installFailed": "安裝失敗",
"previewFailed": "外掛檔案無效", "previewFailed": "外掛檔案無效",
"outdatedFile": "該外掛檔案格式已過期,請從機場重新取得" "outdatedFile": "該外掛檔案格式已過期,請從機場重新取得",
"fileTooLarge": "外掛檔案過大"
} }
} }

View File

@@ -81,6 +81,9 @@ const Profiles: React.FC = () => {
const [, setNow] = useState(new Date()) const [, setNow] = useState(new Date())
const { pluginConfig, mutatePluginConfig } = usePluginConfig() const { pluginConfig, mutatePluginConfig } = usePluginConfig()
const [showPluginImport, setShowPluginImport] = useState(false) const [showPluginImport, setShowPluginImport] = useState(false)
const [pluginDropFile, setPluginDropFile] = useState<File | null>(null)
// bump per .cpx drop -> remount modal so it loads the new file even when open
const [pluginDropSeq, setPluginDropSeq] = useState(0)
const isUrlEmpty = url.trim() === '' const isUrlEmpty = url.trim() === ''
const sensors = useSensors(useSensor(PointerSensor)) const sensors = useSensors(useSensor(PointerSensor))
const { data: subs = [], mutate: mutateSubs } = useSWR( const { data: subs = [], mutate: mutateSubs } = useSWR(
@@ -218,7 +221,8 @@ const Profiles: React.FC = () => {
event.stopPropagation() event.stopPropagation()
if (event.dataTransfer?.files) { if (event.dataTransfer?.files) {
const file = event.dataTransfer.files[0] const file = event.dataTransfer.files[0]
if (file.name.endsWith('.yml') || file.name.endsWith('.yaml')) { const name = file?.name.toLowerCase() ?? ''
if (name.endsWith('.yml') || name.endsWith('.yaml')) {
try { try {
const path = window.api.webUtils.getPathForFile(file) const path = window.api.webUtils.getPathForFile(file)
const content = await readTextFile(path) const content = await readTextFile(path)
@@ -226,7 +230,12 @@ const Profiles: React.FC = () => {
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
} else { } else if (name.endsWith('.cpx')) {
// .cpx -> plugin install modal (preview + confirm)
setPluginDropFile(file)
setPluginDropSeq((n) => n + 1)
setShowPluginImport(true)
} else if (file) {
toast.warning(tRef.current('profiles.error.unsupportedFileType')) toast.warning(tRef.current('profiles.error.unsupportedFileType'))
} }
} }
@@ -535,8 +544,11 @@ const Profiles: React.FC = () => {
)} )}
{showPluginImport && ( {showPluginImport && (
<PluginInstallModal <PluginInstallModal
key={pluginDropSeq}
initialFile={pluginDropFile ?? undefined}
onClose={() => { onClose={() => {
setShowPluginImport(false) setShowPluginImport(false)
setPluginDropFile(null)
mutatePluginConfig() mutatePluginConfig()
}} }}
/> />