2026-07-21 16:13:16 +08:00

300 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { h, render } from 'vue'
// Finder 应用 — 从 js/apps.js 手动转写
import { el, iconImg, fmtBytes, debounce } from '../../utils'
import { bus as Bus } from '../../composables/useBus'
import { fs as FS } from '../../composables/useFS'
import { wm as WM } from '../../composables/useWM'
import { ui as UI } from '../../composables/useUI'
import { Apps, stdMenus } from '../../composables/useApps'
import FinderComponent from './Finder.vue'
// Sys 引用(运行时可用,由 system/index.ts 暴露为 window.Sys
const Sys = () => (window as any).Sys
export const FinderApp = {
id: 'finder', name: '访达', icon: '/assets/icons/finder.png',
w: 860, h: 540, minW: 560, minH: 340, singleton: false,
menus(win: any) {
const st = win?.appState
return stdMenus(this, {
file: [
{ label: '新建 Finder 窗口', key: '⌘N', action: () => Apps.open('finder', { path: st?.path }) },
{ label: '新建文件夹', key: '⇧⌘N', action: () => st?.newFolder() },
{ sep: true },
{ label: '移到废纸篓', key: '⌘⌫', disabled: !st?.selection.size, action: () => st?.trashSelection() },
{ label: '显示简介', key: '⌘I', disabled: st?.selection.size !== 1, action: () => st?.showInfo() },
],
edit: [
{ label: '拷贝', key: '⌘C', disabled: !st?.selection.size, action: () => st?.copySel('copy') },
{ label: '粘贴', key: '⌘V', disabled: !st?.clipboard, action: () => st?.paste() },
],
view: [
{ label: '按图标显示', key: '⌘1', checked: st?.view === 'icon', action: () => st?.setView('icon') },
{ label: '按列表显示', key: '⌘2', checked: st?.view === 'list', action: () => st?.setView('list') },
{ sep: true },
{ label: '排序方式', submenu: [
{ label: '名称', checked: st?.sortBy === 'name', action: () => st?.setSort('name') },
{ label: '种类', checked: st?.sortBy === 'kind', action: () => st?.setSort('kind') },
{ label: '修改日期', checked: st?.sortBy === 'date', action: () => st?.setSort('date') },
] },
]
})
},
render(win: any, args: any) {
const S = Sys()
const st = win.appState = {
path: args.path || FS.HOME + '/Desktop',
history: [] as string[], hi: -1, anchor: null as string | null,
view: S.settings.finderView, sortBy: S.settings.finderSort,
selection: new Set<string>(), clipboard: FinderApp.clipboard || null,
search: '',
}
st.navigate = (p: string, push = true) => {
if (!FS.isDir(p)) return
p = FS.normalize(p)
if (push && st.history[st.hi] !== p) {
st.history = st.history.slice(0, st.hi + 1)
st.history.push(p)
st.hi = st.history.length - 1
}
st.path = p; st.selection.clear(); st.anchor = null; st.search = ''; searchInput.value = ''
render()
}
st.back = () => { if (st.hi > 0) { st.hi--; st.navigate(st.history[st.hi], false) } }
st.fwd = () => { if (st.hi < st.history.length - 1) { st.hi++; st.navigate(st.history[st.hi], false) } }
st.history = [FS.normalize(st.path)]; st.hi = 0
st.setView = (v: string) => { st.view = v; S.settings.finderView = v; S.save(); render() }
st.setSort = (s: string) => { st.sortBy = s; S.settings.finderSort = s; S.save(); render() }
st.newFolder = async () => {
const name = await UI.prompt('新建文件夹', '请输入文件夹名称:', '未命名文件夹')
if (!name) return
try { FS.mkdir(FS.join(st.path, name)) } catch (e: any) { UI.alert('无法创建', e.message, this.icon) }
}
st.trashSelection = () => {
;[...st.selection].forEach((p: string) => { try { FS.trash(p) } catch (e: any) { UI.alert('无法移到废纸篓', e.message, this.icon) } })
st.selection.clear()
}
st.copySel = (op: string) => { st.clipboard = FinderApp.clipboard = { op, paths: [...st.selection], from: st.path } }
st.paste = () => {
const cb = st.clipboard; if (!cb) return
for (const p of cb.paths) {
try { cb.op === 'copy' ? FS.copy(p, st.path) : FS.move(p, st.path) } catch (e: any) { UI.alert('粘贴失败', e.message, this.icon) }
}
if (cb.op === 'cut') { st.clipboard = FinderApp.clipboard = null }
}
st.showInfo = () => {
const p = [...st.selection][0]; if (!p) return
const n = FS.node(p)!
const size = n.t === 'f' ? fmtBytes((n.data || '').length) : (n.t === 'd' ? `${Object.keys(n.c!).length} 个项目` : '—')
UI.dialog({ icon: FS.iconFor(p), title: FS.baseName(p) + ' 简介', buttons: ['好'], msg: `种类:${FS.kindOf(p)}\n大小${size}\n位置${FS.dirName(p).replace(FS.HOME, '~')}\n修改时间${new Date(n.mtime || 0).toLocaleString('zh-CN')}` })
}
st.startRename = (p: string) => {
const itemEl = content.querySelector(`[data-path="${CSS.escape(p)}"]`)
if (!itemEl) return
const nameEl = itemEl.querySelector('.fi-name') as HTMLElement
const old = FS.baseName(p)
const input = el('input', { class: 'text-input fi-rename', value: old }) as HTMLInputElement
nameEl.replaceWith(input)
input.focus()
const dot = old.lastIndexOf('.'); input.setSelectionRange(0, dot > 0 ? dot : old.length)
let done = false
const commit = () => {
if (done) return; done = true
const v = input.value.trim()
if (v && v !== old) { try { FS.rename(p, v) } catch (e: any) { UI.alert('无法重命名', e.message, this.icon) } }
render()
content.focus({ preventScroll: true })
}
input.addEventListener('keydown', (e: KeyboardEvent) => { e.stopPropagation(); if (e.key === 'Enter') commit(); if (e.key === 'Escape') { done = true; render() } })
input.addEventListener('blur', commit)
}
win.body.classList.add('finder')
const backBtn = el('button', { class: 'fb-btn', title: '后退', html: '' }) as HTMLButtonElement
const fwdBtn = el('button', { class: 'fb-btn', title: '前进', html: '' }) as HTMLButtonElement
const crumb = el('div', { class: 'fb-crumb' })
const viewSeg = el('div', { class: 'segmented' },
el('button', { text: '图标', title: '图标视图', onclick: () => st.setView('icon') }),
el('button', { text: '列表', title: '列表视图', onclick: () => st.setView('list') }))
const sortSel = el('select', { class: 'text-input fb-sort', title: '排序方式' },
el('option', { value: 'name', text: '按名称' }), el('option', { value: 'kind', text: '按种类' }), el('option', { value: 'date', text: '按日期' })) as HTMLSelectElement
sortSel.value = st.sortBy
sortSel.addEventListener('change', () => st.setSort(sortSel.value))
const newBtn = el('button', { class: 'fb-btn', title: '新建文件夹', html: '' })
newBtn.addEventListener('click', () => st.newFolder())
const searchInput = el('input', { class: 'text-input fb-search', type: 'search', placeholder: '搜索' }) as HTMLInputElement
searchInput.addEventListener('input', debounce(() => { st.search = searchInput.value.trim(); renderList() }, 200))
const toolbar = el('div', { class: 'fb-toolbar' }, backBtn, fwdBtn, crumb, viewSeg, sortSel, newBtn, searchInput)
const side = el('div', { class: 'fb-sidebar' })
const content = el('div', { class: 'fb-content', tabindex: '0' })
const main = el('div', { class: 'fb-main' }, side, content)
win.body.append(toolbar, main)
const favs: [string, string, string][] = [
['桌面', FS.HOME + '/Desktop', '🖥'], ['文稿', FS.HOME + '/Documents', '📄'], ['下载', FS.HOME + '/Downloads', '⬇️'],
['图片', FS.HOME + '/Pictures', '🖼'], ['音乐', FS.HOME + '/Music', '🎵'], ['应用程序', FS.HOME + '/Applications', '📦'],
]
const locs: [string, string, string][] = [['iCloud 云盘', '__icloud', '☁️'], ['废纸篓', FS.TRASH, '🗑']]
const renderSide = () => {
side.innerHTML = ''
const group = (title: string, items: [string, string, string][]) => {
side.append(el('div', { class: 'fb-side-title', text: title }))
for (const [name, p, ico] of items) {
const row = el('div', { class: 'fb-side-item' + (st.path === p ? ' sel' : '') },
el('span', { class: 'fb-side-ico', text: ico }), el('span', { text: name }))
row.addEventListener('click', () => {
if (p === '__icloud') { UI.dialog({ icon: '/assets/icons/finder.png', title: 'iCloud 云盘', msg: 'iCloud 在离线环境下不可用。你的文件都保存在本机的虚拟磁盘中。', buttons: ['好'] }); return }
st.navigate(p)
})
side.append(row)
}
}
group('个人收藏', favs); group('位置', locs)
}
const sortItems = (items: any[]) => {
const by = st.sortBy
return items.sort((a: any, b: any) => {
if ((a.node.t === 'd') !== (b.node.t === 'd')) return a.node.t === 'd' ? -1 : 1
if (by === 'kind') return FS.kindOf(a.path).localeCompare(FS.kindOf(b.path), 'zh') || a.name.localeCompare(b.name, 'zh-Hans-CN')
if (by === 'date') return (b.node.mtime || 0) - (a.node.mtime || 0)
return a.name.localeCompare(b.name, 'zh-Hans-CN')
})
}
const renderCrumb = () => {
crumb.innerHTML = ''
const rel = st.path === FS.HOME ? ['~'] : st.path.replace(FS.HOME, '~').split('/').filter(Boolean)
let acc = ''
rel.forEach((seg: string, i: number) => {
acc += (i === 0 && seg === '~') ? '' : '/' + seg
const target = seg === '~' ? FS.HOME : FS.join(FS.HOME, acc)
const b = el('button', { class: 'fb-crumb-item' + (i === rel.length - 1 ? ' cur' : ''), text: seg === '~' ? '客人用户' : seg })
b.addEventListener('click', () => st.navigate(target))
crumb.append(b)
if (i < rel.length - 1) crumb.append(el('span', { class: 'fb-crumb-sep', text: '' }))
})
}
const updateSel = () => {
content.querySelectorAll('.fb-item').forEach((n: any) => n.classList.toggle('sel', st.selection.has(n.dataset.path)))
}
const renderList = () => {
content.innerHTML = ''
content.className = 'fb-content ' + (st.view === 'icon' ? 'icon-view' : 'list-view')
let items: any[] = []
try { items = FS.list(st.path) } catch (e) { content.append(el('div', { class: 'empty-state', text: '无法读取此文件夹' })); return }
if (st.search) items = items.filter((it: any) => it.name.toLowerCase().includes(st.search.toLowerCase()))
items = sortItems(items)
if (!items.length) { content.append(el('div', { class: 'empty-state' }, el('div', { class: 'es-icon', text: '📂' }), el('div', { text: st.search ? '没有匹配的结果' : '文件夹为空' }))) }
for (const it of items) {
const selected = st.selection.has(it.path)
const itemEl = el('div', {
class: 'fb-item' + (selected ? ' sel' : ''), dataset: { path: it.path }, draggable: 'true', tabindex: '0',
}, iconImg(FS.iconFor(it.path), '', 'fi-icon'), el('div', { class: 'fi-name', text: it.name }))
if (st.view === 'list') {
itemEl.append(
el('span', { class: 'fi-col', text: FS.kindOf(it.path) }),
el('span', { class: 'fi-col', text: it.node.mtime ? new Date(it.node.mtime).toLocaleDateString('zh-CN') : '—' }),
el('span', { class: 'fi-col', text: it.node.t === 'f' ? fmtBytes((it.node.data || '').length) : '—' }))
}
itemEl.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation()
if (e.metaKey || e.ctrlKey) { st.selection.has(it.path) ? st.selection.delete(it.path) : st.selection.add(it.path); st.anchor = it.path }
else if (e.shiftKey && st.anchor) {
const arr = items.map((x: any) => x.path)
const a = arr.indexOf(st.anchor), b = arr.indexOf(it.path)
if (a >= 0 && b >= 0) st.selection = new Set(arr.slice(Math.min(a, b), Math.max(a, b) + 1))
} else { st.selection = new Set([it.path]); st.anchor = it.path }
updateSel()
content.focus({ preventScroll: true })
})
itemEl.addEventListener('dblclick', (e: Event) => { e.stopPropagation(); it.node.t === 'd' ? st.navigate(it.path) : Apps.openPath(it.path) })
itemEl.addEventListener('contextmenu', (e: MouseEvent) => {
e.preventDefault(); e.stopPropagation()
if (!st.selection.has(it.path)) { st.selection = new Set([it.path]); st.anchor = it.path; updateSel() }
UI.contextMenu((FinderApp as any).itemMenu(st, it), e)
})
itemEl.addEventListener('dragstart', (e: DragEvent) => { e.dataTransfer!.setData('text/x-fspath', it.path) })
if (it.node.t === 'd') {
itemEl.addEventListener('dragover', (e: Event) => { e.preventDefault(); itemEl.classList.add('drop-hint') })
itemEl.addEventListener('dragleave', () => itemEl.classList.remove('drop-hint'))
itemEl.addEventListener('drop', (e: DragEvent) => {
e.preventDefault(); itemEl.classList.remove('drop-hint')
const src = e.dataTransfer!.getData('text/x-fspath')
if (src && src !== it.path) { try { FS.move(src, it.path) } catch (err: any) { UI.alert('无法移动', err.message, this.icon) } }
})
}
content.append(itemEl)
}
}
content.addEventListener('click', () => {
if (st.selection.size) { st.selection.clear(); st.anchor = null; updateSel() }
})
content.addEventListener('contextmenu', (e: MouseEvent) => {
if ((e.target as Element).closest('.fb-item')) return
e.preventDefault()
UI.contextMenu([
{ label: '新建文件夹', action: () => st.newFolder() },
{ sep: true },
{ label: '按图标显示', checked: st.view === 'icon', action: () => st.setView('icon') },
{ label: '按列表显示', checked: st.view === 'list', action: () => st.setView('list') },
{ sep: true },
{ label: '粘贴', disabled: !st.clipboard, action: () => st.paste() },
{ label: '显示简介', action: () => {
const n = FS.node(st.path)!
UI.dialog({ icon: '/assets/icons/folder.svg', title: FS.baseName(st.path) + ' 简介', buttons: ['好'], msg: `种类:文件夹\n项目数${Object.keys(n.c!).length}\n位置${st.path.replace(FS.HOME, '~')}` })
} },
], e)
})
content.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Backspace' && (e.metaKey || e.ctrlKey)) { st.trashSelection(); e.preventDefault() }
else if (e.key === 'Enter' && st.selection.size === 1) { st.startRename([...st.selection][0]); e.preventDefault() }
else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c') { st.copySel('copy') }
else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') { st.paste() }
else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); st.selection = new Set(FS.list(st.path).map((i: any) => i.path)); updateSel() }
else if (e.key === 'Delete' && st.selection.size) { st.trashSelection() }
})
backBtn.addEventListener('click', () => { if (st.hi >= 0) { const p = st.history[st.hi--]; st.navigate(p, false) } })
fwdBtn.addEventListener('click', () => { if (st.hi < st.history.length - 1) { const p = st.history[++st.hi]; st.navigate(p, false) } })
const render = () => {
WM.setTitle(win, st.path === FS.TRASH ? '废纸篓' : FS.baseName(st.path) || '访达')
renderSide(); renderCrumb(); renderList()
backBtn.disabled = st.hi <= 0
fwdBtn.disabled = st.hi >= st.history.length - 1
;(viewSeg.children[0] as HTMLElement).classList.toggle('on', st.view === 'icon')
;(viewSeg.children[1] as HTMLElement).classList.toggle('on', st.view === 'list')
sortSel.value = st.sortBy
}
;(win as any)._fsUnsub = Bus.on('fs:changed', () => { if (document.body.contains(win.el)) render() })
win.onClose = () => { (win as any)._fsUnsub && (win as any)._fsUnsub() }
render()
if (args.path) st.navigate(args.path)
},
itemMenu(st: any, it: any) {
const inTrash = st.path === FS.TRASH || st.path.startsWith(FS.TRASH + '/')
if (inTrash) return [
{ label: '放回原处', action: () => { try { FS.restore(it.name) } catch (e: any) { UI.alert('无法还原', e.message, '/assets/icons/finder.png') } } },
{ label: '立即删除…', action: async () => { if (await UI.confirm('确定要永久删除吗?', `"${it.name}"将被永久删除,此操作无法撤销。`, { ok: '删除', danger: true })) FS.remove(it.path) } },
{ sep: true },
{ label: '清空废纸篓…', action: async () => { if (await UI.confirm('确定要清空废纸篓吗?', '此操作无法撤销。', { ok: '清空', danger: true })) FS.emptyTrash() } },
]
return [
{ label: '打开', action: () => it.node.t === 'd' ? st.navigate(it.path) : Apps.openPath(it.path) },
{ sep: true },
{ label: '显示简介', action: () => { st.selection = new Set([it.path]); st.showInfo() } },
{ label: '重命名', action: () => st.startRename(it.path) },
{ label: '复制"' + it.name + '"', action: () => { try { FS.copy(it.path, st.path) } catch (e: any) { UI.alert('无法复制', e.message, '/assets/icons/finder.png') } } },
{ sep: true },
{ label: '移到废纸篓', action: () => { st.selection = new Set([it.path]); st.trashSelection() } },
]
},
clipboard: null as any,
}
Apps.register(FinderApp)