Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
04cc6079dThis file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Project file listing + reading for the remote agent's SourceTree ("Files" panel).
* Root = the tmux session's working dir. Mirrors the hosted runtime’s tree/ignore/hidden-path rules
* (ProjectService.readDirRecursive / readFile) and ADDS the file-view guard: only files ≤ 5 MB and
* only text files (binary → rejected). Self-contained — no dependencies outside this package.
*/
import { readdirSync, statSync, readFileSync } from 'fs'
import { join, resolve, sep, basename } from 'path'
export interface FileNode {
name: string
type: 'file' | 'dir'
size?: number
children?: FileNode[]
}
export const MAX_FILE_BYTES = 5 * 1024 * 1024 // 5 MiB
// Exact-name ignores (kept in sync with the hosted runtime’s readDirRecursive). Any dot-prefixed entry
// (`.git`, `.next`, `.vite`, `.claude`, `.env`, `.node_modules`, …) is excluded separately below,
// so only the non-dot names need listing here.
const EXCLUDE = new Set(['node_modules', 'dist', 'logs', 'CLAUDE.md'])
/** Hidden entry: any name starting with a dot (e.g. `.git`, `.env`, `.node_modules`). */
const isHidden = (name: string): boolean => name.startsWith('.')
// Soft caps so a giant arbitrary user dir can't hang / flood the walk (the hosted runtime has none, but the
// remote root is an arbitrary machine dir).
const MAX_NODES = 20_000
const MAX_DEPTH = 12
/** Recursive eager file tree under `root`, excluding EXCLUDE names, dirs-before-files sorted. */
export function listFileTree(root: string): FileNode[] {
let count = 0
let truncated = false
const walk = (dir: string, depth: number): FileNode[] => {
if (depth > MAX_DEPTH || count >= MAX_NODES) { truncated = true; return [] }
let entries
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return [] }
const nodes: FileNode[] = []
for (const entry of entries) {
if (isHidden(entry.name) || EXCLUDE.has(entry.name)) continue
if (count >= MAX_NODES) { truncated = true; break }
count++
const full = join(dir, entry.name)
if (entry.isDirectory()) {
nodes.push({ name: entry.name, type: 'dir', children: walk(full, depth + 1) })
} else if (entry.isFile()) {
let size = 0
try { size = statSync(full).size } catch { /* ignore */ }
nodes.push({ name: entry.name, type: 'file', size })
}
}
return nodes.sort((a, b) => (a.type !== b.type ? (a.type === 'dir' ? -1 : 1) : a.name.localeCompare(b.name)))
}
const tree = walk(root, 0)
if (truncated) console.warn(`[files] tree truncated at ${count} nodes for ${root}`)
return tree
}
/** First 8 KB has a NUL byte → treat as binary (git/grep heuristic; extension-agnostic). */
function isBinary(buf: Buffer): boolean {
const n = Math.min(buf.length, 8192)
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
return false
}
/**
* Read one project file. Throws a bare error CODE on any rule violation:
* NOT_FOUND | FILE_TOO_LARGE | NOT_TEXT
*/
export function readProjectFile(root: string, relPath: string): string {
// Hidden/internal blocks (mirror the hosted runtime). Any dot-prefixed path segment (`.env`, `.git/…`,
// `.claude/…`, and `..`) is hidden from the tree, so it must not be readable directly either.
if (
basename(relPath) === 'CLAUDE.md' ||
relPath.startsWith('logs/') ||
relPath.split(/[/\\]/).some((seg) => seg.startsWith('.'))
) {
throw new Error('NOT_FOUND')
}
// Path-traversal safety: the resolved path must stay under root.
const rootResolved = resolve(root)
const full = resolve(rootResolved, relPath)
if (full !== rootResolved && !full.startsWith(rootResolved + sep)) throw new Error('NOT_FOUND')
let st
try { st = statSync(full) } catch { throw new Error('NOT_FOUND') }
if (!st.isFile()) throw new Error('NOT_FOUND')
if (st.size > MAX_FILE_BYTES) throw new Error('FILE_TOO_LARGE')
let buf: Buffer
try { buf = readFileSync(full) } catch { throw new Error('NOT_FOUND') }
if (isBinary(buf)) throw new Error('NOT_TEXT')
return buf.toString('utf-8')
}