Skip to content

Commit 827fed9

Browse files
committed
feat: fs read/write
1 parent afc6962 commit 827fed9

File tree

3 files changed

+266
-8
lines changed

3 files changed

+266
-8
lines changed

src/lib/acp/client.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,25 +334,26 @@ export class ACPClient {
334334

335335
async prompt(content: ContentBlock[]): Promise<PromptResult> {
336336
this.ensureReady();
337-
if (!this._session) {
337+
const session = this._session;
338+
if (!session) {
338339
throw new Error("No active session. Call newSession() first.");
339340
}
340341

341342
const connection = this.getConnection();
342-
this._session.addUserMessage(content);
343+
session.addUserMessage(content);
343344

344345
try {
345346
const result = await this.runWhileConnected(
346347
connection.prompt({
347-
sessionId: this._session.sessionId,
348+
sessionId: session.sessionId,
348349
prompt: content,
349350
}),
350351
);
351352

352-
this._session.finishAgentTurn(result.stopReason);
353+
session.finishAgentTurn(result.stopReason);
353354
return result;
354355
} catch (error) {
355-
this._session.finishAgentTurn();
356+
session.finishAgentTurn();
356357
throw error;
357358
}
358359
}

src/pages/acp/acp.js

Lines changed: 255 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "./acp.scss";
22
import fsOperation from "fileSystem";
3+
import { RequestError } from "@agentclientprotocol/sdk";
34
import Page from "components/page";
45
import toast from "components/toast";
56
import select from "dialogs/select";
@@ -33,6 +34,10 @@ export default function AcpPageInclude() {
3334
let isPrompting = false;
3435
let activePromptSessionId = null;
3536
const BROWSE_CWD_OPTION = "__acp_cwd_browse__";
37+
const ACP_FS_READ_TEXT_FILE = "fs/read_text_file";
38+
const ACP_FS_WRITE_TEXT_FILE = "fs/write_text_file";
39+
40+
registerFilesystemHandlers();
3641

3742
// ─── Connection Form ───
3843
const $form = AgentForm({
@@ -60,7 +65,7 @@ export default function AcpPageInclude() {
6065
async function handleConnect({ url, cwd }) {
6166
if (!url) return;
6267

63-
const nextCwd = normalizeSessionCwd(cwd || "");
68+
const nextCwd = normalizeSessionCwd(cwd || "/home");
6469
$form.setValues({ url, cwd: nextCwd });
6570
$form.setConnecting(true);
6671
setFormStatus("");
@@ -84,6 +89,7 @@ export default function AcpPageInclude() {
8489
const packageName = window.BuildInfo?.packageName || "com.foxdebug.acode";
8590
const dataDir = `/data/user/0/${packageName}`;
8691
return {
92+
dataDir,
8793
alpineRoot: `${dataDir}/files/alpine`,
8894
publicDir: `${dataDir}/files/public`,
8995
};
@@ -146,6 +152,251 @@ export default function AcpPageInclude() {
146152
return convertToTerminalCwd(value, true);
147153
}
148154

155+
function getSessionCwdForFs(sessionId = "") {
156+
const activeSession = client.session;
157+
if (activeSession?.sessionId === sessionId) {
158+
return normalizeSessionCwd(activeSession.cwd || "");
159+
}
160+
return normalizeSessionCwd($form.getValues().cwd || "");
161+
}
162+
163+
function resolveAgentPath(path = "", sessionId = "") {
164+
const normalizedPath = normalizePathInput(path);
165+
if (!normalizedPath) return "";
166+
167+
const sessionCwd = getSessionCwdForFs(sessionId);
168+
const protocol = Url.getProtocol(normalizedPath);
169+
170+
if (protocol) {
171+
if (protocol === "file:") {
172+
return normalizedPath;
173+
}
174+
if (
175+
protocol === "content:" ||
176+
protocol === "ftp:" ||
177+
protocol === "sftp:" ||
178+
protocol === "http:" ||
179+
protocol === "https:"
180+
) {
181+
return normalizedPath;
182+
}
183+
return "";
184+
}
185+
186+
const agentPath = normalizedPath.startsWith("/")
187+
? normalizedPath
188+
: sessionCwd
189+
? Url.join(sessionCwd, normalizedPath)
190+
: "";
191+
if (!agentPath) return "";
192+
193+
const { alpineRoot, publicDir } = getTerminalPaths();
194+
if (agentPath === "~") {
195+
return `file://${alpineRoot}/home`;
196+
}
197+
if (agentPath.startsWith("~/")) {
198+
return `file://${alpineRoot}/home/${agentPath.slice(2)}`;
199+
}
200+
if (agentPath === "/public" || agentPath.startsWith("/public/")) {
201+
const suffix = agentPath.slice("/public".length);
202+
return `file://${publicDir}${suffix}`;
203+
}
204+
if (agentPath === "/home" || agentPath.startsWith("/home/")) {
205+
return `file://${alpineRoot}${agentPath}`;
206+
}
207+
if (
208+
agentPath.startsWith("/sdcard") ||
209+
agentPath.startsWith("/storage") ||
210+
agentPath.startsWith("/data")
211+
) {
212+
return `file://${agentPath}`;
213+
}
214+
if (agentPath.startsWith("/")) {
215+
return `file://${alpineRoot}${agentPath}`;
216+
}
217+
218+
return "";
219+
}
220+
221+
function normalizeFsReadRange(value, { name, min = 1 } = {}) {
222+
if (value == null) return null;
223+
const num = Number(value);
224+
if (!Number.isInteger(num) || num < min) {
225+
throw RequestError.invalidParams(
226+
{},
227+
`${name || "value"} must be an integer >= ${min}`,
228+
);
229+
}
230+
return num;
231+
}
232+
233+
function sliceTextByLineRange(text = "", line, limit) {
234+
if (line == null && limit == null) return text;
235+
const allLines = String(text).split(/\r\n|\n|\r/);
236+
const startLine = line || 1;
237+
if (startLine > allLines.length) return "";
238+
if (limit === 0) return "";
239+
if (limit == null) {
240+
return allLines.slice(startLine - 1).join("\n");
241+
}
242+
return allLines.slice(startLine - 1, startLine - 1 + limit).join("\n");
243+
}
244+
245+
function getOpenEditorFile(uri = "") {
246+
const manager = window.editorManager;
247+
if (!manager?.getFile || !uri) return null;
248+
const candidates = [uri];
249+
try {
250+
const decoded = decodeURIComponent(uri);
251+
if (decoded && !candidates.includes(decoded)) candidates.push(decoded);
252+
} catch {
253+
/* ignore */
254+
}
255+
256+
for (const candidate of candidates) {
257+
const file = manager.getFile(candidate, "uri");
258+
if (file) return file;
259+
}
260+
return null;
261+
}
262+
263+
async function readFileTextFromFs(resolvedPath = "") {
264+
const openFileRef = getOpenEditorFile(resolvedPath);
265+
const unsavedContent = openFileRef?.session?.getValue?.();
266+
if (typeof unsavedContent === "string") {
267+
return unsavedContent;
268+
}
269+
270+
const content = await fsOperation(resolvedPath).readFile("utf8");
271+
if (typeof content === "string") return content;
272+
if (content instanceof ArrayBuffer) {
273+
return new TextDecoder().decode(content);
274+
}
275+
return String(content ?? "");
276+
}
277+
278+
async function writeFileTextToFs(resolvedPath = "", content = "") {
279+
const targetFs = fsOperation(resolvedPath);
280+
const exists = await targetFs.exists();
281+
if (exists) {
282+
await targetFs.writeFile(content, "utf8");
283+
} else {
284+
const parentPath = Url.dirname(resolvedPath);
285+
const filename = Url.basename(resolvedPath);
286+
if (!parentPath || !filename) {
287+
throw RequestError.invalidParams(
288+
{},
289+
`Invalid file path: ${resolvedPath}`,
290+
);
291+
}
292+
await fsOperation(parentPath).createFile(filename, content);
293+
}
294+
295+
const openFileRef = getOpenEditorFile(resolvedPath);
296+
if (openFileRef?.type === "editor") {
297+
openFileRef.session?.setValue?.(content);
298+
openFileRef.isUnsaved = false;
299+
openFileRef.markChanged = false;
300+
await openFileRef.writeToCache?.();
301+
}
302+
}
303+
304+
function assertValidSessionRequest(sessionId = "") {
305+
const activeSessionId = client.session?.sessionId;
306+
if (!sessionId || !activeSessionId || sessionId !== activeSessionId) {
307+
throw RequestError.invalidParams({}, "Invalid or inactive sessionId");
308+
}
309+
}
310+
311+
function toFsError(error, requestPath = "") {
312+
const message = String(error?.message || error || "");
313+
if (error instanceof RequestError) {
314+
return error;
315+
}
316+
if (
317+
/not found|no such file|path not found|does not exist|failed to resolve/i.test(
318+
message,
319+
)
320+
) {
321+
return RequestError.resourceNotFound(requestPath || undefined);
322+
}
323+
return RequestError.internalError(
324+
{},
325+
message || "Filesystem operation failed",
326+
);
327+
}
328+
329+
function registerFilesystemHandlers() {
330+
client.registerRequestHandler(
331+
ACP_FS_READ_TEXT_FILE,
332+
async (params = {}) => {
333+
try {
334+
const sessionId = String(params?.sessionId || "");
335+
assertValidSessionRequest(sessionId);
336+
337+
const rawPath = normalizePathInput(params?.path || "");
338+
if (!rawPath) {
339+
throw RequestError.invalidParams({}, "path is required");
340+
}
341+
342+
const line = normalizeFsReadRange(params?.line, {
343+
name: "line",
344+
min: 1,
345+
});
346+
const limit = normalizeFsReadRange(params?.limit, {
347+
name: "limit",
348+
min: 0,
349+
});
350+
const resolvedPath = resolveAgentPath(rawPath, sessionId);
351+
if (!resolvedPath) {
352+
throw RequestError.invalidParams(
353+
{},
354+
`Unsupported filesystem path: ${rawPath}`,
355+
);
356+
}
357+
358+
const text = await readFileTextFromFs(resolvedPath);
359+
return {
360+
content: sliceTextByLineRange(text, line, limit),
361+
};
362+
} catch (error) {
363+
throw toFsError(error, params?.path);
364+
}
365+
},
366+
);
367+
368+
client.registerRequestHandler(
369+
ACP_FS_WRITE_TEXT_FILE,
370+
async (params = {}) => {
371+
try {
372+
const sessionId = String(params?.sessionId || "");
373+
assertValidSessionRequest(sessionId);
374+
375+
const rawPath = normalizePathInput(params?.path || "");
376+
if (!rawPath) {
377+
throw RequestError.invalidParams({}, "path is required");
378+
}
379+
if (typeof params?.content !== "string") {
380+
throw RequestError.invalidParams({}, "content must be a string");
381+
}
382+
383+
const resolvedPath = resolveAgentPath(rawPath, sessionId);
384+
if (!resolvedPath) {
385+
throw RequestError.invalidParams(
386+
{},
387+
`Unsupported filesystem path: ${rawPath}`,
388+
);
389+
}
390+
391+
await writeFileTextToFs(resolvedPath, params.content);
392+
return {};
393+
} catch (error) {
394+
throw toFsError(error, params?.path);
395+
}
396+
},
397+
);
398+
}
399+
149400
function toFolderLabel(folder = {}) {
150401
const title = normalizePathInput(folder.title || "");
151402
if (title) return title;
@@ -1183,7 +1434,9 @@ export default function AcpPageInclude() {
11831434
}
11841435

11851436
async function loadSelectedSession(entry) {
1186-
const cwd = normalizeSessionCwd(entry.cwd || $form.getValues().cwd || "");
1437+
const cwd = normalizeSessionCwd(
1438+
entry.cwd || $form.getValues().cwd || "/home",
1439+
);
11871440
if (!cwd) {
11881441
setFormStatus("This session is missing a working directory");
11891442
return;

src/pages/acp/components/agentForm.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ export default function AgentForm({
1313
);
1414

1515
const $cwdInput = (
16-
<input type="text" placeholder="e.g. /home/user/project (optional)" />
16+
<input
17+
type="text"
18+
placeholder="e.g. /home/user/project (optional)"
19+
value="/home"
20+
/>
1721
);
1822

1923
const $cwdPickBtn = (

0 commit comments

Comments
 (0)