feat: 重构finder
This commit is contained in:
parent
57f052b6cb
commit
6c2950730e
@ -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 // 依赖 fsTick,fs: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>
|
||||
|
||||
@ -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)
|
||||
|
||||
578
tests/cases/17-finder-vue.test.ts
Normal file
578
tests/cases/17-finder-vue.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user