mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 12:48:54 +08:00
Compare commits
1 Commits
bb/done-ch
...
bb/desktop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1c886bf9 |
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import { fsReadFileDataUrl, selectPaths } from '@/lib/desktop-fs'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
type ComposerAttachment,
|
||||
@@ -36,6 +37,27 @@ function isImagePath(filePath: string): boolean {
|
||||
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
||||
}
|
||||
|
||||
// Thumbnail source for an attached image. Locally-held paths (drag/paste saves,
|
||||
// local picks) read off the client; when that fails on a remote backend the
|
||||
// path lives on the gateway host, so fall back to the gateway data-url read.
|
||||
async function loadImagePreviewDataUrl(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const local = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (local) {
|
||||
return local
|
||||
}
|
||||
} catch {
|
||||
// Path isn't on the client (remote-picked image) — try the gateway below.
|
||||
}
|
||||
|
||||
try {
|
||||
return await fsReadFileDataUrl(filePath)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface DroppedFile {
|
||||
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
|
||||
file?: File
|
||||
@@ -228,7 +250,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
const paths = await selectPaths({
|
||||
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: kind === 'folder'
|
||||
@@ -291,19 +313,13 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
attachToMain(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
const previewUrl = await loadImagePreviewDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image preview failed')
|
||||
|
||||
return true
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const attachImageBlob = useCallback(
|
||||
@@ -338,7 +354,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
const paths = await selectPaths({
|
||||
title: 'Attach images',
|
||||
defaultPath: currentCwd || undefined,
|
||||
filters: [
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { fsReadFileDataUrl, fsReadFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -179,21 +180,19 @@ function looksBinaryBytes(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
return await fsReadFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Back-compat for a running Electron process whose preload hasn't been
|
||||
// restarted since readFileText was added. readFileDataUrl already existed.
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
const dataUrl = await fsReadFileDataUrl(filePath)
|
||||
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
|
||||
const base64 = metadata.includes(';base64')
|
||||
const mimeType = metadata.replace(/;base64$/, '') || undefined
|
||||
@@ -441,7 +440,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
|
||||
try {
|
||||
if (isImage) {
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
const dataUrl = await fsReadFileDataUrl(filePath)
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
|
||||
@@ -68,6 +68,7 @@ import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { RemotePathPicker } from './remote-path-picker'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
@@ -672,6 +673,7 @@ export function DesktopController() {
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<RemotePathPicker />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
234
apps/desktop/src/app/remote-path-picker.tsx
Normal file
234
apps/desktop/src/app/remote-path-picker.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { HermesReadDirEntry } from '@/global'
|
||||
import { fsReadDir } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $remotePathPicker, resolveRemotePathPicker } from '@/store/remote-path-picker'
|
||||
|
||||
function parentDir(path: string): string | null {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
|
||||
if (idx <= 0) {
|
||||
return idx === 0 ? '/' : null
|
||||
}
|
||||
|
||||
return trimmed.slice(0, idx)
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
return (
|
||||
path
|
||||
.replace(/[\\/]+$/, '')
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? path
|
||||
)
|
||||
}
|
||||
|
||||
// Browses the GATEWAY filesystem (via fs.list) so users on a remote backend can
|
||||
// pick files/folders that exist on the agent host rather than their own machine.
|
||||
// Mirrors the native selectPaths contract: resolves with absolute gateway paths
|
||||
// (or [] when cancelled).
|
||||
export function RemotePathPicker() {
|
||||
const request = useStore($remotePathPicker)
|
||||
|
||||
if (!request) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <RemotePathPickerDialog key={request.id} />
|
||||
}
|
||||
|
||||
function RemotePathPickerDialog() {
|
||||
const request = useStore($remotePathPicker)
|
||||
const options = request?.options ?? {}
|
||||
const directoriesMode = Boolean(options.directories)
|
||||
const allowMultiple = options.multiple !== false && !directoriesMode
|
||||
|
||||
const [dir, setDir] = useState<string>(options.defaultPath ?? '')
|
||||
const [entries, setEntries] = useState<HermesReadDirEntry[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const allowedExtensions = useMemo(() => {
|
||||
const exts = (options.filters ?? []).flatMap(filter => filter.extensions)
|
||||
|
||||
return exts.length > 0 ? new Set(exts.map(ext => ext.toLowerCase().replace(/^\./, ''))) : null
|
||||
}, [options.filters])
|
||||
|
||||
const load = useCallback(async (target: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await fsReadDir(target)
|
||||
|
||||
setDir(result.path ?? target)
|
||||
setEntries(result.entries ?? [])
|
||||
setError(result.error ?? null)
|
||||
setSelected(new Set())
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Loads the initial directory. `load` is stable and defaultPath is fixed for
|
||||
// this keyed instance, so this runs once; navigation calls `load` directly.
|
||||
useEffect(() => {
|
||||
void load(options.defaultPath ?? '')
|
||||
}, [load, options.defaultPath])
|
||||
|
||||
const visibleEntries = useMemo(() => {
|
||||
return entries.filter(entry => {
|
||||
if (entry.isDirectory) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (directoriesMode) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!allowedExtensions) {
|
||||
return true
|
||||
}
|
||||
|
||||
const ext = baseName(entry.name).split('.').pop()?.toLowerCase() ?? ''
|
||||
|
||||
return allowedExtensions.has(ext)
|
||||
})
|
||||
}, [allowedExtensions, directoriesMode, entries])
|
||||
|
||||
const cancel = useCallback(() => resolveRemotePathPicker([]), [])
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
if (directoriesMode) {
|
||||
resolveRemotePathPicker([dir])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (selected.size > 0) {
|
||||
resolveRemotePathPicker([...selected])
|
||||
}
|
||||
}, [dir, directoriesMode, selected])
|
||||
|
||||
const onEntryClick = useCallback(
|
||||
(entry: HermesReadDirEntry) => {
|
||||
if (entry.isDirectory) {
|
||||
void load(entry.path)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (directoriesMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowMultiple) {
|
||||
resolveRemotePathPicker([entry.path])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
|
||||
if (next.has(entry.path)) {
|
||||
next.delete(entry.path)
|
||||
} else {
|
||||
next.add(entry.path)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
},
|
||||
[allowMultiple, directoriesMode, load]
|
||||
)
|
||||
|
||||
const parent = parentDir(dir)
|
||||
const title = options.title || (directoriesMode ? 'Select a folder' : 'Select files')
|
||||
const confirmLabel = directoriesMode ? 'Use this folder' : `Attach${selected.size > 1 ? ` (${selected.size})` : ''}`
|
||||
const confirmDisabled = directoriesMode ? !dir : selected.size === 0
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && cancel()} open>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-(--ui-text-tertiary)">
|
||||
<Button
|
||||
aria-label="Up one folder"
|
||||
disabled={!parent || loading}
|
||||
onClick={() => parent && void load(parent)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="arrow-up" size="0.9rem" />
|
||||
</Button>
|
||||
<span className="truncate font-mono" title={dir}>
|
||||
{dir || '…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-72 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-background/40">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-destructive">
|
||||
Could not read this folder ({error}).
|
||||
</div>
|
||||
) : visibleEntries.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)">
|
||||
{directoriesMode ? 'No subfolders here.' : 'No matching files here.'}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{visibleEntries.map(entry => {
|
||||
const isSelected = selected.has(entry.path)
|
||||
|
||||
return (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-(--chrome-action-hover)',
|
||||
isSelected && 'bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={() => onEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon
|
||||
className={entry.isDirectory ? 'text-(--ui-accent)' : 'text-(--ui-text-tertiary)'}
|
||||
name={entry.isDirectory ? 'folder' : 'file'}
|
||||
size="0.95rem"
|
||||
/>
|
||||
<span className="flex-1 truncate">{entry.name}</span>
|
||||
{!entry.isDirectory && isSelected && <Codicon name="check" size="0.9rem" />}
|
||||
{entry.isDirectory && <Codicon name="chevron-right" size="0.85rem" />}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={confirmDisabled} onClick={confirm} type="button">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
import { fsGitRoot, fsReadDir, fsReadFileDataUrl } from '@/lib/desktop-fs'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
|
||||
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = clean(start)
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
cached = fsGitRoot(key)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
const listing = await fsReadDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
const text = decodeDataUrl(await fsReadFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
@@ -138,11 +131,7 @@ async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, di
|
||||
}
|
||||
|
||||
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
|
||||
if (!window.hermesDesktop) {
|
||||
return { entries: [], error: 'no-bridge' }
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
const result = await fsReadDir(dirPath)
|
||||
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { selectPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -68,7 +69,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
const selected = await selectPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
|
||||
@@ -177,6 +177,42 @@ export function usePromptActions({
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
// Remote gateways (e.g. a VPS over tailscale) cannot see the client's local
|
||||
// filesystem, so a path-based `image.attach` fails with "image not found".
|
||||
// Fall back to uploading the bytes the Electron client already holds.
|
||||
const uploadImageAttachmentBytes = useCallback(
|
||||
async (sessionId: string, attachment: ComposerAttachment): Promise<ImageAttachResponse | null> => {
|
||||
const path = attachment.path
|
||||
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
let data = attachment.previewUrl
|
||||
|
||||
if (!data && window.hermesDesktop?.readFileDataUrl) {
|
||||
try {
|
||||
data = await window.hermesDesktop.readFileDataUrl(path)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
|
||||
session_id: sessionId,
|
||||
filename: pathLabel(path),
|
||||
data
|
||||
})
|
||||
|
||||
return result.attached ? result : null
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const syncImageAttachmentsForSubmit = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
@@ -191,14 +227,28 @@ export function usePromptActions({
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
let result: ImageAttachResponse | null = null
|
||||
|
||||
if (!result.attached) {
|
||||
try {
|
||||
const pathResult = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
|
||||
if (pathResult.attached) {
|
||||
result = pathResult
|
||||
}
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
result = await uploadImageAttachmentBytes(sessionId, attachment)
|
||||
}
|
||||
|
||||
if (!result?.attached) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
throw new Error(result.message || `Could not attach ${label}`)
|
||||
throw new Error(result?.message || `Could not attach ${label}`)
|
||||
}
|
||||
|
||||
const attachedPath = result.path || attachment.path
|
||||
@@ -214,7 +264,7 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
[requestGateway, uploadImageAttachmentBytes]
|
||||
)
|
||||
|
||||
const submitPromptText = useCallback(
|
||||
@@ -537,6 +587,7 @@ export function usePromptActions({
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
|
||||
3
apps/desktop/src/global.d.ts
vendored
3
apps/desktop/src/global.d.ts
vendored
@@ -372,6 +372,9 @@ export interface HermesReadDirEntry {
|
||||
export interface HermesReadDirResult {
|
||||
entries: HermesReadDirEntry[]
|
||||
error?: string
|
||||
// Absolute directory the entries were read from. Set by the gateway `fs.list`
|
||||
// RPC (remote backends); the local Electron readDir omits it.
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewFileChanged {
|
||||
|
||||
91
apps/desktop/src/lib/desktop-fs.test.ts
Normal file
91
apps/desktop/src/lib/desktop-fs.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { resolveRemotePathPicker } from '@/store/remote-path-picker'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { fsGitRoot, fsReadDir, fsReadFileDataUrl, isRemoteBackend, selectPaths } from './desktop-fs'
|
||||
|
||||
const request = vi.fn()
|
||||
const readDir = vi.fn()
|
||||
const readFileDataUrl = vi.fn()
|
||||
const gitRoot = vi.fn()
|
||||
const desktopSelectPaths = vi.fn()
|
||||
|
||||
function setRemote(remote: boolean) {
|
||||
$connection.set(remote ? ({ mode: 'remote' } as never) : null)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
readDir.mockReset()
|
||||
readFileDataUrl.mockReset()
|
||||
gitRoot.mockReset()
|
||||
desktopSelectPaths.mockReset()
|
||||
$gateway.set({ request } as unknown as HermesGateway)
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
readDir,
|
||||
readFileDataUrl,
|
||||
gitRoot,
|
||||
selectPaths: desktopSelectPaths
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$connection.set(null)
|
||||
$gateway.set(null)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
describe('desktop-fs facade', () => {
|
||||
it('routes reads to local IPC when not remote', async () => {
|
||||
setRemote(false)
|
||||
readDir.mockResolvedValue({ entries: [] })
|
||||
|
||||
await fsReadDir('/p')
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/p')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(isRemoteBackend()).toBe(false)
|
||||
})
|
||||
|
||||
it('routes directory listing to fs.list when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ entries: [], path: '/srv' })
|
||||
|
||||
const result = await fsReadDir('/srv')
|
||||
|
||||
expect(request).toHaveBeenCalledWith('fs.list', { path: '/srv' })
|
||||
expect(result.path).toBe('/srv')
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unwraps the data url from fs.read_data_url when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ dataUrl: 'data:image/png;base64,AAAA' })
|
||||
|
||||
expect(await fsReadFileDataUrl('/srv/x.png')).toBe('data:image/png;base64,AAAA')
|
||||
expect(request).toHaveBeenCalledWith('fs.read_data_url', { path: '/srv/x.png' })
|
||||
})
|
||||
|
||||
it('returns gateway git root when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ root: '/srv/repo' })
|
||||
|
||||
expect(await fsGitRoot('/srv/repo/a')).toBe('/srv/repo')
|
||||
})
|
||||
|
||||
it('uses the native picker locally and the remote picker when remote', async () => {
|
||||
setRemote(false)
|
||||
desktopSelectPaths.mockResolvedValue(['/local/a.png'])
|
||||
expect(await selectPaths({ title: 'pick' })).toEqual(['/local/a.png'])
|
||||
|
||||
setRemote(true)
|
||||
const pending = selectPaths({ title: 'pick' })
|
||||
resolveRemotePathPicker(['/srv/a.png'])
|
||||
expect(await pending).toEqual(['/srv/a.png'])
|
||||
// Remote selection never touches the native dialog.
|
||||
expect(desktopSelectPaths).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
64
apps/desktop/src/lib/desktop-fs.ts
Normal file
64
apps/desktop/src/lib/desktop-fs.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { openRemotePathPicker } from '@/store/remote-path-picker'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
// On a remote gateway (e.g. a VPS over tailscale) the agent's filesystem lives
|
||||
// on the server, but the Electron IPC helpers only see the client machine. This
|
||||
// facade routes reads + path selection through gateway `fs.*` RPCs when remote,
|
||||
// and falls back to local Electron IPC against a locally-spawned backend.
|
||||
export const isRemoteBackend = (): boolean => $connection.get()?.mode === 'remote'
|
||||
|
||||
function gw<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<T>(method, params)
|
||||
}
|
||||
|
||||
const unavailable = (): never => {
|
||||
throw new Error('File reading is unavailable')
|
||||
}
|
||||
|
||||
export function fsReadDir(path: string): Promise<HermesReadDirResult> {
|
||||
if (isRemoteBackend()) {
|
||||
return gw('fs.list', { path })
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readDir?.(path) ?? Promise.resolve({ entries: [], error: 'no-bridge' })
|
||||
}
|
||||
|
||||
export function fsReadFileText(path: string): Promise<HermesReadFileTextResult> {
|
||||
if (isRemoteBackend()) {
|
||||
return gw('fs.read_text', { path })
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readFileText?.(path) ?? unavailable()
|
||||
}
|
||||
|
||||
export async function fsReadFileDataUrl(path: string): Promise<string> {
|
||||
if (isRemoteBackend()) {
|
||||
return (await gw<{ dataUrl?: string }>('fs.read_data_url', { path })).dataUrl ?? unavailable()
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readFileDataUrl?.(path) ?? unavailable()
|
||||
}
|
||||
|
||||
export async function fsGitRoot(path: string): Promise<string | null> {
|
||||
if (isRemoteBackend()) {
|
||||
return (await gw<{ root?: string | null }>('fs.git_root', { path })).root ?? null
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.gitRoot?.(path) ?? null
|
||||
}
|
||||
|
||||
export async function selectPaths(options: HermesSelectPathsOptions = {}): Promise<string[]> {
|
||||
if (isRemoteBackend()) {
|
||||
return openRemotePathPicker(options)
|
||||
}
|
||||
|
||||
return (await window.hermesDesktop?.selectPaths?.(options)) ?? []
|
||||
}
|
||||
41
apps/desktop/src/store/remote-path-picker.ts
Normal file
41
apps/desktop/src/store/remote-path-picker.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { HermesSelectPathsOptions } from '@/global'
|
||||
|
||||
export interface RemotePathPickerRequest {
|
||||
id: number
|
||||
options: HermesSelectPathsOptions
|
||||
resolve: (paths: string[]) => void
|
||||
}
|
||||
|
||||
// Holds the currently open remote path-picker request, if any. The picker
|
||||
// modal subscribes and resolves the promise when the user confirms or cancels.
|
||||
// Used only when the desktop is connected to a remote gateway, where the native
|
||||
// OS dialog (which browses the client machine) is the wrong filesystem.
|
||||
export const $remotePathPicker = atom<RemotePathPickerRequest | null>(null)
|
||||
|
||||
let nextRequestId = 0
|
||||
|
||||
export function openRemotePathPicker(options: HermesSelectPathsOptions = {}): Promise<string[]> {
|
||||
// Only one picker at a time; cancel any prior request.
|
||||
const previous = $remotePathPicker.get()
|
||||
|
||||
if (previous) {
|
||||
previous.resolve([])
|
||||
}
|
||||
|
||||
return new Promise<string[]>(resolve => {
|
||||
$remotePathPicker.set({ id: (nextRequestId += 1), options, resolve })
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRemotePathPicker(paths: string[]): void {
|
||||
const request = $remotePathPicker.get()
|
||||
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
|
||||
$remotePathPicker.set(null)
|
||||
request.resolve(paths)
|
||||
}
|
||||
211
tests/tui_gateway/test_fs_methods.py
Normal file
211
tests/tui_gateway/test_fs_methods.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Tests for the remote-browsing filesystem RPCs (fs.*) and image.attach_bytes.
|
||||
|
||||
These power the desktop app when it talks to a gateway on a remote host (e.g. a
|
||||
VPS over tailscale): the Files sidebar and path pickers browse the gateway's
|
||||
filesystem via fs.list / fs.read_text / fs.read_data_url / fs.git_root, and
|
||||
locally-held images are pushed to the gateway via image.attach_bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# 1x1 transparent PNG.
|
||||
_PNG_1x1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
yield home
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(hermes_home):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"hermes_cli.env_loader": MagicMock(),
|
||||
"hermes_cli.banner": MagicMock(),
|
||||
},
|
||||
):
|
||||
mod = importlib.import_module("tui_gateway.server")
|
||||
yield mod
|
||||
mod._sessions.clear()
|
||||
mod._pending.clear()
|
||||
mod._answers.clear()
|
||||
mod._methods.clear()
|
||||
importlib.reload(mod)
|
||||
|
||||
|
||||
def _call(server, method: str, params: dict) -> dict:
|
||||
return server.handle_request({"id": "1", "method": method, "params": params})
|
||||
|
||||
|
||||
# ── fs.list ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fs_list_returns_sorted_entries(server, tmp_path):
|
||||
work = tmp_path / "proj"
|
||||
work.mkdir()
|
||||
(work / "b_file.txt").write_text("x")
|
||||
(work / "a_dir").mkdir()
|
||||
(work / "node_modules").mkdir() # hidden by filter
|
||||
|
||||
resp = _call(server, "fs.list", {"path": str(work)})
|
||||
result = resp["result"]
|
||||
|
||||
assert result["path"] == str(work.resolve())
|
||||
names = [e["name"] for e in result["entries"]]
|
||||
# Directories first, then files; node_modules filtered out.
|
||||
assert names == ["a_dir", "b_file.txt"]
|
||||
assert result["entries"][0]["isDirectory"] is True
|
||||
assert result["entries"][1]["isDirectory"] is False
|
||||
|
||||
|
||||
def test_fs_list_missing_dir_reports_error(server, tmp_path):
|
||||
resp = _call(server, "fs.list", {"path": str(tmp_path / "nope")})
|
||||
|
||||
assert resp["result"]["entries"] == []
|
||||
assert resp["result"]["error"] == "ENOENT"
|
||||
|
||||
|
||||
# ── fs.read_text ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fs_read_text_reads_file(server, tmp_path):
|
||||
target = tmp_path / "hello.py"
|
||||
target.write_text("print('hi')\n")
|
||||
|
||||
resp = _call(server, "fs.read_text", {"path": str(target)})
|
||||
result = resp["result"]
|
||||
|
||||
assert result["text"] == "print('hi')\n"
|
||||
assert result["language"] == "python"
|
||||
assert result["binary"] is False
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
def test_fs_read_text_missing_file_errors(server, tmp_path):
|
||||
resp = _call(server, "fs.read_text", {"path": str(tmp_path / "gone.txt")})
|
||||
|
||||
assert resp["error"]["code"] == 4016
|
||||
|
||||
|
||||
def test_fs_read_text_flags_binary(server, tmp_path):
|
||||
target = tmp_path / "blob.bin"
|
||||
target.write_bytes(b"\x00\x01\x02\x03")
|
||||
|
||||
result = _call(server, "fs.read_text", {"path": str(target)})["result"]
|
||||
|
||||
assert result["binary"] is True
|
||||
|
||||
|
||||
# ── fs.read_data_url ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fs_read_data_url_encodes_file(server, tmp_path):
|
||||
target = tmp_path / "pixel.png"
|
||||
target.write_bytes(_PNG_1x1)
|
||||
|
||||
result = _call(server, "fs.read_data_url", {"path": str(target)})["result"]
|
||||
|
||||
assert result["dataUrl"].startswith("data:image/png;base64,")
|
||||
encoded = result["dataUrl"].split(",", 1)[1]
|
||||
assert base64.b64decode(encoded) == _PNG_1x1
|
||||
|
||||
|
||||
def test_fs_read_data_url_rejects_oversized(server, tmp_path):
|
||||
target = tmp_path / "big.bin"
|
||||
target.write_bytes(b"x")
|
||||
# Patch the cap below the file size to exercise the guard deterministically.
|
||||
with patch.object(server, "_FS_DATA_URL_MAX_BYTES", 0):
|
||||
resp = _call(server, "fs.read_data_url", {"path": str(target)})
|
||||
|
||||
assert resp["error"]["code"] == 4017
|
||||
|
||||
|
||||
# ── fs.git_root ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fs_git_root_walks_up(server, tmp_path):
|
||||
(tmp_path / ".git").mkdir()
|
||||
nested = tmp_path / "a" / "b"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
result = _call(server, "fs.git_root", {"path": str(nested)})["result"]
|
||||
|
||||
assert result["root"] == str(tmp_path.resolve())
|
||||
|
||||
|
||||
def test_fs_git_root_none_when_absent(server, tmp_path):
|
||||
result = _call(server, "fs.git_root", {"path": str(tmp_path)})["result"]
|
||||
|
||||
assert result["root"] is None
|
||||
|
||||
|
||||
# ── image.attach_bytes ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def image_session(server):
|
||||
"""A session that bypasses agent build so _sess() resolves cleanly."""
|
||||
sid = "sid-img"
|
||||
# Non-empty so _sess_nowait()'s truthiness check treats it as present.
|
||||
server._sessions[sid] = {"image_counter": 0}
|
||||
with patch.object(server, "_start_agent_build", lambda *a, **k: None), patch.object(
|
||||
server, "_wait_agent", lambda s, rid: None
|
||||
):
|
||||
yield sid
|
||||
|
||||
|
||||
def test_image_attach_bytes_writes_and_attaches(server, hermes_home, image_session):
|
||||
data_url = "data:image/png;base64," + base64.b64encode(_PNG_1x1).decode()
|
||||
|
||||
resp = _call(
|
||||
server,
|
||||
"image.attach_bytes",
|
||||
{"session_id": image_session, "data": data_url, "filename": "shot.png"},
|
||||
)
|
||||
result = resp["result"]
|
||||
|
||||
assert result["attached"] is True
|
||||
saved = Path(result["path"])
|
||||
assert saved.exists()
|
||||
assert saved.read_bytes() == _PNG_1x1
|
||||
assert saved.suffix == ".png"
|
||||
# Lands under the gateway's HERMES_HOME, not the client.
|
||||
assert str(saved).startswith(str(hermes_home / "images"))
|
||||
assert server._sessions[image_session]["attached_images"] == [str(saved)]
|
||||
|
||||
|
||||
def test_image_attach_bytes_infers_extension_from_mime(server, image_session):
|
||||
data_url = "data:image/webp;base64," + base64.b64encode(_PNG_1x1).decode()
|
||||
|
||||
result = _call(
|
||||
server,
|
||||
"image.attach_bytes",
|
||||
{"session_id": image_session, "data": data_url},
|
||||
)["result"]
|
||||
|
||||
assert Path(result["path"]).suffix == ".webp"
|
||||
|
||||
|
||||
def test_image_attach_bytes_rejects_empty(server, image_session):
|
||||
resp = _call(
|
||||
server,
|
||||
"image.attach_bytes",
|
||||
{"session_id": image_session, "data": ""},
|
||||
)
|
||||
|
||||
assert resp["error"]["code"] == 4015
|
||||
@@ -4980,6 +4980,249 @@ def _(rid, params: dict) -> dict:
|
||||
return _err(rid, 5027, str(e))
|
||||
|
||||
|
||||
_DATA_URL_MIME_EXT = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp",
|
||||
"image/tiff": ".tiff",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/x-icon": ".ico",
|
||||
"image/vnd.microsoft.icon": ".ico",
|
||||
}
|
||||
|
||||
|
||||
@method("image.attach_bytes")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Attach an image uploaded as bytes (base64 / data URL).
|
||||
|
||||
Unlike ``image.attach`` (which resolves a path on the gateway host), this
|
||||
writes the client-supplied bytes into ``$HERMES_HOME/images`` on the
|
||||
gateway. Used by the desktop app when the gateway is remote (e.g. a VPS)
|
||||
and the UI file picker yields a path that only exists on the client.
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
raw = str(params.get("data", "") or "").strip()
|
||||
if not raw:
|
||||
return _err(rid, 4015, "data required")
|
||||
|
||||
from cli import _IMAGE_EXTENSIONS
|
||||
|
||||
mime = ""
|
||||
payload = raw
|
||||
m = re.match(r"^data:([^;,]*)(;base64)?,(.*)$", raw, re.DOTALL)
|
||||
if m:
|
||||
mime = (m.group(1) or "").strip().lower()
|
||||
payload = m.group(3) or ""
|
||||
try:
|
||||
blob = base64.b64decode(payload, validate=False)
|
||||
except Exception:
|
||||
return _err(rid, 4016, "invalid image data")
|
||||
if not blob:
|
||||
return _err(rid, 4016, "empty image data")
|
||||
|
||||
ext = Path(str(params.get("filename", "") or "")).suffix.lower()
|
||||
if ext not in _IMAGE_EXTENSIONS:
|
||||
ext = _DATA_URL_MIME_EXT.get(mime, "")
|
||||
if ext not in _IMAGE_EXTENSIONS:
|
||||
ext = ".png"
|
||||
|
||||
session["image_counter"] = session.get("image_counter", 0) + 1
|
||||
img_dir = _hermes_home / "images"
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
img_path = (
|
||||
img_dir
|
||||
/ f"upload_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}{ext}"
|
||||
)
|
||||
try:
|
||||
img_path.write_bytes(blob)
|
||||
except Exception as e:
|
||||
session["image_counter"] = max(0, session["image_counter"] - 1)
|
||||
return _err(rid, 5027, str(e))
|
||||
|
||||
session.setdefault("attached_images", []).append(str(img_path))
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"attached": True,
|
||||
"path": str(img_path),
|
||||
"count": len(session["attached_images"]),
|
||||
"text": f"[User attached image: {img_path.name}]",
|
||||
**_image_meta(img_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Filesystem browsing RPCs (fs.*) run on the GATEWAY host. The desktop app uses
|
||||
# them when connected to a remote gateway (e.g. a VPS over tailscale) so the
|
||||
# Files sidebar and path pickers browse the agent's filesystem rather than the
|
||||
# client's. Shapes mirror the Electron `hermes:fs:*` IPC handlers so the same
|
||||
# renderer components consume either source unchanged.
|
||||
_FS_READDIR_HIDDEN = frozenset({
|
||||
".git", ".hg", ".svn", ".cache", ".next", ".turbo", ".venv",
|
||||
"__pycache__", "build", "dist", "node_modules", "target", "venv",
|
||||
})
|
||||
_FS_TEXT_READ_MAX_BYTES = 512 * 1024
|
||||
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
|
||||
_FS_LANGUAGE_BY_EXT = {
|
||||
".c": "c", ".conf": "ini", ".cpp": "cpp", ".css": "css", ".csv": "csv",
|
||||
".go": "go", ".graphql": "graphql", ".h": "c", ".hpp": "cpp",
|
||||
".html": "html", ".ini": "ini", ".java": "java", ".js": "javascript",
|
||||
".json": "json", ".jsx": "jsx", ".kt": "kotlin", ".lua": "lua",
|
||||
".md": "markdown", ".php": "php", ".py": "python", ".rb": "ruby",
|
||||
".rs": "rust", ".sh": "shell", ".sql": "sql", ".swift": "swift",
|
||||
".toml": "toml", ".ts": "typescript", ".tsx": "tsx", ".xml": "xml",
|
||||
".yaml": "yaml", ".yml": "yaml",
|
||||
}
|
||||
|
||||
|
||||
def _fs_resolve(params: dict, *, default_to_cwd: bool = False) -> Path:
|
||||
"""Resolve an fs.* path param on the gateway host.
|
||||
|
||||
Relative paths resolve against the session/terminal cwd (same base as path
|
||||
completions). When ``default_to_cwd`` and no path is given, returns the cwd.
|
||||
"""
|
||||
raw = str(params.get("path", "") or "").strip()
|
||||
base = _completion_cwd(params)
|
||||
if not raw:
|
||||
return Path(base)
|
||||
expanded = os.path.expanduser(os.path.expandvars(raw))
|
||||
p = Path(expanded)
|
||||
if not p.is_absolute():
|
||||
p = Path(base) / p
|
||||
return p
|
||||
|
||||
|
||||
def _fs_stat_file(rid, params: dict):
|
||||
"""Resolve + stat a regular file. Returns (resolved, stat, None) or (None, None, err)."""
|
||||
target = _fs_resolve(params)
|
||||
try:
|
||||
resolved = target.resolve()
|
||||
st = resolved.stat()
|
||||
except FileNotFoundError:
|
||||
return None, None, _err(rid, 4016, f"file not found: {target}")
|
||||
except OSError as e:
|
||||
return None, None, _err(rid, 5027, str(e))
|
||||
if not resolved.is_file():
|
||||
return None, None, _err(rid, 4016, f"not a file: {resolved}")
|
||||
return resolved, st, None
|
||||
|
||||
|
||||
@method("fs.list")
|
||||
def _(rid, params: dict) -> dict:
|
||||
import errno as _errno
|
||||
|
||||
target = _fs_resolve(params, default_to_cwd=True)
|
||||
try:
|
||||
resolved = target.resolve()
|
||||
except Exception:
|
||||
resolved = target
|
||||
try:
|
||||
entries = []
|
||||
with os.scandir(resolved) as it:
|
||||
for entry in it:
|
||||
if entry.name in _FS_READDIR_HIDDEN:
|
||||
continue
|
||||
try:
|
||||
is_dir = entry.is_dir()
|
||||
except OSError:
|
||||
is_dir = False
|
||||
entries.append(
|
||||
{
|
||||
"name": entry.name,
|
||||
"path": str(Path(resolved) / entry.name),
|
||||
"isDirectory": is_dir,
|
||||
}
|
||||
)
|
||||
entries.sort(key=lambda e: (0 if e["isDirectory"] else 1, e["name"].lower()))
|
||||
return _ok(rid, {"path": str(resolved), "entries": entries})
|
||||
except OSError as e:
|
||||
code = _errno.errorcode.get(getattr(e, "errno", None), "read-error")
|
||||
return _ok(rid, {"path": str(resolved), "entries": [], "error": code})
|
||||
|
||||
|
||||
@method("fs.read_text")
|
||||
def _(rid, params: dict) -> dict:
|
||||
import mimetypes
|
||||
|
||||
resolved, st, err = _fs_stat_file(rid, params)
|
||||
if err:
|
||||
return err
|
||||
|
||||
to_read = min(st.st_size, _FS_TEXT_READ_MAX_BYTES)
|
||||
try:
|
||||
with open(resolved, "rb") as f:
|
||||
chunk = f.read(to_read)
|
||||
except OSError as e:
|
||||
return _err(rid, 5027, str(e))
|
||||
|
||||
binary = b"\x00" in chunk[:4096]
|
||||
text = "" if binary else chunk.decode("utf-8", errors="replace")
|
||||
mime = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"binary": binary,
|
||||
"byteSize": st.st_size,
|
||||
"language": _FS_LANGUAGE_BY_EXT.get(resolved.suffix.lower(), "text"),
|
||||
"mimeType": mime,
|
||||
"path": str(resolved),
|
||||
"text": text,
|
||||
"truncated": st.st_size > _FS_TEXT_READ_MAX_BYTES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("fs.read_data_url")
|
||||
def _(rid, params: dict) -> dict:
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
resolved, st, err = _fs_stat_file(rid, params)
|
||||
if err:
|
||||
return err
|
||||
if st.st_size > _FS_DATA_URL_MAX_BYTES:
|
||||
return _err(rid, 4017, f"file too large: {resolved.name}")
|
||||
|
||||
try:
|
||||
data = resolved.read_bytes()
|
||||
except OSError as e:
|
||||
return _err(rid, 5027, str(e))
|
||||
mime = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return _ok(rid, {"path": str(resolved), "dataUrl": f"data:{mime};base64,{b64}"})
|
||||
|
||||
|
||||
@method("fs.git_root")
|
||||
def _(rid, params: dict) -> dict:
|
||||
target = _fs_resolve(params, default_to_cwd=True)
|
||||
try:
|
||||
start = target.resolve()
|
||||
if start.is_file():
|
||||
start = start.parent
|
||||
except Exception:
|
||||
start = target
|
||||
current = start
|
||||
for _ in range(50):
|
||||
try:
|
||||
if (current / ".git").exists():
|
||||
return _ok(rid, {"root": str(current)})
|
||||
except OSError:
|
||||
return _ok(rid, {"root": None})
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return _ok(rid, {"root": None})
|
||||
|
||||
|
||||
@method("image.detach")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess(params, rid)
|
||||
|
||||
Reference in New Issue
Block a user