From 6c2950730ec7199ffd639839a9f5a09cc433e48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Wed, 22 Jul 2026 09:31:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84finder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apps/finder/Finder.vue | 446 ++++++++++++++++++++++- src/apps/finder/index.ts | 273 +------------- tests/cases/17-finder-vue.test.ts | 578 ++++++++++++++++++++++++++++++ 3 files changed, 1030 insertions(+), 267 deletions(-) create mode 100644 tests/cases/17-finder-vue.test.ts diff --git a/src/apps/finder/Finder.vue b/src/apps/finder/Finder.vue index 0ada1d8..eb6eaf7 100644 --- a/src/apps/finder/Finder.vue +++ b/src/apps/finder/Finder.vue @@ -1 +1,445 @@ - + + + diff --git a/src/apps/finder/index.ts b/src/apps/finder/index.ts index 6d7aac1..9505102 100644 --- a/src/apps/finder/index.ts +++ b/src/apps/finder/index.ts @@ -1,16 +1,8 @@ +// Finder 应用 — Vue 组件化 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, @@ -21,11 +13,11 @@ export const FinderApp = { { 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() }, + { 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: '⌘C', disabled: !st?.selection?.size, action: () => st?.copySel('copy') }, { label: '粘贴', key: '⌘V', disabled: !st?.clipboard, action: () => st?.paste() }, ], view: [ @@ -40,260 +32,9 @@ export const FinderApp = { ] }) }, - 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) + render(win: any) { + const vnode = h(FinderComponent, { win }) + render(vnode, win.body) }, - 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) diff --git a/tests/cases/17-finder-vue.test.ts b/tests/cases/17-finder-vue.test.ts new file mode 100644 index 0000000..0dd9ef1 --- /dev/null +++ b/tests/cases/17-finder-vue.test.ts @@ -0,0 +1,578 @@ +/** + * 17-finder-vue: Finder Vue 组件化全覆盖测试 + * + * 覆盖功能点: + * 导航 — 侧边栏点击、面包屑、前进/后退、参数路径 + * 视图 — 图标/列表切换、排序 + * 选择 — 单击、Ctrl+单击、Shift+范围、Cmd+A、空白区取消 + * 文件操作 — 新建文件夹、重命名、移到废纸篓 + * 剪贴板 — 拷贝/粘贴 + * 搜索 — 过滤文件列表 + * 键盘 — Enter 重命名、Cmd+C/V/A、Delete 删除 + * 拖放 — dragstart/drop + * 右键菜单 — 空白区菜单、文件菜单 + * FS 变化 — fs:changed 自动刷新 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { h, render, nextTick } from 'vue' +import FinderComponent from '../../src/apps/finder/Finder.vue' +import { fs } from '../../src/composables/useFS' +import { wm } from '../../src/composables/useWM' +import { setupDOM } from '../helpers' + +// 模拟 win 对象 +function makeMockWin() { + const el = document.createElement('div') + el.className = 'window' + document.getElementById('window-layer')?.appendChild(el) + const body = document.createElement('div') + body.className = 'win-body' + el.appendChild(body) + return { + id: 'test-win', + appId: 'finder', + el, + body, + timers: [] as any[], + data: {}, + } +} + +describe('17-finder-vue — Finder Vue 组件化', () => { + let win: any + + beforeEach(() => { + localStorage.clear(); + (fs as any).root = null + fs.init() + setupDOM() + win = makeMockWin() + }) + + afterEach(() => { + render(null, win.body) + win.el.remove() + }) + + function mount() { + const vnode = h(FinderComponent, { win }) + render(vnode, win.body) + } + + // ==================== 初始化 ==================== + describe('初始化', () => { + it('挂载后在 body 中渲染 .finder 容器', () => { + mount() + expect(win.body.querySelector('.finder')).toBeTruthy() + }) + + it('默认路径为 Desktop', () => { + mount() + const cur = win.body.querySelector('.fb-crumb-item.cur') + expect(cur?.textContent).toBe('Desktop') + }) + + it('侧边栏包含个人收藏和位置', () => { + mount() + const titles = win.body.querySelectorAll('.fb-side-title') + expect(titles.length).toBeGreaterThanOrEqual(2) + expect(titles[0].textContent).toBe('个人收藏') + }) + }) + + // ==================== 导航 ==================== + describe('导航', () => { + it('点击侧边栏收藏项导航到对应目录', async () => { + mount() + const docItem = [...win.body.querySelectorAll('.fb-side-item')] + .find(el => el.textContent?.includes('文稿')) as HTMLElement + expect(docItem).toBeTruthy() + docItem.click() + await nextTick() + const cur = win.body.querySelector('.fb-crumb-item.cur') + expect(cur?.textContent).toBe('Documents') + }) + + it('点击面包屑导航到上级目录', async () => { + mount() + // 先导航到 Desktop/Sample Folder + const folderEl = [...win.body.querySelectorAll('.fb-item')] + .find(el => el.querySelector('.fi-name')?.textContent === 'Sample Folder') as HTMLElement + if (folderEl) { + folderEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + await nextTick() + // 点击面包屑回到 Desktop + const crumbs = win.body.querySelectorAll('.fb-crumb-item') + const desktopCrumb = [...crumbs].find(c => c.textContent?.includes('Desktop')) as HTMLElement + if (desktopCrumb) { + desktopCrumb.click() + await nextTick() + const cur = win.body.querySelector('.fb-crumb-item.cur') + expect(cur?.textContent).toBe('Desktop') + } + } + }) + + it('后退按钮初始禁用', () => { + mount() + const backBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="后退"]') as HTMLButtonElement + expect(backBtn.disabled).toBe(true) + }) + + it('通过 win.data.path 参数指定初始路径', () => { + win.data = { path: fs.HOME + '/Documents' } + mount() + const cur = win.body.querySelector('.fb-crumb-item.cur') + expect(cur?.textContent).toBe('Documents') + }) + + it('前进/后退完整循环', async () => { + mount() + // 双击进入 Sample Folder + const folderEl = [...win.body.querySelectorAll('.fb-item')] + .find(el => el.querySelector('.fi-name')?.textContent === 'Sample Folder') as HTMLElement + if (!folderEl) return + folderEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Sample Folder') + // 后退 + const backBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="后退"]') as HTMLButtonElement + backBtn.click() + await nextTick() + expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Desktop') + // 前进 + const fwdBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="前进"]') as HTMLButtonElement + fwdBtn.click() + await nextTick() + expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Sample Folder') + }) + + it('前进按钮在最新位置禁用', () => { + mount() + const fwdBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="前进"]') as HTMLButtonElement + expect(fwdBtn.disabled).toBe(true) + }) + + it('iCloud 侧边栏占位点击', () => { + mount() + const icloudItem = [...win.body.querySelectorAll('.fb-side-item')] + .find(el => el.textContent?.includes('iCloud')) as HTMLElement + // 不导航,仅验证不抛错 + expect(() => icloudItem.click()).not.toThrow() + // 路径应保持 Desktop(不导航到 iCloud) + expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Desktop') + }) + + it('废纸篓侧边栏导航', async () => { + mount() + const trashItems = [...win.body.querySelectorAll('.fb-side-item')] + const trashItem = trashItems.find(el => { + const text = el.textContent || '' + return text.includes('Trash') || text.includes('废纸篓') + }) as HTMLElement + if (!trashItem) return + trashItem.click() + await nextTick() + const cur = win.body.querySelector('.fb-crumb-item.cur') + expect(cur).toBeTruthy() + }) + }) + + // ==================== 视图切换 ==================== + describe('视图切换', () => { + it('默认图标视图时 .fb-content 有 icon-view 类', () => { + mount() + expect(win.body.querySelector('.fb-content')?.classList.contains('icon-view')).toBe(true) + }) + + it('切换到列表视图后 .fb-content 有 list-view 类', async () => { + mount() + const listBtn = win.body.querySelector('.segmented button:last-child') as HTMLElement + listBtn.click() + await nextTick() + expect(win.body.querySelector('.fb-content')?.classList.contains('list-view')).toBe(true) + // 列表视图应显示额外列 + const cols = win.body.querySelectorAll('.fi-col') + expect(cols.length).toBeGreaterThan(0) + }) + + it('视图切换按钮有 on 类', async () => { + mount() + const btns = win.body.querySelectorAll('.segmented button') + const iconBtn = btns[0] as HTMLElement + const listBtn = btns[1] as HTMLElement + expect(iconBtn.classList.contains('on')).toBe(true) + listBtn.click() + await nextTick() + expect(listBtn.classList.contains('on')).toBe(true) + expect(iconBtn.classList.contains('on')).toBe(false) + }) + }) + + // ==================== 排序 ==================== + describe('排序', () => { + it('排序下拉存在三个选项', () => { + mount() + const sel = win.body.querySelector('.fb-sort') as HTMLSelectElement + expect(sel.options.length).toBe(3) + expect(sel.options[0].value).toBe('name') + expect(sel.options[1].value).toBe('kind') + expect(sel.options[2].value).toBe('date') + }) + + it('文件夹排在文件前面', () => { + mount() + const names = [...win.body.querySelectorAll('.fi-name')].map(e => e.textContent) + const firstFolderIdx = names.findIndex(n => n === 'Sample Folder') + const firstFileIdx = names.findIndex(n => n === 'welcome.txt') + expect(firstFolderIdx).toBeLessThan(firstFileIdx) + }) + }) + + // ==================== 文件选择 ==================== + describe('文件选择', () => { + it('单击选中单个文件', async () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + item.click() + await nextTick() + expect(item.classList.contains('sel')).toBe(true) + }) + + it('单击另一个文件取消前一个选择', async () => { + mount() + const items = win.body.querySelectorAll('.fb-item') + if (items.length < 2) return + ;(items[0] as HTMLElement).click() + await nextTick() + ;(items[1] as HTMLElement).click() + await nextTick() + expect((items[0] as HTMLElement).classList.contains('sel')).toBe(false) + expect((items[1] as HTMLElement).classList.contains('sel')).toBe(true) + }) + + it('Ctrl+单击多选', async () => { + mount() + const items = win.body.querySelectorAll('.fb-item') + if (items.length < 2) return + ;(items[0] as HTMLElement).dispatchEvent(new MouseEvent('click', { ctrlKey: true, bubbles: true })) + await nextTick() + ;(items[1] as HTMLElement).dispatchEvent(new MouseEvent('click', { ctrlKey: true, bubbles: true })) + await nextTick() + expect((items[0] as HTMLElement).classList.contains('sel')).toBe(true) + expect((items[1] as HTMLElement).classList.contains('sel')).toBe(true) + }) + + it('Shift+单击范围选择', async () => { + mount() + const items = win.body.querySelectorAll('.fb-item') + if (items.length < 3) return + // 先单击选中第一个 + const first = items[0] as HTMLElement + first.click() + await nextTick() + // Shift+单击第三个:需要直接在 .fb-item 上触发 + const third = items[2] as HTMLElement + third.dispatchEvent(new MouseEvent('click', { shiftKey: true, bubbles: true, cancelable: true })) + await nextTick() + // 0, 1, 2 都应该被选中 + expect(first.classList.contains('sel')).toBe(true) + expect((items[1] as HTMLElement).classList.contains('sel')).toBe(true) + expect(third.classList.contains('sel')).toBe(true) + }) + }) + + // ==================== 双击打开 ==================== + describe('双击打开', () => { + it('双击文件夹导航进入', async () => { + mount() + // Find a folder item and dblclick + const folderEl = [...win.body.querySelectorAll('.fb-item')] + .find(el => el.querySelector('.fi-name')?.textContent === 'Sample Folder') as HTMLElement + if (!folderEl) return + folderEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + await nextTick() + const cur = win.body.querySelector('.fb-crumb-item.cur') + expect(cur?.textContent).toBe('Sample Folder') + }) + + it('双击文件触发 openPath', async () => { + mount() + const fileEl = [...win.body.querySelectorAll('.fb-item')] + .find(el => el.querySelector('.fi-name')?.textContent === 'welcome.txt') as HTMLElement + expect(fileEl).toBeTruthy() + // 双击文件不应该崩溃 + expect(() => { + fileEl?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }).not.toThrow() + }) + }) + + // ==================== 搜索 ==================== + describe('搜索', () => { + it('搜索过滤文件列表', async () => { + mount() + const searchInput = win.body.querySelector('.fb-search') as HTMLInputElement + searchInput.value = 'welcome' + searchInput.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + const names = [...win.body.querySelectorAll('.fi-name')].map(e => e.textContent) + expect(names.every(n => n?.toLowerCase().includes('welcome'))).toBe(true) + }) + + it('无匹配时显示空状态', async () => { + mount() + const searchInput = win.body.querySelector('.fb-search') as HTMLInputElement + searchInput.value = 'zzzz_not_exist' + searchInput.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.empty-state')?.textContent).toContain('没有匹配的结果') + }) + }) + + // ==================== 键盘操作 ==================== + describe('键盘操作', () => { + it('Cmd+A 全选所有文件', async () => { + mount() + const content = win.body.querySelector('.fb-content') as HTMLElement + content.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', metaKey: true, bubbles: true, cancelable: true })) + await nextTick() + const selCount = win.body.querySelectorAll('.fb-item.sel').length + const totalCount = win.body.querySelectorAll('.fb-item').length + expect(selCount).toBe(totalCount) + }) + + it('Cmd+C 拷贝选中文件', async () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + item.click() + await nextTick() + const content = win.body.querySelector('.fb-content') as HTMLElement + content.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true, bubbles: true })) + expect((window as any).__finderClipboard).toBeTruthy() + expect((window as any).__finderClipboard.op).toBe('copy') + }) + + it('Cmd+V 粘贴', async () => { + // 先设置 clipboard + ;(window as any).__finderClipboard = { + op: 'copy', paths: [fs.HOME + '/Desktop/welcome.txt'], from: fs.HOME + '/Desktop' + } + mount() + const content = win.body.querySelector('.fb-content') as HTMLElement + content.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true, bubbles: true })) + // 粘贴后文件出现在桌面 + await nextTick() + const names = [...win.body.querySelectorAll('.fi-name')].map(e => e.textContent) + const copies = names.filter(n => n?.includes('welcome')) + expect(copies.length).toBeGreaterThanOrEqual(1) + }) + + it('Delete 键移到废纸篓', async () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + item.click() + await nextTick() + const content = win.body.querySelector('.fb-content') as HTMLElement + content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })) + await nextTick() + // 选中被清除 + expect(win.body.querySelectorAll('.fb-item.sel').length).toBe(0) + // 文件已移走(选择集被清空,fs trash 已调用) + }) + }) + + // ==================== 文件操作 ==================== + describe('文件操作', () => { + it('新建文件夹按钮存在', () => { + mount() + const newBtn = win.body.querySelector('.fb-toolbar .fb-btn[title="新建文件夹"]') + expect(newBtn).toBeTruthy() + }) + + it('重命名:Enter 键盘触发表单出现', async () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + item.click() + await nextTick() + const content = win.body.querySelector('.fb-content') as HTMLElement + content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })) + await nextTick() + const renameInput = win.body.querySelector('.fi-rename') + expect(renameInput).toBeTruthy() + }) + + it('重命名:Escape 取消重命名', async () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + item.click() + await nextTick() + const content = win.body.querySelector('.fb-content') as HTMLElement + content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await nextTick() + const input = win.body.querySelector('.fi-rename') as HTMLInputElement + expect(input).toBeTruthy() + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await nextTick() + // 重命名输入框消失 + expect(win.body.querySelector('.fi-rename')).toBeNull() + }) + }) + + // ==================== 拖放 ==================== + describe('拖放', () => { + // jsdom 不完全支持 DragEvent + DataTransfer,此测试在真实浏览器验证 + it.skip('拖放设置 dataTransfer', () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + // jsdom DataTransfer 在新版本可用 + const dt = new DataTransfer() + const event = new DragEvent('dragstart', { dataTransfer: dt, bubbles: true, cancelable: true }) + item.dispatchEvent(event) + expect(dt.getData('text/x-fspath')).toBeTruthy() + }) + }) + + // ==================== 右键菜单 ==================== + describe('右键菜单', () => { + it('右键空白区触发 contextmenu', () => { + mount() + const content = win.body.querySelector('.fb-content') as HTMLElement + // Vue 的 @contextmenu.prevent 会调用 preventDefault + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }) + content.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + }) + + it('右键文件触发文件菜单', () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }) + item.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + }) + }) + + // ==================== win.appState 暴露 ==================== + describe('win.appState', () => { + it('暴露 path/selection/setView 给 menus', () => { + mount() + expect(win.appState).toBeTruthy() + expect(typeof win.appState.path).toBe('string') + expect(win.appState.selection instanceof Set).toBe(true) + expect(typeof win.appState.setView).toBe('function') + expect(typeof win.appState.newFolder).toBe('function') + expect(typeof win.appState.trashSelection).toBe('function') + expect(typeof win.appState.copySel).toBe('function') + expect(typeof win.appState.paste).toBe('function') + expect(typeof win.appState.showInfo).toBe('function') + }) + + it('selection.size 跟随单击变化', async () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + item.click() + await nextTick() + expect(win.appState.selection.size).toBe(1) + }) + + it('clipboard 为空时返回 null', () => { + ;(window as any).__finderClipboard = null + mount() + expect(win.appState.clipboard).toBeNull() + }) + }) + + // ==================== FS 变化刷新 ==================== + describe('FS 变化', () => { + it('fs:changed 事件触发 UI 刷新', async () => { + mount() + const beforeCount = win.body.querySelectorAll('.fb-item').length + // 模拟 FS 外部变化:创建新文件夹 + fs.mkdir(fs.HOME + '/Desktop/_test-refresh', { silent: true }) + // 触发 fs:changed + const { bus } = await import('../../src/composables/useBus') + bus.emit('fs:changed', { op: 'mkdir', paths: [fs.HOME + '/Desktop/_test-refresh'] }) + await nextTick() + const afterCount = win.body.querySelectorAll('.fb-item').length + expect(afterCount).toBe(beforeCount + 1) + // 清理 + fs.remove(fs.HOME + '/Desktop/_test-refresh') + }) + }) + + // ==================== 空文件夹 ==================== + describe('空文件夹', () => { + it('空文件夹显示空状态', async () => { + // 创建一个空文件夹并导航 + fs.mkdir(fs.HOME + '/Desktop/_empty-folder') + win.data = { path: fs.HOME + '/Desktop/_empty-folder' } + mount() + await nextTick() + expect(win.body.querySelector('.empty-state')?.textContent).toContain('文件夹为空') + // 清理 + fs.remove(fs.HOME + '/Desktop/_empty-folder') + }) + }) + + // ==================== 拖放高亮 ==================== + describe('拖放', () => { + // jsdom 中 DragEvent 与 Vue 事件系统不完全兼容,在真实浏览器验证 + it.skip('拖拽到文件夹上显示 drop-hint', async () => { + mount() + const folderEl = [...win.body.querySelectorAll('.fb-item')] + .find(el => el.querySelector('.fi-name')?.textContent === 'Sample Folder') as HTMLElement + if (!folderEl) return + folderEl.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true })) + await nextTick() + expect(folderEl).toBeTruthy() + }) + + // jsdom 不完全支持 DragEvent + DataTransfer + it.skip('拖放设置 dataTransfer', () => { + mount() + const item = win.body.querySelector('.fb-item') as HTMLElement + const dt = new DataTransfer() + const event = new DragEvent('dragstart', { dataTransfer: dt, bubbles: true, cancelable: true }) + item.dispatchEvent(event) + expect(dt.getData('text/x-fspath')).toBeTruthy() + }) + }) + + // ==================== 图标错误回退 ==================== + describe('图标', () => { + it('图标加载失败回退到 finder 图标', async () => { + mount() + const img = win.body.querySelector('.fi-icon') as HTMLImageElement + expect(img).toBeTruthy() + const originalSrc = img.src + img.dispatchEvent(new Event('error', { bubbles: true })) + await nextTick() + expect(img.src).toContain('finder.png') + }) + }) + + // ==================== 标题更新 ==================== + describe('标题', () => { + it('导航后调用 wm.setTitle', async () => { + mount() + await nextTick() + const docItem = [...win.body.querySelectorAll('.fb-side-item')] + .find(el => el.textContent?.includes('文稿')) as HTMLElement + if (docItem) { + docItem.click() + await nextTick() + expect(win.body.querySelector('.fb-crumb-item.cur')?.textContent).toBe('Documents') + } + }) + }) + + // ==================== 卸载清理 ==================== + describe('卸载清理', () => { + it('卸载后 DOM 为空', () => { + mount() + render(null, win.body) + expect(win.body.children.length).toBe(0) + }) + }) +})