300 lines
17 KiB
TypeScript
300 lines
17 KiB
TypeScript
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)
|