Compare commits

...

5 Commits

Author SHA1 Message Date
yoniebans
bdcc2dd7f3 fix(desktop): scope remote workspace defaults 2026-06-10 12:46:34 +02:00
yoniebans
4e40f7bb4d fix(desktop): tighten remote filesystem wiring 2026-06-10 10:37:58 +02:00
yoniebans
969aeb279c feat(desktop): wire remote filesystem browsing 2026-06-10 10:37:58 +02:00
yoniebans
2ca1d7da10 feat(desktop): add filesystem routing facade 2026-06-10 10:36:32 +02:00
yoniebans
eb473710e1 feat(desktop): add read-only remote filesystem API 2026-06-10 10:36:32 +02:00
21 changed files with 1048 additions and 46 deletions

View File

@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -180,15 +181,13 @@ 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 readDesktopFileText(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
}
}
@@ -448,7 +447,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isImage) {
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
if (active) {
setState({ dataUrl, loading: false })

View File

@@ -1,11 +1,50 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})
afterEach(() => {
cleanup()
$connection.set(null)
vi.unstubAllGlobals()
})
it('does not watch backend-only remote filesystem previews locally', () => {
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
const onPreviewFileChanged = vi.fn(() => vi.fn())
$connection.set({ mode: 'remote' } as never)
vi.stubGlobal('window', {
...window,
hermesDesktop: {
onPreviewFileChanged,
watchPreviewFile
}
})
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
expect(watchPreviewFile).not.toHaveBeenCalled()
expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {

View File

@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -406,6 +407,7 @@ export function PreviewPane({
useEffect(() => {
if (
target.kind !== 'file' ||
isDesktopFsRemoteMode() ||
!window.hermesDesktop?.watchPreviewFile ||
!window.hermesDesktop?.onPreviewFileChanged
) {

View File

@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -25,12 +26,16 @@ import {
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -353,6 +358,11 @@ export function useGatewayBoot({
progress: 97
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {

View File

@@ -1,5 +1,6 @@
import ignore from 'ignore'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
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)
const key = `${desktopFsCacheKey()}:${clean(start)}`
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
cached = desktopGitRoot(start)
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 readDesktopDir(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 readDesktopFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
}
async function gitignoreFor(dir: string) {
const key = clean(dir)
const key = `${desktopFsCacheKey()}:${clean(dir)}`
let cached = gitignoreCache.get(key)
if (!cached) {
cached = readGitignore(key)
cached = readGitignore(clean(dir))
gitignoreCache.set(key, cached)
}
@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
const result = await readDesktopDir(dirPath)
const entries = result?.entries ?? []
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {
return
}
const key = clean(rootPath)
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
gitRootCache.delete(key)
gitignoreCache.delete(key)
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
}
function parentDir(path: string) {
const value = clean(path)
if (value === '/') {
return '/'
}
const parent = value.slice(0, value.lastIndexOf('/'))
return parent || '/'
}
function pathName(path: string) {
return path.split('/').filter(Boolean).pop() || path
}
interface PendingSelection {
defaultPath: string
resolve: (paths: string[]) => void
title: string
}
export function RemoteFolderPicker() {
const { t } = useI18n()
const r = t.rightSidebar
const [pending, setPending] = useState<PendingSelection | null>(null)
const [currentPath, setCurrentPath] = useState('/')
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
setDesktopFsRemotePicker({
selectPaths: options =>
new Promise(resolve => {
const defaultPath = clean(options?.defaultPath || '/')
setCurrentPath(defaultPath)
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
})
})
return () => setDesktopFsRemotePicker(null)
}, [r.remotePickerTitle])
useEffect(() => {
if (!pending) {
return
}
let active = true
setLoading(true)
setError(null)
void readDesktopDir(currentPath)
.then(result => {
if (!active) {
return
}
if (result.error) {
setError(result.error)
setEntries([])
return
}
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
})
.catch(err => {
if (active) {
setError(err instanceof Error ? err.message : String(err))
setEntries([])
}
})
.finally(() => {
if (active) {
setLoading(false)
}
})
return () => {
active = false
}
}, [currentPath, pending])
const crumbs = useMemo(() => {
const parts = clean(currentPath).split('/').filter(Boolean)
const out = [{ label: '/', path: '/' }]
let acc = ''
for (const part of parts) {
acc += `/${part}`
out.push({ label: part, path: acc })
}
return out
}, [currentPath])
const close = (paths: string[] = []) => {
pending?.resolve(paths)
setPending(null)
setEntries([])
setError(null)
}
return (
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
<div className="border-b border-border/70 px-4 py-3">
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
</div>
<div className="flex min-h-[22rem] flex-col">
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<button
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
key={crumb.path}
onClick={() => setCurrentPath(crumb.path)}
type="button"
>
{crumb.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
{loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Codicon name="loading" size="0.8rem" spinning />
{r.loadingFiles}
</div>
) : error ? (
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
) : entries.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
) : (
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => close()} size="sm" variant="ghost">
{t.common.cancel}
</Button>
<Button onClick={() => close([currentPath])} size="sm">
{r.remotePickerSelect}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={disabled}
onClick={onClick}
type="button"
>
<Codicon name="folder" size="0.875rem" />
<span className="min-w-0 truncate">{name}</span>
</button>
)
}

View File

@@ -1,19 +1,24 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import type { HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
cleanup()
$connection.set(null)
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
@@ -106,6 +111,36 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(1)
})
it('reads gitignore from the real path while caching per connection', async () => {
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
const gitRoot = vi.fn(async () => '/repo')
readDir.mockImplementation(async path => {
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
if (path === '/repo/src') {
return ok([
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
])
}
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir).toHaveBeenCalledWith('/repo')
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
clearProjectDirCache()
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
})
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })

View File

@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
@@ -84,6 +86,7 @@ const initialState: ProjectTreeState = {
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
let lastConnectionKey = ''
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
@@ -145,6 +148,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
export function resetProjectTreeState() {
lastConnectionKey = ''
clearProjectTree()
clearProjectDirCache()
}
@@ -158,6 +162,8 @@ export function resetProjectTreeState() {
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const connection = useStore($connection)
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@@ -236,8 +242,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
)
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey
if (connectionChanged) {
clearProjectDirCache()
void loadRoot(cwd, { force: true })
return
}
void loadRoot(cwd)
}, [cwd])
}, [connectionKey, cwd])
return useMemo(
() => ({

View File

@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { RemoteFolderPicker } from './files/remote-picker'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
@@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const canCollapse = Object.values(openState).some(Boolean)
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
const selected = await selectDesktopPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RemoteFolderPicker />
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}

View File

@@ -1532,6 +1532,9 @@ export const en: Translations = {
terminal: 'Terminal',
noFolderSelected: 'No folder selected',
changeCwdTitle: 'Change working directory',
remotePickerTitle: 'Choose remote folder',
remotePickerDescription: 'Browse folders on the connected backend.',
remotePickerSelect: 'Select folder',
folderTip: cwd => `${cwd} — click to change folder`,
openFolder: 'Open folder',
refreshTree: 'Refresh tree',

View File

@@ -1665,6 +1665,9 @@ export const ja = defineLocale({
terminal: 'ターミナル',
noFolderSelected: 'フォルダーが選択されていません',
changeCwdTitle: '作業ディレクトリを変更',
remotePickerTitle: 'リモートフォルダーを選択',
remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
remotePickerSelect: 'フォルダーを選択',
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
openFolder: 'フォルダーを開く',
refreshTree: 'ツリーを更新',

View File

@@ -1194,6 +1194,9 @@ export interface Translations {
terminal: string
noFolderSelected: string
changeCwdTitle: string
remotePickerTitle: string
remotePickerDescription: string
remotePickerSelect: string
folderTip: (cwd: string) => string
openFolder: string
refreshTree: string

View File

@@ -1626,6 +1626,9 @@ export const zhHant = defineLocale({
terminal: '終端機',
noFolderSelected: '未選擇資料夾',
changeCwdTitle: '變更工作目錄',
remotePickerTitle: '選擇遠端資料夾',
remotePickerDescription: '瀏覽已連線後端上的資料夾。',
remotePickerSelect: '選擇資料夾',
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
openFolder: '開啟資料夾',
refreshTree: '重新整理檔案樹',

View File

@@ -1712,6 +1712,9 @@ export const zh: Translations = {
terminal: '终端',
noFolderSelected: '未选择文件夹',
changeCwdTitle: '更改工作目录',
remotePickerTitle: '选择远程文件夹',
remotePickerDescription: '浏览已连接后端上的文件夹。',
remotePickerSelect: '选择文件夹',
folderTip: cwd => `${cwd} — 点击更改文件夹`,
openFolder: '打开文件夹',
refreshTree: '刷新文件树',

View File

@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopGitRoot,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileText,
selectDesktopPaths,
setDesktopFsRemotePicker
} from './desktop-fs'
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
const gitRoot = vi.fn(async () => '/local')
const selectPaths = vi.fn(async () => ['/local'])
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
throw new Error(`unexpected path ${path}`)
})
function stubBridge() {
vi.stubGlobal('window', {
hermesDesktop: {
api,
gitRoot,
readDir,
readFileDataUrl,
readFileText,
selectPaths
}
})
}
describe('desktop filesystem facade', () => {
beforeEach(() => {
stubBridge()
$connection.set(null)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
setDesktopFsRemotePicker(null)
})
it('uses local Electron filesystem methods in local mode', async () => {
$connection.set({ mode: 'local' } as never)
await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
expect(readDir).toHaveBeenCalledWith('/work')
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
expect(gitRoot).toHaveBeenCalledWith('/work')
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
expect(api).not.toHaveBeenCalled()
})
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
expect(readDir).not.toHaveBeenCalled()
expect(readFileText).not.toHaveBeenCalled()
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(gitRoot).not.toHaveBeenCalled()
})
it('uses the registered in-app directory picker in remote mode', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
'/remote/project'
])
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
expect(selectPaths).not.toHaveBeenCalled()
})
it('does not treat the remote directory picker as a general file picker', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
expect(remoteSelect).not.toHaveBeenCalled()
expect(selectPaths).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,95 @@
import { $connection } from '@/store/session'
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
export interface DesktopFsRemotePicker {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
}
let remotePicker: DesktopFsRemotePicker | null = null
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
remotePicker = next
}
function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
}
export function desktopFsCacheKey() {
return connectionCacheKey($connection.get())
}
export function isDesktopFsRemoteMode() {
return $connection.get()?.mode === 'remote'
}
function fsPath(endpoint: string, filePath: string) {
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
}
function bridge() {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readDir(path)
}
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileText(path)
}
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileDataUrl(path)
}
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
return typeof result === 'string' ? result : result.dataUrl || ''
}
export async function desktopGitRoot(path: string): Promise<string | null> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.gitRoot ? desktop.gitRoot(path) : null
}
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
return result.root
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null
}
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
}
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.selectPaths(options)
}
if (!options?.directories || options.multiple !== false) {
return []
}
return remotePicker ? remotePicker.selectPaths(options) : []
}

View File

@@ -1,3 +1,4 @@
import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
import type { PreviewTarget } from '@/store/preview'
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
}
}
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
return target
}
try {
const result = await readDesktopFileText(target.path || target.source)
return {
...target,
binary: result.binary,
byteSize: result.byteSize,
language: result.language || target.language,
large: (result.byteSize ?? 0) > 512 * 1024,
mimeType: result.mimeType
}
} catch {
return target
}
}
export async function normalizeOrLocalPreviewTarget(
rawTarget: string,
cwd?: string | null
@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
if (normalized) {
return normalized
return enrichPreviewTarget(normalized)
}
} catch {
// Running Electron may still have the old HTML-only preview IPC. Fall
// through to renderer-side local classification so text/images still open.
}
return localPreviewTarget(rawTarget, cwd)
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
}

View File

@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$workingSessionIds,
applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setCurrentCwd,
setSessionAttention,
setSessionWorking,
workspaceCwdForNewSession
@@ -145,9 +147,12 @@ describe('mergeSessionPage', () => {
describe('workspaceCwdForNewSession', () => {
afterEach(() => {
applyConfiguredDefaultProjectDir(null)
$connection.set(null)
$currentCwd.set('')
$activeSessionId.set(null)
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
})
it('prefers the configured default over the sticky remembered workspace', () => {
@@ -177,6 +182,26 @@ describe('workspaceCwdForNewSession', () => {
expect($currentCwd.get()).toBe('/live/session/path')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
it('keeps remote workspace memory separate from local and other remotes', () => {
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
$currentCwd.set('/live/session/path')
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-a')
expect(workspaceCwdForNewSession()).toBe('/backend/project-a')
$connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-b')
expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
$connection.set(null)
expect(workspaceCwdForNewSession()).toBe('/local/project')
})
})
describe('getRecentlySettledSessionIds', () => {

View File

@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
// Cached copy of Settings → Sessions → Default project directory. The main
// process persists this in project-dir.json, but the renderer must also honor it
// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir
// wins and new sessions ignore the user's explicit picker choice.
let configuredDefaultProjectDir = ''
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
if (connection?.mode !== 'remote') {
return WORKSPACE_CWD_KEY
}
const base = encodeURIComponent(connection.baseUrl || 'remote')
const profile = encodeURIComponent(connection.profile || 'default')
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
}
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
@@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
}
}
const remembered = getRememberedWorkspaceCwd()
if ($connection.get()?.mode === 'remote') {
seedLiveCwd(remembered)
return
}
if (configured) {
const { cwd } = await sanitize(configured)
seedLiveCwd(cwd)
@@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
return
}
const { cwd } = await sanitize(getRememberedWorkspaceCwd())
seedLiveCwd(cwd)
if (remembered) {
const { cwd } = await sanitize(remembered)
seedLiveCwd(cwd)
}
}
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
@@ -229,15 +244,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive,
export const setCurrentCwd = (next: Updater<string>) => {
updateAtom($currentCwd, next)
// Keep localStorage in sync with the atom: a real folder is remembered, an
// empty cwd clears the key (|| null → removeItem).
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
}
/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise
* fall back to the sticky last-used folder, then whatever is already live. */
export const workspaceCwdForNewSession = (): string =>
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
export const workspaceCwdForNewSession = (): string => {
if ($connection.get()?.mode === 'remote') {
return getRememberedWorkspaceCwd()
}
return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
}
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)

View File

@@ -18,6 +18,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
import hmac
import importlib.util
import mimetypes
import json
import logging
import os
@@ -820,6 +821,177 @@ _MEDIA_CONTENT_TYPES = {
}
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
_FS_READDIR_HIDDEN = {
".git",
".hg",
".svn",
".cache",
".next",
".turbo",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
"target",
"venv",
}
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
_FS_PREVIEW_LANGUAGE_BY_EXT = {
".c": "c",
".conf": "ini",
".cpp": "cpp",
".css": "css",
".csv": "csv",
".go": "go",
".graphql": "graphql",
".h": "c",
".hpp": "cpp",
".html": "html",
".java": "java",
".js": "javascript",
".json": "json",
".jsx": "jsx",
".kt": "kotlin",
".lua": "lua",
".md": "markdown",
".mjs": "javascript",
".py": "python",
".rb": "ruby",
".rs": "rust",
".sh": "shell",
".sql": "sql",
".svg": "xml",
".toml": "toml",
".ts": "typescript",
".tsx": "tsx",
".txt": "text",
".xml": "xml",
".yaml": "yaml",
".yml": "yaml",
".zsh": "shell",
}
_FS_MIME_TYPES = {
".avi": "video/x-msvideo",
".bmp": "image/bmp",
".flac": "audio/flac",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".m4a": "audio/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".opus": "audio/ogg; codecs=opus",
".png": "image/png",
".svg": "image/svg+xml",
".wav": "audio/wav",
".webm": "video/webm",
".webp": "image/webp",
}
def _fs_path(raw_path: str) -> Path:
raw = str(raw_path or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="Path is required")
if "\0" in raw:
raise HTTPException(status_code=400, detail="Invalid path")
try:
if raw.lower().startswith("file:"):
parsed = urllib.parse.urlparse(raw)
if parsed.netloc and parsed.netloc not in {"", "localhost"}:
raise ValueError
raw = urllib.request.url2pathname(parsed.path)
candidate = Path(raw).expanduser()
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
return candidate.resolve(strict=False)
except (OSError, RuntimeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid path")
def _fs_mime_type(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in _FS_MIME_TYPES:
return _FS_MIME_TYPES[suffix]
guessed, _ = mimetypes.guess_type(str(path))
return guessed or "application/octet-stream"
def _fs_looks_binary(data: bytes) -> bool:
if not data:
return False
if b"\0" in data:
return True
suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13})
return suspicious / len(data) > 0.12
def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]:
target = _fs_path(str(path))
try:
st = target.stat()
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found")
except NotADirectoryError:
raise HTTPException(status_code=404, detail="File not found")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid path")
if stat.S_ISDIR(st.st_mode):
raise HTTPException(status_code=400, detail="Path points to a directory")
if not stat.S_ISREG(st.st_mode):
raise HTTPException(status_code=400, detail="Only regular files can be read")
return target, st
def _fs_find_git_root(start: Path) -> str | None:
directory = start
for _ in range(50):
try:
if (directory / ".git").exists():
return str(directory)
except OSError:
return None
parent = directory.parent
if parent == directory:
return None
directory = parent
return None
def _fs_default_cwd() -> str:
cfg_terminal = load_config().get("terminal") or {}
raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip()
if raw and raw not in {".", "auto", "cwd"}:
try:
candidate = Path(raw).expanduser().resolve(strict=False)
if candidate.is_dir():
return str(candidate)
except (OSError, RuntimeError):
pass
return str(Path.cwd())
def _fs_git_branch(cwd: str) -> str:
try:
result = subprocess.run(
["git", "-C", cwd, "branch", "--show-current"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
return result.stdout.strip() if result.returncode == 0 else ""
except Exception:
return ""
def _media_serve_roots() -> list[Path]:
"""Directories ``GET /api/media`` is allowed to read from.
@@ -874,6 +1046,87 @@ async def get_media(path: str):
return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
@app.get("/api/fs/list")
async def fs_list(path: str):
target = _fs_path(path)
try:
entries = []
with os.scandir(target) as scan:
for entry in scan:
if entry.name in _FS_READDIR_HIDDEN:
continue
entries.append({
"name": entry.name,
"path": str(target / entry.name),
"isDirectory": entry.is_dir(follow_symlinks=False),
})
entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"]))
return {"entries": entries}
except FileNotFoundError:
return {"entries": [], "error": "ENOENT"}
except NotADirectoryError:
return {"entries": [], "error": "ENOTDIR"}
except PermissionError:
return {"entries": [], "error": "EACCES"}
except OSError as exc:
return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"}
@app.get("/api/fs/read-text")
async def fs_read_text(path: str):
target, st = _fs_regular_file(_fs_path(path))
if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES)
try:
with target.open("rb") as handle:
data = handle.read(bytes_to_read)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
return {
"binary": _fs_looks_binary(data[:4096]),
"byteSize": st.st_size,
"language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"),
"mimeType": _fs_mime_type(target),
"path": str(target),
"text": data.decode("utf-8", errors="replace"),
"truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES,
}
@app.get("/api/fs/read-data-url")
async def fs_read_data_url(path: str):
target, st = _fs_regular_file(_fs_path(path))
if st.st_size > _FS_DATA_URL_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
try:
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"}
@app.get("/api/fs/git-root")
async def fs_git_root(path: str):
target = _fs_path(path)
try:
st = target.stat()
start = target if stat.S_ISDIR(st.st_mode) else target.parent
except OSError:
start = target
return {"root": _fs_find_git_root(start)}
@app.get("/api/fs/default-cwd")
async def fs_default_cwd():
cwd = _fs_default_cwd()
return {"cwd": cwd, "branch": _fs_git_branch(cwd)}
@app.get("/api/status")
async def get_status():
current_ver, latest_ver = check_config_version()

View File

@@ -0,0 +1,188 @@
import base64
from pathlib import Path
import pytest
from hermes_cli import web_server
pytest.importorskip("starlette.testclient")
from starlette.testclient import TestClient
@pytest.fixture
def client(monkeypatch):
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
test_client = TestClient(web_server.app)
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
try:
yield test_client
finally:
if previous_auth_required is None:
try:
delattr(web_server.app.state, "auth_required")
except AttributeError:
pass
else:
web_server.app.state.auth_required = previous_auth_required
def test_fs_list_sorts_and_hides_noise(client, tmp_path):
root = tmp_path / "project"
root.mkdir()
(root / "b.txt").write_text("b")
(root / "a_dir").mkdir()
(root / "a.txt").write_text("a")
(root / "node_modules").mkdir()
(root / ".git").mkdir()
response = client.get("/api/fs/list", params={"path": str(root)})
assert response.status_code == 200
entries = response.json()["entries"]
assert [entry["name"] for entry in entries] == ["a_dir", "a.txt", "b.txt"]
assert entries[0] == {"name": "a_dir", "path": str(root / "a_dir"), "isDirectory": True}
assert all(entry["name"] not in {".git", "node_modules"} for entry in entries)
def test_fs_list_accepts_relative_paths(client, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "rel").mkdir()
(tmp_path / "rel" / "file.txt").write_text("ok")
response = client.get("/api/fs/list", params={"path": "rel"})
assert response.status_code == 200
assert response.json()["entries"] == [
{"name": "file.txt", "path": str(tmp_path / "rel" / "file.txt"), "isDirectory": False}
]
def test_fs_list_missing_path_returns_structured_error(client, tmp_path):
response = client.get("/api/fs/list", params={"path": str(tmp_path / "missing")})
assert response.status_code == 200
assert response.json() == {"entries": [], "error": "ENOENT"}
def test_fs_read_text_matches_preview_shape_and_truncates(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 32)
monkeypatch.setattr(web_server, "_FS_TEXT_PREVIEW_MAX_BYTES", 5)
target = tmp_path / "sample.py"
target.write_text("print('hello')")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {
"binary": False,
"byteSize": 14,
"language": "python",
"mimeType": "text/x-python",
"path": str(target),
"text": "print",
"truncated": True,
}
def test_fs_read_text_rejects_source_over_cap(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 4)
target = tmp_path / "large.txt"
target.write_text("12345")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 413
def test_fs_read_text_flags_binary(client, tmp_path):
target = tmp_path / "blob.bin"
target.write_bytes(b"hello\x00world")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 200
body = response.json()
assert body["binary"] is True
assert body["text"].startswith("hello")
def test_fs_read_data_url_returns_capped_data_url(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 16)
target = tmp_path / "image.png"
target.write_bytes(b"pngbytes")
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {"dataUrl": "data:image/png;base64," + base64.b64encode(b"pngbytes").decode("ascii")}
def test_fs_read_data_url_rejects_over_cap(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 3)
target = tmp_path / "image.png"
target.write_bytes(b"1234")
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
assert response.status_code == 413
def test_fs_git_root_for_nested_file(client, tmp_path):
(tmp_path / ".git").mkdir()
nested = tmp_path / "pkg" / "mod"
nested.mkdir(parents=True)
target = nested / "file.py"
target.write_text("x")
response = client.get("/api/fs/git-root", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {"root": str(tmp_path)}
def test_fs_git_root_returns_null_outside_repo(client, tmp_path):
response = client.get("/api/fs/git-root", params={"path": str(tmp_path)})
assert response.status_code == 200
assert response.json() == {"root": None}
def test_fs_default_cwd_prefers_existing_terminal_cwd(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": str(tmp_path)}})
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "env"))
monkeypatch.setattr(web_server.Path, "cwd", lambda: tmp_path / "process")
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "main")
response = client.get("/api/fs/default-cwd")
assert response.status_code == 200
assert response.json() == {"cwd": str(tmp_path), "branch": "main"}
def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path, monkeypatch):
fallback = tmp_path / "backend"
fallback.mkdir()
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": "/client/missing"}})
monkeypatch.setenv("TERMINAL_CWD", "/client/missing")
monkeypatch.setattr(web_server.Path, "cwd", lambda: fallback)
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "")
response = client.get("/api/fs/default-cwd")
assert response.status_code == 200
assert response.json() == {"cwd": str(fallback), "branch": ""}
def test_fs_endpoints_require_auth(tmp_path):
client = TestClient(web_server.app)
target = tmp_path / "secret.txt"
target.write_text("secret")
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
default_response = client.get("/api/fs/default-cwd")
assert list_response.status_code == 401
assert read_response.status_code == 401
assert default_response.status_code == 401