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',
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',
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',
@@ -56,6 +58,45 @@ function commandExists(command) {
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() {
console.log('========================================')
}
@@ -73,7 +114,7 @@ printDivider()
for (const check of checks) {
console.log(`\n[pre-commit] ${check.name}`)
const result = spawnCommand(runner.run(check.command))
let result = spawnCommand(runner.run(check.command))
if (result.error) {
console.error(`\n[pre-commit] Failed to run "${check.command}".`)
@@ -81,6 +122,34 @@ for (const check of checks) {
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) {
console.error(`\n[pre-commit] ${check.name.toUpperCase()} FAILED`)
console.error(`[pre-commit] ${check.help}`)

View File

@@ -1,13 +1,16 @@
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 { toast } from '@renderer/components/base/toast'
import { previewPlugin, installPlugin } from '@renderer/utils/ipc'
interface Props {
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 {
let binary = ''
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 fileInput = useRef<HTMLInputElement>(null)
const [fileName, setFileName] = useState('')
@@ -31,18 +34,29 @@ const PluginInstallModal: React.FC<Props> = ({ onClose }) => {
const [preview, setPreview] = useState<IPluginDescriptorPreview | null>(null)
const [busy, setBusy] = useState(false)
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const f = e.target.files?.[0]
if (!f) return
setFileName(f.name)
setFileB64(abToBase64(await f.arrayBuffer()))
setPreview(null)
// file -> base64, or null if rejected/unreadable (toasts here)
const loadFile = async (f: File): Promise<string | null> => {
if (f.size > MAX_CPX_BYTES) {
toast.error(t('plugins.fileTooLarge'))
return 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)
try {
setPreview(await previewPlugin(fileB64))
setPreview(await previewPlugin(b64))
} catch (e) {
const msg = e instanceof Error ? e.message : ''
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> => {
setBusy(true)
try {
@@ -78,7 +110,7 @@ const PluginInstallModal: React.FC<Props> = ({ onClose }) => {
className="hidden"
onChange={onPickFile}
/>
<Button variant="flat" onPress={() => fileInput.current?.click()}>
<Button variant="flat" isDisabled={busy} onPress={() => fileInput.current?.click()}>
{fileName || t('plugins.chooseFile')}
</Button>
</div>

View File

@@ -836,6 +836,7 @@
"installed": "Installed",
"installFailed": "Install failed",
"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": "نصب شد",
"installFailed": "نصب ناموفق بود",
"previewFailed": "فایل افزونه نامعتبر است",
"outdatedFile": "قالب فایل افزونه قدیمی است؛ لطفاً نسخه جدید را از سرویس‌دهنده دریافت کنید"
"outdatedFile": "قالب فایل افزونه قدیمی است؛ لطفاً نسخه جدید را از سرویس‌دهنده دریافت کنید",
"fileTooLarge": "فایل افزونه بیش از حد بزرگ است"
}
}

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,9 @@ const Profiles: React.FC = () => {
const [, setNow] = useState(new Date())
const { pluginConfig, mutatePluginConfig } = usePluginConfig()
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 sensors = useSensors(useSensor(PointerSensor))
const { data: subs = [], mutate: mutateSubs } = useSWR(
@@ -218,7 +221,8 @@ const Profiles: React.FC = () => {
event.stopPropagation()
if (event.dataTransfer?.files) {
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 {
const path = window.api.webUtils.getPathForFile(file)
const content = await readTextFile(path)
@@ -226,7 +230,12 @@ const Profiles: React.FC = () => {
} catch (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'))
}
}
@@ -535,8 +544,11 @@ const Profiles: React.FC = () => {
)}
{showPluginImport && (
<PluginInstallModal
key={pluginDropSeq}
initialFile={pluginDropFile ?? undefined}
onClose={() => {
setShowPluginImport(false)
setPluginDropFile(null)
mutatePluginConfig()
}}
/>