新增: 文件操作增强与预览扩展

- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
This commit is contained in:
lxy
2026-09-15 22:26:12 +08:00
parent f1bc5b49f1
commit e7a2c5148e
59 changed files with 3185 additions and 2047 deletions
+30
View File
@@ -89,6 +89,36 @@ export async function loadLanguageExtension(language) {
extension = StreamLanguage.define(powerShell)
break
}
case 'batch': {
// 自写 DOS batch 流式模式:REM/:: 整行注释,%var% 变量,常用关键字
const { StreamLanguage } = await import('@codemirror/language')
const batchKeywords = new Set('echo off on set setlocal endlocal enabledelayedexpansion if not exist defined equ neq lss leq gtr geq for in do goto call exit exitb start del erase copy move ren rename md mkdir rd rmdir cd chdir type cls pause shift errorlevel title ver vol prompt pushd popd xcopy robocopy powershell cmd'.split(' '))
extension = StreamLanguage.define({
name: 'batch',
token(stream) {
if (stream.sol()) {
stream.eatSpace()
// 行首 REM(大小写不敏感)或 :: 开头的整行注释;:标签名 视作 keyword
if (stream.match(/^rem(?:\s|$)/i) || stream.match(/^::/)) {
stream.skipToEnd()
return 'comment'
}
if (stream.match(/^:[\w.-]+/)) return 'keyword'
}
if (stream.eatSpace()) return null
if (stream.match(/^"(?:[^"\r\n]|"")*"?/)) return 'string'
if (stream.eat('^')) return 'operator'
// !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数
if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i)) return 'atom'
if (stream.match(/^\w+/)) {
return batchKeywords.has(stream.current().toLowerCase()) ? 'keyword' : null
}
stream.next()
return null
}
})
break
}
case 'dart': {
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
extension = StreamLanguage.define(dart)
+1 -1
View File
@@ -76,7 +76,7 @@ export const FILE_EXTENSIONS = {
CODE: [
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'cmd', 'ps1',
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
'dockerfile', 'm', 'r', 'matlab'
],
+53
View File
@@ -0,0 +1,53 @@
/**
* Excel 解析 Worker
* 把 XLSX.read() 与 sheet_to_json() 这两个【同步阻塞】操作隔离到独立线程,
* 避免大表解析冻结 webview 主线程(导致整个窗口点击无响应)。
*
* 协议:
* 主线程 -> { type: 'parse', reqId, wid, buffer: ArrayBuffer } (buffer 会被 transfer)
* 主线程 -> { type: 'sheet', reqId, wid, idx }
* worker -> { type: 'ready', reqId, wid, sheetNames: string[] }
* worker -> { type: 'sheet', reqId, wid, idx, data: any[][] }
* worker -> { type: 'stale', reqId } // wid 不匹配当前已加载工作簿
* worker -> { type: 'error', reqId, message }
*
* wid(workbook id)由主线程递增分配;切到新文件时 wid 变化,
* 旧 wid 的 sheet 请求会被识别为 stale,防止乱序回包覆盖新内容。
*/
let XLSX = null
let workbook = null
let loadedWid = 0
async function ensureXlsx() {
if (!XLSX) XLSX = await import('xlsx')
return XLSX
}
self.onmessage = async (e) => {
const msg = e.data || {}
const { type, reqId, wid, idx, buffer } = msg
try {
if (type === 'parse') {
const X = await ensureXlsx()
workbook = X.read(buffer, { type: 'array' })
loadedWid = wid
self.postMessage({ type: 'ready', reqId, wid, sheetNames: workbook.SheetNames || [] })
return
}
if (type === 'sheet') {
// wid 不匹配(已切到别的文件)→ 丢弃,避免旧回包覆盖
if (!workbook || wid !== loadedWid) {
self.postMessage({ type: 'stale', reqId })
return
}
const X = await ensureXlsx()
const ws = workbook.Sheets[workbook.SheetNames[idx]]
const data = X.utils.sheet_to_json(ws, { header: 1, defval: '' })
self.postMessage({ type: 'sheet', reqId, wid, idx, data })
return
}
} catch (err) {
self.postMessage({ type: 'error', reqId, message: err && err.message ? err.message : String(err) })
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Excel 解析 Worker 单例客户端
*
* 职责:
* 1. 懒加载并复用单个 excel.worker.js;
* 2. 用 wid(generation)机制忽略过期解析结果——快速切换文件时,旧文件的
* 解析回包会被 isCurrent(wid) 判定为 stale 而丢弃;
* 3. Worker 不可用时退化为「主线程同步解析」,保证功能不丢(极少数老 webview)。
*
* 根治点:主线程不再出现 XLSX.read() 同步调用,从源头消除卡死。
*/
let worker = null
let useFallback = false
const pending = new Map() // reqId -> { resolve, reject }
let nextReqId = 1
let currentWid = 0 // 每次 parseWorkbook 自增;切文件 / 自愈时也会变
// ---- fallback(无 Worker 环境的同步退化实现)----
let fbXlsx = null
let fbWorkbook = null
let fbWid = 0
function getWorker() {
if (worker) return worker
if (useFallback) return null
try {
worker = new Worker(new URL('./excel.worker.js', import.meta.url), { type: 'module' })
worker.onmessage = (e) => {
const msg = e.data || {}
const p = pending.get(msg.reqId)
if (!p) return
pending.delete(msg.reqId)
if (msg.type === 'error') p.reject(new Error(msg.message || 'excel worker error'))
else p.resolve(msg)
}
worker.onerror = (e) => {
// worker 崩溃:reject 所有在途请求,并退化为同步
for (const p of pending.values()) p.reject(new Error(e.message || 'excel worker crash'))
pending.clear()
useFallback = true
worker = null
}
return worker
} catch (e) {
console.warn('[excelWorkerClient] Worker 创建失败,退化为同步解析:', e)
useFallback = true
return null
}
}
function send(type, payload, transfer = []) {
const w = getWorker()
if (!w) return Promise.reject(new Error('worker unavailable'))
const reqId = nextReqId++
return new Promise((resolve, reject) => {
pending.set(reqId, { resolve, reject })
w.postMessage({ type, reqId, ...payload }, transfer)
})
}
async function ensureFallbackXlsx() {
if (!fbXlsx) fbXlsx = await import('xlsx')
return fbXlsx
}
async function fallbackParse(buffer, wid) {
const X = await ensureFallbackXlsx()
fbWorkbook = X.read(buffer, { type: 'array' })
fbWid = wid
return { wid, sheetNames: fbWorkbook.SheetNames || [] }
}
/**
* 解析整个工作簿(在 worker 线程)。buffer 会被 transfer,主线程不再持有。
* @returns {Promise<{ wid: number, sheetNames: string[] }>}
*/
export async function parseWorkbook(buffer) {
const wid = ++currentWid
if (useFallback || !getWorker()) {
return fallbackParse(buffer, wid)
}
try {
const res = await send('parse', { wid, buffer }, [buffer])
return { wid, sheetNames: res.sheetNames || [] }
} catch (e) {
// worker 通道异常 → 本次及后续退化为同步
console.warn('[excelWorkerClient] parse 走 worker 失败,退化为同步:', e)
useFallback = true
return await fallbackParse(buffer, wid)
}
}
/**
* 取某个 sheet 的二维数据(在 worker 线程)。
* @returns {Promise<any[][]>}
*/
export async function getSheet(wid, idx) {
if (wid !== currentWid) throw new Error('stale')
if (useFallback) {
if (wid !== fbWid || !fbWorkbook) throw new Error('stale')
const X = await ensureFallbackXlsx()
const ws = fbWorkbook.Sheets[fbWorkbook.SheetNames[idx]]
return X.utils.sheet_to_json(ws, { header: 1, defval: '' })
}
const res = await send('sheet', { wid, idx })
if (res.type === 'stale') throw new Error('stale')
return res.data
}
/** 指定 wid 是否仍是当前(最新)解析任务,用于丢弃过期回包 */
export function isCurrent(wid) {
return wid === currentWid
}
/**
* 让所有在途解析结果立即作废(wid 推进)。
* 卡死自愈时调用,确保旧解析不会在恢复后覆盖界面。
*/
export function invalidateExcelPreview() {
currentWid++
}
+54 -33
View File
@@ -1,30 +1,36 @@
/**
* Office 文件预览处理器
*
* Excel 解析已迁移到 Web Worker(见 excel.worker.js + excelWorkerClient.js),
* 主线程不再出现 XLSX.read() 同步调用,避免大表解析冻结整个界面。
*/
import { escapeHtml } from './fileUtils'
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
import { parseWorkbook, getSheet, isCurrent } from './excelWorkerClient'
// 每批加载行数
const BATCH_ROWS = 200
const LOAD_MORE_THRESHOLD = 100
// Excel 预览处理器
// Excel 预览处理器(解析在 Web Worker 中进行,不阻塞主线程)
export async function previewExcel(file, container) {
const XLSX = await import('xlsx')
// 1. 取二进制并交给 worker 解析(XLSX.read / sheet_to_json 全在 worker 线程)
const arrayBuffer = await file.arrayBuffer()
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
const { wid, sheetNames } = await parseWorkbook(arrayBuffer)
// 切到别的文件时 wid 会推进,丢弃本次结果
if (!isCurrent(wid)) return { success: true }
// 渲染标签页
const tabs = workbook.SheetNames.map((name, i) =>
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${name}</button>`
// 2. 渲染骨架(标签页 + 信息栏 + 内容区)
const tabs = sheetNames.map((name, i) =>
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${escapeHtml(name)}</button>`
).join('')
container.innerHTML = `
<div class="excel-preview">
<div class="excel-tabs">${tabs}</div>
<div class="excel-info"></div>
<div class="excel-content"></div>
<div class="excel-content"><div class="loading-hint">解析中...</div></div>
</div>
<style>
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
@@ -41,6 +47,7 @@ export async function previewExcel(file, container) {
.excel-content th.row-num{z-index:3;top:0;left:0}
.excel-content tr:hover td{background:var(--color-fill-1)}
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
.loading-hint{padding:24px;text-align:center;color:var(--color-text-3);font-size:13px}
</style>
`
@@ -49,19 +56,15 @@ export async function previewExcel(file, container) {
const tabsEl = container.querySelector('.excel-tabs')
// 当前 sheet 状态
let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 }
// 获取 sheet 数据
const getSheetData = (idx) => {
const ws = workbook.Sheets[workbook.SheetNames[idx]]
return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
}
let currentSheet = { idx: -1, data: null, renderedRows: 0, totalRows: 0 }
// 渲染令牌:切 sheet 自增,丢弃乱序回包
let renderToken = 0
// 渲染表格(带行号)
const renderTable = (data, startRow = 0) => {
let html = '<table><thead><tr><th class="row-num">#</th>'
if (data[0]) {
data[0].forEach((cell, i) => {
data[0].forEach((cell) => {
html += `<th>${escapeHtml(cell)}</th>`
})
}
@@ -80,7 +83,7 @@ export async function previewExcel(file, container) {
return html
}
// 追加行
// 追加行(从内存中的 data 追加,不再过 worker)
const appendRows = (data, fromRow, toRow) => {
const tbody = contentEl.querySelector('tbody')
if (!tbody) return
@@ -107,17 +110,35 @@ export async function previewExcel(file, container) {
}
}
// 切换 sheet
const loadSheet = (idx) => {
currentSheet = {
idx,
data: getSheetData(idx),
renderedRows: BATCH_ROWS,
totalRows: 0
}
currentSheet.totalRows = currentSheet.data.length
// 切换 sheet(worker 异步解析单个 sheet)
const loadSheet = async (idx) => {
const token = ++renderToken
const displayData = currentSheet.data.slice(0, BATCH_ROWS + 1)
// 标签高亮
tabsEl.querySelectorAll('.excel-tab').forEach(t => {
t.classList.toggle('active', parseInt(t.dataset.idx, 10) === idx)
})
// 该 sheet 尚未解析过 → 显示解析中
if (currentSheet.idx !== idx) {
contentEl.innerHTML = '<div class="loading-hint">解析中...</div>'
}
let data
try {
data = await getSheet(wid, idx)
} catch (e) {
if (isCurrent(wid) && token === renderToken) {
contentEl.innerHTML = `<div class="preview-error"><p>❌ Excel 解析失败</p><p class="error-detail">${escapeHtml(e && e.message ? e.message : String(e))}</p></div>`
}
return
}
// 丢弃过期 / 乱序回包
if (!isCurrent(wid) || token !== renderToken) return
currentSheet = { idx, data, renderedRows: BATCH_ROWS, totalRows: data.length }
const displayData = data.slice(0, BATCH_ROWS + 1)
contentEl.innerHTML = renderTable(displayData)
updateInfo()
}
@@ -127,7 +148,7 @@ export async function previewExcel(file, container) {
const { scrollTop, scrollHeight, clientHeight } = contentEl
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
const { data, renderedRows, totalRows } = currentSheet
if (renderedRows < totalRows - 1) {
if (data && renderedRows < totalRows - 1) {
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
appendRows(data, renderedRows + 1, newEnd)
currentSheet.renderedRows = newEnd
@@ -136,17 +157,17 @@ export async function previewExcel(file, container) {
}
}
loadSheet(0)
// 标签页切换
tabsEl.onclick = (e) => {
const tab = e.target.closest('.excel-tab')
if (!tab) return
tabsEl.querySelectorAll('.excel-tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
loadSheet(parseInt(tab.dataset.idx, 10))
const idx = parseInt(tab.dataset.idx, 10)
if (idx === currentSheet.idx) return
loadSheet(idx)
}
await loadSheet(0)
return { success: true }
}
@@ -233,7 +254,7 @@ export async function previewCsv(file, container) {
container.innerHTML = `
<div class="csv-preview">
<div class="csv-info">📋 ${file.name}</div>
<div class="csv-info">📋 ${escapeHtml(file.name)}</div>
<div class="csv-content"></div>
</div>
<style>
+5 -3
View File
@@ -29,13 +29,15 @@ export const PREVIEWABLE_TYPES = [
*/
export const KNOWN_BINARY_TYPES = [
// 可执行文件
'exe', 'dll', 'so', 'bin',
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite',
// 压缩文件
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
// 编译/中间产物
'pdb', 'idb', 'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
// 安装包
'msi', 'jar', 'war', 'ear', 'apk',
// Office PowerPoint(暂不支持预览)
'ppt', 'pptx',
// 其他二进制
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
]
/**
+110
View File
@@ -0,0 +1,110 @@
/**
* 主线程卡死检测 + 事后自愈(主线程侧控制器)
*
* 能力边界(必须如实告知):
* - 主线程冻结「进行中」无法自救(回调被一起冻住);
* - 本模块只能「事后」检测到冻结(心跳断流),并在主线程恢复后做自愈;
* - 自愈动作绝不包含 location.reload(),否则会与「启动自动预览历史 Excel」
* 形成死循环(reload → 再次自动预览 → 再次冻结 → 再次 reload)。
*
* 自愈动作(每次冻结事件只执行一次,有 healing 防抖):
* 1. invalidateExcelPreview() —— 推进 wid,丢弃在途 Excel 解析回包;
* 2. 清掉持久化会话(LAST_OPENED_FILE + MULTI_PREVIEW_TABS),断开 reload-loop;
* 3. 置位 autorestore-disabled 标记,下次启动 restoreSession 主动跳过自动恢复;
* 4. toast 提示用户。
*/
import { STORAGE_KEYS } from './constants'
import { invalidateExcelPreview } from './excelWorkerClient'
const AUTORESTORE_DISABLED_KEY = 'app-filesystem-autorestore-disabled'
let started = false
let healing = false
export function isAutoRestoreDisabled(): boolean {
try {
return localStorage.getItem(AUTORESTORE_DISABLED_KEY) === '1'
} catch {
return false
}
}
export function clearAutoRestoreDisabled(): void {
try {
localStorage.removeItem(AUTORESTORE_DISABLED_KEY)
} catch {
/* ignore */
}
}
function setAutoRestoreDisabled(): void {
try {
localStorage.setItem(AUTORESTORE_DISABLED_KEY, '1')
} catch {
/* ignore */
}
}
function heal(freezeMs: number) {
if (healing) return // 同一次冻结可能触发多次 recovered,只处理一次
healing = true
console.error(`[freezeWatchdog] 检测到主线程冻结约 ${(freezeMs / 1000).toFixed(1)}s,执行自愈`)
// 1. 丢弃在途 Excel 解析
try {
invalidateExcelPreview()
} catch {
/* ignore */
}
// 2. 清持久化会话 —— 断开 reload-loop 的关键
try {
localStorage.removeItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
localStorage.removeItem(STORAGE_KEYS.FILESYSTEM.MULTI_PREVIEW_TABS)
} catch {
/* ignore */
}
// 3. 置位熔断标记:下次启动跳过自动恢复(防御纵深,即便上一步失败也不会 loop)
setAutoRestoreDisabled()
// 4. 提示用户(懒加载避免循环依赖)
import('@arco-design/web-vue')
.then(({ Message }) => {
Message.warning(`检测到界面卡顿 ${(freezeMs / 1000).toFixed(0)}s,已自动恢复并清理历史预览会话`)
})
.catch(() => {
/* ignore */
})
// 短暂冷却后释放防抖锁,允许后续「新的」冻结事件再次自愈
setTimeout(() => {
healing = false
}, 5000)
}
/** 启动看门狗(幂等,全进程只需启动一次) */
export function startFreezeWatchdog(): void {
if (started) return
if (typeof Worker === 'undefined') return // 环境不支持,静默跳过
try {
const w = new Worker(new URL('./freezeWatchdog.worker.js', import.meta.url), { type: 'module' })
w.onmessage = (e) => {
const msg = e.data || {}
if (msg.type === 'ping') {
// 主线程活着 → 立即回 pong(这一行在冻结期间跑不到,正是检测依据)
w.postMessage({ type: 'pong' })
} else if (msg.type === 'recovered') {
heal(msg.freezeMs || 0)
}
}
w.onerror = (e) => {
console.warn('[freezeWatchdog] worker 异常,看门狗停止:', e.message)
}
started = true
} catch (e) {
console.warn('[freezeWatchdog] 启动失败:', e)
}
}
@@ -0,0 +1,41 @@
/**
* 卡死看门狗 Worker
*
* 原理:主线程被【同步任务】阻塞时,连定时器回调都跑不了。
* 因此由独立 worker 线程周期性 ping 主线程;主线程收到立即 pong。
* 若 worker 在 FREEZE_MS 内收不到 pong,即可断定主线程被冻结。
*
* 注意:worker 线程本身不被主线程的同步阻塞影响,故能在冻结期间持续计时。
* 这里只有一个 2s 的低频定时器,不是忙循环,不会消耗 CPU。
*/
const PING_MS = 2000 // 每 2s 探测一次
const FREEZE_MS = 8000 // 超过 8s 没 pong 即判定冻结(正常异步 IO 不会触发)
let lastPongTs = Date.now()
let frozen = false
let freezeStartTs = 0
self.setInterval(() => {
self.postMessage({ type: 'ping' })
const sincePong = Date.now() - lastPongTs
if (!frozen && sincePong > FREEZE_MS) {
// 进入冻结态,记录起点(用最后一次 pong 时间近似冻结起点)
frozen = true
freezeStartTs = lastPongTs
}
}, PING_MS)
self.onmessage = (e) => {
const msg = e.data || {}
if (msg.type !== 'pong') return
const now = Date.now()
if (frozen) {
// 主线程恢复响应 → 报告本次冻结时长
frozen = false
self.postMessage({ type: 'recovered', freezeMs: now - freezeStartTs })
}
lastPongTs = now
}
+4 -1
View File
@@ -65,7 +65,10 @@ const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
shell: { hljs: 'bash', cm: 'shell' },
zsh: { hljs: 'bash', cm: 'shell' },
ps1: { hljs: 'powershell', cm: 'powershell' },
bat: { hljs: 'dos', cm: 'text' },
psm1: { hljs: 'powershell', cm: 'powershell' },
psd1: { hljs: 'powershell', cm: 'powershell' },
cmd: { hljs: 'dos', cm: 'batch' },
bat: { hljs: 'dos', cm: 'batch' },
ahk: { hljs: 'autohotkey', cm: 'text' },
lua: { hljs: 'lua', cm: 'text' },
r: { hljs: 'r', cm: 'text' },
+16
View File
@@ -0,0 +1,16 @@
/**
* Wails 事件 payload 解析(统一入口)
*
* 运行时 On 回调收到的是 WailsEvent 对象,真实载荷在其 .data 字段;
* 不同版本/路径下 payload 可能是 JSON 字符串或对象,这里统一兼容:
* 解包 .data → 字符串则 JSON.parse → 异常/空值回落空对象。
*/
export function parseWailsEventData(event: unknown): any {
const data = (event as any)?.data ?? event
try {
return typeof data === 'string' ? JSON.parse(data) : (data ?? {})
} catch {
return {}
}
}