Files
hermes-ui/packages/client/src/components/hermes/files/FileTree.vue
T
toor 836d0228d2 Phase 007: migrate every remaining view from naive-ui to Arco Design Vue
The project now has exactly one UI library. Every naive-ui component in
packages/client/src is replaced by its Arco equivalent across 24 views and
roughly 80 components, and naive-ui is dropped from package.json along with
its transitive vueuc / @css-render / vdirs / seemly dependencies.

The migration is rendering-layer only. No store logic, no API contract, and
no /chat-run payload was touched.

Three classes of leftover were invisible to `grep naive-ui` and are now
covered by tests/client/no-naive-ui.test.ts, which asserts all three:
  - dead .n-* class names in style blocks
  - dead --n-* custom properties
  - naive-ui in package.json

Notable behavioural traps handled along the way, recorded in
docs/arco-migration-rules.md:
  - a-input-password defaults to masked; passing default-visibility="false"
    would have exposed API keys in clear text
  - a-upload fires a real XHR unless auto-upload is false
  - nested Trigger components fight over the first element child, so the
    group-chat settings dropdown was flattened
  - Arco renders tooltip default-slot text inline, which was silently
    collapsing the chat and group-chat toolbars
2026-10-18 22:57:25 +08:00

125 lines
3.3 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import type { TreeNodeData } from '@arco-design/web-vue'
const { t } = useI18n()
const filesStore = useFilesStore()
const props = defineProps<{
profile?: string | null
workspaceKey?: string | null
}>()
const effectiveProfile = computed(() => props.profile === undefined ? filesStore.currentProfile : props.profile)
const treeData = ref<TreeNodeData[]>([])
const selectedKeys = ref<string[]>([])
const treeInstanceKey = ref(0)
let rootLoadSeq = 0
async function loadChildren(path: string): Promise<TreeNodeData[]> {
try {
const result = filesStore.currentWorkspaceSessionId
? await filesStore.listEntries(path)
: await filesStore.fetchDirectory(path, { profile: effectiveProfile.value })
return result.entries
.filter(e => e.isDir)
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => ({
key: e.path,
title: e.name,
isLeaf: false,
}))
} catch {
return []
}
}
async function handleLoad(node: TreeNodeData): Promise<void> {
const seq = rootLoadSeq
const children = await loadChildren(String(node.key ?? ''))
if (seq === rootLoadSeq) node.children = children
}
function handleSelect(keys: (string | number)[]) {
if (keys.length > 0) {
selectedKeys.value = keys.map(String)
filesStore.navigateTo(String(keys[0]), { profile: effectiveProfile.value })
}
}
function handleRootClick() {
selectedKeys.value = []
filesStore.navigateTo('', { profile: effectiveProfile.value })
}
watch([effectiveProfile, () => filesStore.currentWorkspaceSessionId, () => props.workspaceKey], async () => {
const seq = ++rootLoadSeq
selectedKeys.value = []
treeInstanceKey.value += 1
const nextTreeData = await loadChildren('')
if (seq === rootLoadSeq) treeData.value = nextTreeData
}, { immediate: true })
</script>
<template>
<div class="file-tree">
<div class="tree-header" @click="handleRootClick">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<span>{{ t('files.breadcrumbRoot') }}</span>
</div>
<a-tree
:key="treeInstanceKey"
:data="treeData"
:selected-keys="selectedKeys"
:load-more="handleLoad"
:default-expand-all="false"
action-on-node-click="expand"
block-node
show-line
@select="handleSelect"
>
<template #title="node">
<span class="tree-node-label" :title="node.title">{{ node.title }}</span>
</template>
</a-tree>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-tree {
padding: 8px;
}
.tree-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: $radius-sm;
font-size: 13px;
font-weight: 500;
color: $text-primary;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
}
}
:deep(.tree-node-label) {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>