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(), 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)