Fix file browser absolute path copy (#860)

This commit is contained in:
Zhicheng Han
2026-05-20 04:36:49 +02:00
committed by GitHub
parent 7f6b691238
commit 5fc7dce9c8
8 changed files with 181 additions and 6 deletions
+3 -1
View File
@@ -3,6 +3,7 @@ import { request, getApiKey, getBaseUrlValue } from '../client'
export interface FileEntry {
name: string
path: string
absolutePath?: string
isDir: boolean
size: number
modTime: string
@@ -11,13 +12,14 @@ export interface FileEntry {
export interface FileStat {
name: string
path: string
absolutePath?: string
isDir: boolean
size: number
modTime: string
permissions?: string
}
export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string }> {
export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string; absolutePath?: string }> {
const params = new URLSearchParams()
if (path) params.set('path', path)
const query = params.toString()
@@ -6,6 +6,7 @@ import { useFilesStore, isTextFile, isImageFile, isMarkdownFile } from '@/stores
import { downloadFile } from '@/api/hermes/download'
import type { FileEntry } from '@/api/hermes/files'
import { copyToClipboard } from '@/utils/clipboard'
import { getClipboardPathForEntry } from '@/utils/file-path'
const { t } = useI18n()
const message = useMessage()
@@ -74,7 +75,7 @@ async function handleSelect(key: string) {
try { await downloadFile(entry.path, entry.name) } catch (err: any) { message.error(err.message) }
break
case 'copyPath': {
const ok = await copyToClipboard(entry.path)
const ok = await copyToClipboard(getClipboardPathForEntry(entry))
if (ok) {
message.success(t('files.pathCopied'))
} else {
+5
View File
@@ -0,0 +1,5 @@
import type { FileEntry } from '@/api/hermes/files'
export function getClipboardPathForEntry(entry: FileEntry): string {
return entry.absolutePath || entry.path
}
+6 -2
View File
@@ -6,6 +6,10 @@ import {
MAX_EDIT_SIZE,
} from '../../services/hermes/file-provider'
function withAbsolutePath<T extends { path: string }>(entry: T): T & { absolutePath: string } {
return { ...entry, absolutePath: resolveHermesPath(entry.path) }
}
export const fileRoutes = new Router()
function handleError(ctx: any, err: any) {
@@ -39,7 +43,7 @@ fileRoutes.get('/api/hermes/files/list', async (ctx) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.name.localeCompare(b.name)
})
ctx.body = { entries, path: relativePath }
ctx.body = { entries: entries.map(withAbsolutePath), path: relativePath, absolutePath: absPath }
} catch (err: any) {
handleError(ctx, err)
}
@@ -57,7 +61,7 @@ fileRoutes.get('/api/hermes/files/stat', async (ctx) => {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const info = await provider.stat(absPath)
ctx.body = info
ctx.body = withAbsolutePath(info)
} catch (err: any) {
handleError(ctx, err)
}
+20 -2
View File
@@ -1,8 +1,10 @@
import { WebSocketServer } from 'ws'
import type { Server as HttpServer } from 'http'
import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs'
import { dirname, join } from 'path'
import { dirname, join, isAbsolute, resolve as resolvePath } from 'path'
import { homedir } from 'os'
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
import { getTerminalConfig, type TerminalConfig } from '../../services/hermes/file-provider'
import { getToken } from '../../services/auth'
import { logger } from '../../services/logger'
@@ -66,6 +68,22 @@ function shellName(shell: string): string {
return shell.split('/').pop() || 'shell'
}
export function resolveTerminalCwd(
cfg: Pick<TerminalConfig, 'cwd'> = getTerminalConfig(),
profileDir = getActiveProfileDir(),
): string {
const configured = cfg.cwd?.trim()
const fallback = existsSync(profileDir) ? profileDir : homedir()
if (!configured) return fallback
const cwd = isAbsolute(configured) ? configured : resolvePath(profileDir, configured)
if (!existsSync(cwd)) {
logger.warn({ cwd }, 'Configured terminal cwd does not exist; falling back to Hermes profile directory')
return fallback
}
return cwd
}
// ─── Session types ──────────────────────────────────────────────
interface PtySession {
@@ -96,7 +114,7 @@ function createSession(shell: string): PtySession {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: homedir(),
cwd: resolveTerminalCwd(),
})
} catch (err: any) {
throw new Error(`Failed to spawn shell "${shell}": ${err.message}`)
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { getClipboardPathForEntry } from '@/utils/file-path'
const baseEntry = {
name: 'app.log',
path: 'logs/app.log',
isDir: false,
size: 12,
modTime: '2026-05-20T00:00:00.000Z',
}
describe('file path clipboard helpers', () => {
it('prefers absolute path metadata when available', () => {
expect(getClipboardPathForEntry({
...baseEntry,
absolutePath: '/home/agent/.hermes/logs/app.log',
})).toBe('/home/agent/.hermes/logs/app.log')
})
it('falls back to the relative operation path for older API responses', () => {
expect(getClipboardPathForEntry(baseEntry)).toBe('logs/app.log')
})
})
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const provider = {
listDir: vi.fn(),
stat: vi.fn(),
}
const createFileProviderMock = vi.fn(async () => provider)
const resolveHermesPathMock = vi.fn((relativePath: string) => {
const normalized = relativePath.replace(/^\/+/, '')
return normalized ? `/home/agent/.hermes/${normalized}` : '/home/agent/.hermes'
})
vi.mock('../../packages/server/src/services/hermes/file-provider', () => ({
createFileProvider: createFileProviderMock,
resolveHermesPath: resolveHermesPathMock,
isSensitivePath: vi.fn(() => false),
MAX_EDIT_SIZE: 10 * 1024 * 1024,
}))
describe('file routes path metadata', () => {
beforeEach(() => {
vi.resetModules()
createFileProviderMock.mockClear()
resolveHermesPathMock.mockClear()
provider.listDir.mockReset()
provider.stat.mockReset()
})
it('returns absolute paths for listed entries while preserving relative operation paths', async () => {
provider.listDir.mockResolvedValue([
{ name: 'app.log', path: 'logs/app.log', isDir: false, size: 12, modTime: '2026-05-20T00:00:00.000Z' },
])
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/list')
const ctx: any = { query: { path: 'logs' }, body: null }
await layer.stack[0](ctx)
expect(provider.listDir).toHaveBeenCalledWith('/home/agent/.hermes/logs')
expect(ctx.body).toEqual({
path: 'logs',
absolutePath: '/home/agent/.hermes/logs',
entries: [
{
name: 'app.log',
path: 'logs/app.log',
absolutePath: '/home/agent/.hermes/logs/app.log',
isDir: false,
size: 12,
modTime: '2026-05-20T00:00:00.000Z',
},
],
})
})
it('returns an absolute path in stat responses', async () => {
provider.stat.mockResolvedValue({
name: 'app.log',
path: 'logs/app.log',
isDir: false,
size: 12,
modTime: '2026-05-20T00:00:00.000Z',
})
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/stat')
const ctx: any = { query: { path: 'logs/app.log' }, body: null }
await layer.stack[0](ctx)
expect(ctx.body).toEqual({
name: 'app.log',
path: 'logs/app.log',
absolutePath: '/home/agent/.hermes/logs/app.log',
isDir: false,
size: 12,
modTime: '2026-05-20T00:00:00.000Z',
})
})
})
+41
View File
@@ -0,0 +1,41 @@
import { mkdtempSync, mkdirSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveTerminalCwd } from '../../packages/server/src/routes/hermes/terminal'
const tmpRoots: string[] = []
function makeTmpRoot() {
const root = mkdtempSync(join(tmpdir(), 'wui-terminal-cwd-'))
tmpRoots.push(root)
return root
}
afterEach(() => {
for (const root of tmpRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('terminal cwd resolution', () => {
it('defaults terminal sessions to the active Hermes profile directory', () => {
const profileDir = makeTmpRoot()
expect(resolveTerminalCwd({}, profileDir)).toBe(profileDir)
})
it('resolves relative configured cwd from the Hermes profile directory', () => {
const profileDir = makeTmpRoot()
mkdirSync(join(profileDir, 'workspace'))
expect(resolveTerminalCwd({ cwd: 'workspace' }, profileDir)).toBe(join(profileDir, 'workspace'))
})
it('uses absolute configured cwd when it exists', () => {
const profileDir = makeTmpRoot()
const cwd = makeTmpRoot()
expect(resolveTerminalCwd({ cwd }, profileDir)).toBe(cwd)
})
it('falls back to the profile directory when configured cwd is missing', () => {
const profileDir = makeTmpRoot()
expect(resolveTerminalCwd({ cwd: 'missing' }, profileDir)).toBe(profileDir)
})
})