feat: 重构finder

This commit is contained in:
李岩岩 2026-07-22 09:31:22 +08:00
parent 57f052b6cb
commit 6c2950730e
3 changed files with 1030 additions and 267 deletions

View File

@ -1 +1,445 @@
<template><div></div></template>
<template>
<div class="finder">
<!-- 工具栏 -->
<div class="fb-toolbar">
<button class="fb-btn" title="后退" :disabled="!canBack" @click="goBack" v-html="''"></button>
<button class="fb-btn" title="前进" :disabled="!canFwd" @click="goFwd" v-html="''"></button>
<div class="fb-crumb">
<template v-for="(seg, i) in crumbSegments" :key="i">
<button
:class="'fb-crumb-item' + (i === crumbSegments.length - 1 ? ' cur' : '')"
@click="navigateTo(seg.target)"
>{{ seg.label }}</button>
<span v-if="i < crumbSegments.length - 1" class="fb-crumb-sep"></span>
</template>
</div>
<div class="segmented">
<button :class="{ on: view === 'icon' }" title="图标视图" @click="setView('icon')">图标</button>
<button :class="{ on: view === 'list' }" title="列表视图" @click="setView('list')">列表</button>
</div>
<select class="text-input fb-sort" title="排序方式" v-model="sortBy" @change="onSortChange">
<option value="name">按名称</option>
<option value="kind">按种类</option>
<option value="date">按日期</option>
</select>
<button class="fb-btn" title="新建文件夹" @click="newFolder" v-html="''"></button>
<input
class="text-input fb-search"
type="search"
placeholder="搜索"
v-model="search"
>
</div>
<!-- 主区域 -->
<div class="fb-main">
<!-- 侧边栏 -->
<div class="fb-sidebar">
<div class="fb-side-title">个人收藏</div>
<div
v-for="(item, i) in favItems" :key="'fav' + i"
:class="'fb-side-item' + (path === item.path ? ' sel' : '')"
@click="onSideClick(item)"
>
<span class="fb-side-ico">{{ item.ico }}</span>
<span>{{ item.name }}</span>
</div>
<div class="fb-side-title">位置</div>
<div
v-for="(item, i) in locItems" :key="'loc' + i"
:class="'fb-side-item' + (path === item.path ? ' sel' : '')"
@click="onSideClick(item)"
>
<span class="fb-side-ico">{{ item.ico }}</span>
<span>{{ item.name }}</span>
</div>
</div>
<!-- 文件内容区 -->
<div
:class="'fb-content ' + (view === 'icon' ? 'icon-view' : 'list-view')"
tabindex="0"
@click="onContentClick"
@contextmenu="onContentContextmenu"
@keydown="onContentKeydown"
@dragover.prevent
>
<div v-if="!displayItems.length" class="empty-state">
<div class="es-icon">📂</div>
<div>{{ search ? '没有匹配的结果' : '文件夹为空' }}</div>
</div>
<div
v-for="item in displayItems" :key="item.path"
:class="'fb-item' + (selection.has(item.path) ? ' sel' : '') + (dragOverPath === item.path ? ' drop-hint' : '')"
:data-path="item.path"
draggable="true"
tabindex="0"
@click.stop="onItemClick(item, $event)"
@dblclick.stop="onItemDblclick(item)"
@contextmenu.stop.prevent="onItemContextmenu(item, $event)"
@dragstart="onItemDragstart(item, $event)"
@dragover.prevent="item.node.t === 'd' && (dragOverPath = item.path)"
@dragleave="dragOverPath === item.path && (dragOverPath = null)"
@drop="onItemDrop(item, $event)"
>
<img :src="iconFor(item.path)" alt="" class="fi-icon" @error="onIconError($event)">
<!-- 重命名模式 -->
<input
v-if="renaming === item.path"
class="text-input fi-rename"
:value="renameBaseName"
@keydown.stop="onRenameKeydown"
@blur="commitRename"
>
<div v-else class="fi-name">{{ item.name }}</div>
<!-- 列表视图额外列 -->
<template v-if="view === 'list'">
<span class="fi-col">{{ kindOf(item.path) }}</span>
<span class="fi-col">{{ item.node.mtime ? fmtDate(item.node.mtime) : '—' }}</span>
<span class="fi-col">{{ item.node.t === 'f' ? fmtBytes((item.node.data || '').length) : '—' }}</span>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { fmtBytes } from '../../utils'
import { bus } from '../../composables/useBus'
import { fs } from '../../composables/useFS'
import { wm } from '../../composables/useWM'
import { ui } from '../../composables/useUI'
import { Apps } from '../../composables/useApps'
const Sys = () => (window as any).Sys
const props = defineProps<{ win: any }>()
// ============ ============
const S = Sys()
const sView = S?.settings?.finderView
const sSort = S?.settings?.finderSort
const path = ref(argsPath() || fs.HOME + '/Desktop')
const history = ref<string[]>([fs.normalize(path.value)])
const hi = ref(0)
const anchor = ref<string | null>(null)
const view = ref<'icon' | 'list'>(sView || 'icon')
const sortBy = ref<'name' | 'kind' | 'date'>(sSort || 'name')
const selectionSet = ref(new Set<string>())
const search = ref('')
const renaming = ref<string | null>(null)
const dragOverPath = ref<string | null>(null)
const fsTick = ref(0) // fs:changed items
function argsPath() {
try { return props.win.data?.path || null } catch { return null }
}
// menus()
const st = reactive({
get path() { return path.value },
get view() { return view.value },
get sortBy() { return sortBy.value },
get selection() { return selectionSet.value },
get clipboard() { return (window as any).__finderClipboard || null },
navigate, newFolder, trashSelection, copySel, paste, showInfo, setView, setSort,
})
props.win.appState = st
// ============ ============
const selection = computed(() => selectionSet.value)
const canBack = computed(() => hi.value > 0)
const canFwd = computed(() => hi.value < history.value.length - 1)
const rawItems = computed(() => {
void fsTick.value // fsTickfs:changed
try { return fs.list(path.value) } catch { return [] }
})
const filteredItems = computed(() => {
if (!search.value) return rawItems.value
const q = search.value.toLowerCase()
return rawItems.value.filter((it: any) => it.name.toLowerCase().includes(q))
})
const displayItems = computed(() => {
const items = [...filteredItems.value]
const by = sortBy.value
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 kindOf(a.path).localeCompare(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')
})
return items
})
const crumbSegments = computed(() => {
const rel = path.value === fs.HOME ? ['~'] : path.value.replace(fs.HOME, '~').split('/').filter(Boolean)
const segs: { label: string; target: string }[] = []
let acc = ''
rel.forEach((seg: string, i: number) => {
acc += (i === 0 && seg === '~') ? '' : '/' + seg
segs.push({
label: seg === '~' ? '客人用户' : seg,
target: seg === '~' ? fs.HOME : fs.join(fs.HOME, acc),
})
})
return segs
})
const favItems = [
{ name: '桌面', path: fs.HOME + '/Desktop', ico: '🖥' },
{ name: '文稿', path: fs.HOME + '/Documents', ico: '📄' },
{ name: '下载', path: fs.HOME + '/Downloads', ico: '⬇️' },
{ name: '图片', path: fs.HOME + '/Pictures', ico: '🖼' },
{ name: '音乐', path: fs.HOME + '/Music', ico: '🎵' },
{ name: '应用程序', path: fs.HOME + '/Applications', ico: '📦' },
]
const locItems = [
{ name: 'iCloud 云盘', path: '__icloud', ico: '☁️' },
{ name: '废纸篓', path: fs.TRASH, ico: '🗑' },
]
const renameBaseName = computed(() => {
if (!renaming.value) return ''
return fs.baseName(renaming.value)
})
// ============ ============
function iconFor(p: string) { return fs.iconFor(p) }
function kindOf(p: string) { return fs.kindOf(p) }
function fmtDate(ts: number) { return new Date(ts).toLocaleDateString('zh-CN') }
// ============ ============
function navigateTo(target: string) { navigate(target, true) }
function navigate(p: string, push = true) {
if (!fs.isDir(p)) return
p = fs.normalize(p)
if (push && history.value[hi.value] !== p) {
history.value = history.value.slice(0, hi.value + 1)
history.value.push(p)
hi.value = history.value.length - 1
}
path.value = p
selectionSet.value = new Set()
anchor.value = null
search.value = ''
renaming.value = null
}
function goBack() {
if (hi.value > 0) { hi.value--; navigate(history.value[hi.value], false) }
}
function goFwd() {
if (hi.value < history.value.length - 1) { hi.value++; navigate(history.value[hi.value], false) }
}
// ============ / ============
function setView(v: string) {
view.value = v as 'icon' | 'list'
if (S?.settings) { S.settings.finderView = v; S.save() }
}
function onSortChange() {
if (S?.settings) { S.settings.finderSort = sortBy.value; S.save() }
}
function setSort(s: string) {
sortBy.value = s as 'name' | 'kind' | 'date'; onSortChange()
}
// ============ ============
async function newFolder() {
const name = await ui.prompt('新建文件夹', '请输入文件夹名称:', '未命名文件夹')
if (!name) return
try { fs.mkdir(fs.join(path.value, name)) } catch (e: any) { ui.alert('无法创建', e.message, '/assets/icons/finder.png') }
}
function trashSelection() {
;[...selectionSet.value].forEach((p: string) => {
try { fs.trash(p) } catch (e: any) { ui.alert('无法移到废纸篓', e.message, '/assets/icons/finder.png') }
})
selectionSet.value = new Set()
}
function copySel(op: string) {
(window as any).__finderClipboard = { op, paths: [...selectionSet.value], from: path.value }
}
function paste() {
const cb = (window as any).__finderClipboard; if (!cb) return
for (const p of cb.paths) {
try { cb.op === 'copy' ? fs.copy(p, path.value) : fs.move(p, path.value) }
catch (e: any) { ui.alert('粘贴失败', e.message, '/assets/icons/finder.png') }
}
if (cb.op === 'cut') (window as any).__finderClipboard = null
}
function showInfo() {
const p = [...selectionSet.value][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: `种类:${kindOf(p)}\n大小${size}\n位置${fs.dirName(p).replace(fs.HOME, '~')}\n修改时间${new Date(n.mtime || 0).toLocaleString('zh-CN')}` })
}
// ============ ============
function startRename(p: string) {
renaming.value = p
nextTick(() => {
const input = document.querySelector('.fi-rename') as HTMLInputElement
if (input) {
const old = fs.baseName(p)
const dot = old.lastIndexOf('.')
input.setSelectionRange(0, dot > 0 ? dot : old.length)
input.focus()
}
})
}
function commitRename() {
if (!renaming.value) return
const input = document.querySelector('.fi-rename') as HTMLInputElement
const old = fs.baseName(renaming.value)
const v = input?.value?.trim()
if (v && v !== old) {
try { fs.rename(renaming.value, v) } catch (e: any) { ui.alert('无法重命名', e.message, '/assets/icons/finder.png') }
}
renaming.value = null
}
function onRenameKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); commitRename() }
if (e.key === 'Escape') { renaming.value = null }
}
// ============ ============
function onSideClick(item: { name: string; path: string }) {
if (item.path === '__icloud') {
ui.dialog({ icon: '/assets/icons/finder.png', title: 'iCloud 云盘', msg: 'iCloud 在离线环境下不可用。你的文件都保存在本机的虚拟磁盘中。', buttons: ['好'] })
return
}
navigate(item.path)
}
// ============ / ============
function onItemClick(item: any, e: MouseEvent) {
if (e.metaKey || e.ctrlKey) {
const s = new Set(selectionSet.value)
s.has(item.path) ? s.delete(item.path) : s.add(item.path)
selectionSet.value = s; anchor.value = item.path
} else if (e.shiftKey && anchor.value) {
const arr = displayItems.value.map((x: any) => x.path)
const a = arr.indexOf(anchor.value), b = arr.indexOf(item.path)
if (a >= 0 && b >= 0) selectionSet.value = new Set(arr.slice(Math.min(a, b), Math.max(a, b) + 1))
} else {
selectionSet.value = new Set([item.path]); anchor.value = item.path
}
}
function onItemDblclick(item: any) {
item.node.t === 'd' ? navigate(item.path) : Apps.openPath(item.path)
}
function onContentClick() {
if (selectionSet.value.size) { selectionSet.value = new Set(); anchor.value = null }
}
// ============ ============
function itemMenu(it: any): any[] {
const inTrash = path.value === fs.TRASH || path.value.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' ? navigate(it.path) : Apps.openPath(it.path) },
{ sep: true },
{ label: '显示简介', action: () => { selectionSet.value = new Set([it.path]); showInfo() } },
{ label: '重命名', action: () => startRename(it.path) },
{ label: '复制"' + it.name + '"', action: () => { try { fs.copy(it.path, path.value) } catch (e: any) { ui.alert('无法复制', e.message, '/assets/icons/finder.png') } } },
{ sep: true },
{ label: '移到废纸篓', action: () => { selectionSet.value = new Set([it.path]); trashSelection() } },
]
}
function onItemContextmenu(item: any, e: MouseEvent) {
if (!selectionSet.value.has(item.path)) { selectionSet.value = new Set([item.path]); anchor.value = item.path }
ui.contextMenu(itemMenu(item), e)
}
function onContentContextmenu(e: MouseEvent) {
if ((e.target as Element).closest('.fb-item')) return
const cb = (window as any).__finderClipboard
ui.contextMenu([
{ label: '新建文件夹', action: () => newFolder() },
{ sep: true },
{ label: '按图标显示', checked: view.value === 'icon', action: () => setView('icon') },
{ label: '按列表显示', checked: view.value === 'list', action: () => setView('list') },
{ sep: true },
{ label: '粘贴', disabled: !cb, action: () => paste() },
{ label: '显示简介', action: () => {
const n = fs.node(path.value)!
ui.dialog({ icon: '/assets/icons/folder.svg', title: fs.baseName(path.value) + ' 简介', buttons: ['好'], msg: `种类:文件夹\n项目数${Object.keys(n.c!).length}\n位置${path.value.replace(fs.HOME, '~')}` })
} },
], e)
}
// ============ ============
function onContentKeydown(e: KeyboardEvent) {
if (e.key === 'Backspace' && (e.metaKey || e.ctrlKey)) { trashSelection(); e.preventDefault() }
else if (e.key === 'Enter' && selectionSet.value.size === 1) { startRename([...selectionSet.value][0]); e.preventDefault() }
else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c') { copySel('copy') }
else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') { paste() }
else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); selectionSet.value = new Set(displayItems.value.map((i: any) => i.path)) }
else if (e.key === 'Delete' && selectionSet.value.size) { trashSelection() }
}
// ============ ============
function onItemDragstart(item: any, e: DragEvent) {
e.dataTransfer!.setData('text/x-fspath', item.path)
}
function onItemDrop(target: any, e: DragEvent) {
dragOverPath.value = null
const src = e.dataTransfer!.getData('text/x-fspath')
if (src && src !== target.path) {
try { fs.move(src, target.path) } catch (err: any) { ui.alert('无法移动', err.message, '/assets/icons/finder.png') }
}
}
// ============ FS ============
let fsUnsub: (() => void) | null = null
onMounted(() => {
fsUnsub = bus.on('fs:changed', () => {
if (document.body.contains(props.win.el)) {
fsTick.value++ // rawItems
}
})
wm.setTitle(props.win, fs.baseName(path.value) || '访达')
const p = argsPath()
if (p) navigate(p)
})
onUnmounted(() => { fsUnsub?.() })
function onIconError(e: Event) {
(e.target as HTMLImageElement).src = '/assets/icons/finder.png'
}
</script>

View File

@ -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<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)
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)

View File

@ -0,0 +1,578 @@
/**
* 17-finder-vue: Finder Vue
*
*
* /退
* /
* Ctrl+Shift+Cmd+A
*
* /
*
* Enter Cmd+C/V/ADelete
* 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)
})
})
})