Skip to content

Commit 12bbd12

Browse files
Add listFiles method (#57)
* Add listFiles method * Update ISandbox type * Fix types * Fix crash due to pre-warming * Support relative paths * Create changeset
1 parent 5213e94 commit 12bbd12

File tree

14 files changed

+774
-84
lines changed

14 files changed

+774
-84
lines changed

.changeset/puny-teams-doubt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/sandbox": patch
3+
---
4+
5+
Add listFiles method

examples/basic/app/index.tsx

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,35 @@ import { useEffect, useRef, useState } from "react";
33
import { createRoot } from "react-dom/client";
44
import "./style.css";
55

6+
// Type definitions
7+
interface FileInfo {
8+
name: string;
9+
absolutePath: string;
10+
relativePath: string;
11+
type: 'file' | 'directory' | 'symlink' | 'other';
12+
size: number;
13+
modifiedAt: string;
14+
mode: string;
15+
permissions: {
16+
readable: boolean;
17+
writable: boolean;
18+
executable: boolean;
19+
};
20+
}
21+
22+
interface ListFilesOptions {
23+
recursive?: boolean;
24+
includeHidden?: boolean;
25+
}
26+
27+
interface ListFilesResponse {
28+
success: boolean;
29+
path: string;
30+
files: FileInfo[];
31+
count: number;
32+
timestamp: string;
33+
}
34+
635
// Simple API client to replace direct HttpClient usage
736
class SandboxApiClient {
837
private baseUrl: string;
@@ -244,6 +273,13 @@ class SandboxApiClient {
244273
});
245274
}
246275

276+
async listFiles(path: string, options: ListFilesOptions = {}): Promise<ListFilesResponse> {
277+
return this.doFetch("/api/list-files", {
278+
method: "POST",
279+
body: JSON.stringify({ path, options }),
280+
});
281+
}
282+
247283
async mkdir(path: string, options: any = {}) {
248284
return this.doFetch("/api/mkdir", {
249285
method: "POST",
@@ -1385,6 +1421,10 @@ function FilesTab({
13851421
const [moveSourcePath, setMoveSourcePath] = useState("");
13861422
const [moveDestPath, setMoveDestPath] = useState("");
13871423
const [deleteFilePath, setDeleteFilePath] = useState("");
1424+
const [listPath, setListPath] = useState("/workspace");
1425+
const [listRecursive, setListRecursive] = useState(false);
1426+
const [listHidden, setListHidden] = useState(false);
1427+
const [listedFiles, setListedFiles] = useState<FileInfo[]>([]);
13881428

13891429
// Git Operations
13901430
const [gitRepoUrl, setGitRepoUrl] = useState("");
@@ -1468,6 +1508,63 @@ function FilesTab({
14681508
}
14691509
};
14701510

1511+
const handleListFiles = async () => {
1512+
if (!client || !listPath.trim()) return;
1513+
try {
1514+
const result = await client.listFiles(listPath, {
1515+
recursive: listRecursive,
1516+
includeHidden: listHidden,
1517+
});
1518+
1519+
// Sort files for proper tree display using relativePath
1520+
const sortedFiles = (result.files || []).sort((a, b) => {
1521+
// Use relativePath for cleaner sorting
1522+
const aSegments = a.relativePath.split('/').filter(s => s);
1523+
const bSegments = b.relativePath.split('/').filter(s => s);
1524+
1525+
// Compare segment by segment
1526+
const minLength = Math.min(aSegments.length, bSegments.length);
1527+
1528+
for (let i = 0; i < minLength; i++) {
1529+
// If we're at the last segment for either path
1530+
const aIsLast = i === aSegments.length - 1;
1531+
const bIsLast = i === bSegments.length - 1;
1532+
1533+
// If one is a parent of the other
1534+
if (aIsLast && !bIsLast) {
1535+
// a is a parent directory of b (if a is a directory)
1536+
return a.type === 'directory' ? -1 : 1;
1537+
}
1538+
if (!aIsLast && bIsLast) {
1539+
// b is a parent directory of a (if b is a directory)
1540+
return b.type === 'directory' ? 1 : -1;
1541+
}
1542+
1543+
// If both are at the same level (both last or both not last)
1544+
if (aIsLast && bIsLast) {
1545+
// Same directory level - directories first, then alphabetical
1546+
if (a.type === 'directory' && b.type !== 'directory') return -1;
1547+
if (a.type !== 'directory' && b.type === 'directory') return 1;
1548+
}
1549+
1550+
// Compare the segments alphabetically
1551+
const segmentCompare = aSegments[i].localeCompare(bSegments[i]);
1552+
if (segmentCompare !== 0) return segmentCompare;
1553+
}
1554+
1555+
// If we get here, one path is a prefix of the other
1556+
// The shorter path (parent) should come first
1557+
return aSegments.length - bSegments.length;
1558+
});
1559+
1560+
setListedFiles(sortedFiles);
1561+
addResult("success", `Listed ${result.count || 0} files in: ${listPath}`);
1562+
} catch (error: any) {
1563+
addResult("error", `Failed to list files: ${error.message}`);
1564+
setListedFiles([]);
1565+
}
1566+
};
1567+
14711568
const handleGitCheckout = async () => {
14721569
if (!client || !gitRepoUrl.trim()) return;
14731570
try {
@@ -1658,6 +1755,91 @@ function FilesTab({
16581755
</button>
16591756
</div>
16601757
</div>
1758+
1759+
{/* List Files */}
1760+
<div className="operation-group">
1761+
<h3>List Files</h3>
1762+
<div className="input-group">
1763+
<input
1764+
type="text"
1765+
placeholder="Directory path (e.g., /workspace)"
1766+
value={listPath}
1767+
onChange={(e) => setListPath(e.target.value)}
1768+
className="file-input"
1769+
/>
1770+
<button
1771+
onClick={handleListFiles}
1772+
disabled={!listPath.trim() || connectionStatus !== "connected"}
1773+
className="action-button"
1774+
>
1775+
List Files
1776+
</button>
1777+
</div>
1778+
<div className="list-options">
1779+
<label>
1780+
<input
1781+
type="checkbox"
1782+
checked={listRecursive}
1783+
onChange={(e) => setListRecursive(e.target.checked)}
1784+
/>
1785+
Recursive
1786+
</label>
1787+
<label>
1788+
<input
1789+
type="checkbox"
1790+
checked={listHidden}
1791+
onChange={(e) => setListHidden(e.target.checked)}
1792+
/>
1793+
Include Hidden
1794+
</label>
1795+
</div>
1796+
{listedFiles.length > 0 && (
1797+
<div className="file-list-results">
1798+
<h4>Files ({listedFiles.length}):</h4>
1799+
<div className="file-list">
1800+
{listedFiles.map((file, index) => {
1801+
// Calculate indentation level using the relativePath field
1802+
const depth = listRecursive ? (file.relativePath.split('/').filter(s => s).length - 1) : 0;
1803+
1804+
// For directories, add a trailing slash for clarity
1805+
const displayName = file.type === 'directory' ? `${file.name}/` : file.name;
1806+
1807+
// Add tree-like prefix for better hierarchy visualization
1808+
const treePrefix = depth > 0 ? '├── ' : '';
1809+
1810+
return (
1811+
<div
1812+
key={index}
1813+
className="file-item"
1814+
style={{
1815+
paddingLeft: `${depth * 16 + 8}px`,
1816+
fontWeight: file.type === 'directory' ? '500' : 'normal'
1817+
}}
1818+
>
1819+
{depth > 0 && <span className="tree-prefix">{treePrefix}</span>}
1820+
<span className="file-icon">
1821+
{file.type === 'directory' ? '📁' :
1822+
file.permissions.executable ? '⚙️' : '📄'}
1823+
</span>
1824+
<span className="file-mode">{file.mode}</span>
1825+
<span className="file-name" title={file.absolutePath}>
1826+
{displayName}
1827+
</span>
1828+
<span className="file-details">
1829+
{file.type === 'file' && (
1830+
<span className="file-size">{file.size.toLocaleString()} bytes</span>
1831+
)}
1832+
<span className="file-date">
1833+
{new Date(file.modifiedAt).toLocaleDateString()}
1834+
</span>
1835+
</span>
1836+
</div>
1837+
);
1838+
})}
1839+
</div>
1840+
</div>
1841+
)}
1842+
</div>
16611843
</div>
16621844
{/* Git Operations */}
16631845
<div className="git-section">
@@ -3047,7 +3229,7 @@ result = x / y`,
30473229
}
30483230

30493231
function SandboxTester() {
3050-
const [activeTab, setActiveTab] = useState<TabType>("notebook");
3232+
const [activeTab, setActiveTab] = useState<TabType>("commands");
30513233
const [client, setClient] = useState<SandboxApiClient | null>(null);
30523234
const [connectionStatus, setConnectionStatus] = useState<
30533235
"disconnected" | "connecting" | "connected"

examples/basic/app/style.css

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,113 @@ body {
1010
-moz-osx-font-smoothing: grayscale;
1111
}
1212

13+
/* List Files Styles */
14+
.list-options {
15+
display: flex;
16+
gap: 1rem;
17+
margin-top: 0.5rem;
18+
margin-bottom: 0.5rem;
19+
}
20+
21+
.list-options label {
22+
display: flex;
23+
align-items: center;
24+
gap: 0.5rem;
25+
font-size: 0.9rem;
26+
color: #8b949e;
27+
cursor: pointer;
28+
}
29+
30+
.list-options input[type="checkbox"] {
31+
cursor: pointer;
32+
}
33+
34+
.file-list-results {
35+
margin-top: 1rem;
36+
padding: 1rem;
37+
background-color: #0d1117;
38+
border: 1px solid #30363d;
39+
border-radius: 6px;
40+
max-height: 400px;
41+
overflow-y: auto;
42+
}
43+
44+
.file-list-results h4 {
45+
margin-top: 0;
46+
margin-bottom: 0.75rem;
47+
color: #58a6ff;
48+
font-size: 0.95rem;
49+
}
50+
51+
.file-list {
52+
display: flex;
53+
flex-direction: column;
54+
gap: 0.25rem;
55+
}
56+
57+
.file-item {
58+
display: flex;
59+
align-items: center;
60+
gap: 0.5rem;
61+
padding: 0.5rem;
62+
border-radius: 4px;
63+
transition: background-color 0.2s;
64+
position: relative;
65+
}
66+
67+
.file-item:hover {
68+
background-color: #161b22;
69+
}
70+
71+
.file-item::before {
72+
content: "";
73+
position: absolute;
74+
left: 0;
75+
top: 0;
76+
bottom: 0;
77+
width: 1px;
78+
background: linear-gradient(to bottom, transparent, #30363d, transparent);
79+
}
80+
81+
.tree-prefix {
82+
color: #30363d;
83+
font-family: "Fira Code", monospace;
84+
margin-right: 0.25rem;
85+
}
86+
87+
.file-icon {
88+
font-size: 1.1rem;
89+
flex-shrink: 0;
90+
}
91+
92+
.file-mode {
93+
font-family: "Fira Code", monospace;
94+
font-size: 0.85rem;
95+
color: #7ee83f;
96+
margin-right: 0.75rem;
97+
}
98+
99+
.file-name {
100+
flex: 1;
101+
font-family: "Fira Code", monospace;
102+
font-size: 0.9rem;
103+
}
104+
105+
.file-details {
106+
display: flex;
107+
gap: 1rem;
108+
font-size: 0.85rem;
109+
color: #8b949e;
110+
}
111+
112+
.file-size {
113+
flex-shrink: 0;
114+
}
115+
116+
.file-date {
117+
flex-shrink: 0;
118+
}
119+
13120
/* Main Container */
14121
.sandbox-tester-container {
15122
max-width: 1280px;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Sandbox } from "@cloudflare/sandbox";
2+
import { errorResponse, jsonResponse, parseJsonBody } from "../http";
3+
4+
export async function listFiles(sandbox: Sandbox<unknown>, request: Request) {
5+
try {
6+
const body = await parseJsonBody(request);
7+
const { path, options } = body;
8+
9+
if (!path) {
10+
return errorResponse("Path is required");
11+
}
12+
13+
const result = await sandbox.listFiles(path, options);
14+
return jsonResponse({
15+
success: true,
16+
path,
17+
files: result.files,
18+
count: result.files.length,
19+
timestamp: new Date().toISOString()
20+
});
21+
} catch (error: any) {
22+
console.error("Error listing files:", error);
23+
return errorResponse(`Failed to list files: ${error.message}`);
24+
}
25+
}

examples/basic/src/endpoints/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { listProcesses } from "./processList";
77
export { getProcessLogs, streamProcessLogs } from "./processLogs";
88
export { startProcess } from "./processStart";
99
export { readFile } from "./fileRead";
10+
export { listFiles } from "./fileList";
1011
export { deleteFile } from "./fileDelete";
1112
export { renameFile } from "./fileRename";
1213
export { moveFile } from "./fileMove";

0 commit comments

Comments
 (0)