File System API
The File System API lets extensions read, write, create, delete, rename, move, copy, and list files within the editor. All operations go through the editor.workspace.fs namespace and work transparently with both local file paths (file://) and Android SAF URIs (content://).
Overview
| Method | Description |
|---|---|
workspace.fs.read | Read file content as a string |
workspace.fs.write | Write string content to a file |
workspace.fs.create | Create a new file in a directory |
workspace.fs.delete | Delete a file or directory |
workspace.fs.rename | Rename a file or directory |
workspace.fs.move | Move a file or directory to a different location |
workspace.fs.copy | Copy a file to a different location |
workspace.fs.list | List directory contents with optional filters |
workspace.fs.exists | Check if a file or directory exists |
workspace.fs.createDirectory | Create a new directory |
workspace.fs.zip | Create a ZIP archive of a folder |
workspace.fs.share | Share a file using the system share sheet |
workspace.fs.openExternal | Open a file with the device's default app |
workspace.openFile | Open a file in an editor tab |
workspace.getActiveFile | Get the currently active file/tab |
RPC Methods
workspace.fs.read
Read the contents of a file as a UTF-8 string.
SDK signature:
editor.workspace.fs.read(uri);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | File URI (local file:// path or SAF content:// URI) |
Returns: Promise<string> -- The file content as a string.
RPC method name: workspace.fs.read
RPC params: { uri: string }
Example:
const content = await editor.workspace.fs.read(fileUri);
console.log("File content:", content);
workspace.fs.write
Write string content to a file. If the file exists, its content is overwritten. If it does not exist yet, it is created.
SDK signature:
editor.workspace.fs.write(uri, content);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | File URI to write to |
content | string | Yes | New file content |
Returns: Promise<void>
RPC method name: workspace.fs.write
RPC params: { uri: string, content: string }
Example:
await editor.workspace.fs.write(fileUri, "Hello, world!\n");
workspace.fs.create
Create a new empty file inside a directory. The file name must not contain path separators or parent traversal sequences.
SDK signature:
editor.workspace.fs.create(parentUri, name);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
parentUri | string | Yes | URI of the parent directory |
name | string | Yes | Name for the new file (e.g. "style.css") |
Returns: Promise<string> -- The URI of the newly created file.
RPC method name: workspace.fs.create
RPC params: { parentUri: string, name: string }
Validation rules for name:
- Must not contain
.. - Must not contain
/or\
Example:
const newUri = await editor.workspace.fs.create(folderUri, "index.html");
console.log("Created:", newUri);
// Write initial content into the new file
await editor.workspace.fs.write(newUri, "<!DOCTYPE html>\n<html></html>");
workspace.fs.delete
Delete a file or directory.
SDK signature:
editor.workspace.fs.delete(uri);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the file or directory to delete |
Returns: Promise<void>
RPC method name: workspace.fs.delete
RPC params: { uri: string }
Example:
const confirmed = await editor.window.showConfirm({
title: "Delete File",
message: "Are you sure you want to delete this file?",
});
if (confirmed) {
await editor.workspace.fs.delete(fileUri);
editor.window.showToast("File deleted");
}
workspace.fs.rename
Rename a file or directory. The new name must be a simple file name without path separators.
SDK signature:
editor.workspace.fs.rename(uri, newName);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the file or directory to rename |
newName | string | Yes | New name (e.g. "app.js") |
Returns: Promise<string> -- The URI of the renamed file.
RPC method name: workspace.fs.rename
RPC params: { uri: string, newName: string }
Validation rules for newName:
- Must not contain
.. - Must not contain
/or\
Example:
const newUri = await editor.workspace.fs.rename(fileUri, "main.js");
console.log("Renamed to:", newUri);
workspace.fs.move
Move a file or directory to a different target directory.
SDK signature:
editor.workspace.fs.move(uri, destinationUri);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the file or directory to move |
destinationUri | string | Yes | URI of the target directory |
Returns: Promise<string> -- The URI of the file at its new location.
RPC method name: workspace.fs.move
RPC params: { uri: string, destinationUri: string }
Permission note: Both the source and destination paths are checked independently. If either is outside the current project, the fileSystem permission is required for that path.
Example:
const newUri = await editor.workspace.fs.move(
"/project/src/old/utils.js",
"/project/src/lib/"
);
console.log("Moved to:", newUri);
workspace.fs.copy
Copy a file to a target directory. The copy retains the original file name.
SDK signature:
editor.workspace.fs.copy(uri, destinationUri);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the file to copy |
destinationUri | string | Yes | URI of the target directory |
Returns: Promise<string> -- The URI of the copied file (constructed as destinationUri + "/" + originalFileName).
RPC method name: workspace.fs.copy
RPC params: { uri: string, destinationUri: string }
Permission note: Both the source and destination paths are checked independently, same as move.
Example:
const copiedUri = await editor.workspace.fs.copy(
"/project/config/base.json",
"/project/config/env/"
);
console.log("Copied to:", copiedUri);
workspace.fs.list
List the contents of a directory. Supports optional filtering by MIME type, file extension, name substring, and recursive traversal.
SDK signature:
editor.workspace.fs.list(uri, options?)
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the directory to list |
options | object | No | Filter and traversal options (see below) |
Options object:
| Name | Type | Default | Description |
|---|---|---|---|
mimeTypes | string[] | [] | Only include files matching these MIME types (e.g. ["text/javascript"]) |
extensions | string[] | [] | Only include files with these extensions (e.g. [".js", ".ts"]) |
nameContains | string | null | Only include entries whose name contains this substring |
excludeDirs | string[] | [] | Directory names to exclude from results (e.g. ["node_modules", ".git"]) |
recursive | boolean | false | If true, list all entries recursively through subdirectories |
Returns: Promise<Array<FileEntry>> where each FileEntry has:
| Property | Type | Description |
|---|---|---|
uri | string | URI of the file or directory |
name | string | Display name |
isDirectory | boolean | true if the entry is a directory |
size | number | File size in bytes (0 for directories) |
RPC method name: workspace.fs.list
RPC params: { uri: string, mimeTypes?: string[], extensions?: string[], nameContains?: string, excludeDirs?: string[], recursive?: boolean }
Example -- basic listing:
const entries = await editor.workspace.fs.list(folderUri);
entries.forEach((entry) => {
const type = entry.isDirectory ? "DIR " : "FILE";
console.log(`${type} ${entry.name} (${entry.size} bytes)`);
});
Example -- filtered and recursive:
const jsFiles = await editor.workspace.fs.list(projectUri, {
extensions: [".js", ".mjs"],
excludeDirs: ["node_modules", ".git", "dist"],
recursive: true,
});
console.log(`Found ${jsFiles.length} JavaScript files`);
workspace.fs.exists
Check whether a file or directory exists at the given URI.
SDK signature:
editor.workspace.fs.exists(uri);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | File or directory URI to check |
Returns: Promise<boolean> -- true if the file or directory exists, false otherwise.
RPC method name: workspace.fs.exists
RPC params: { uri: string }
Example:
const configExists = await editor.workspace.fs.exists(configUri);
if (!configExists) {
// Create default config
const uri = await editor.workspace.fs.create(projectRoot, "config.json");
await editor.workspace.fs.write(uri, JSON.stringify({ version: 1 }));
}
workspace.fs.createDirectory
Create a new empty directory inside a parent directory.
SDK signature:
editor.workspace.fs.createDirectory(parentUri, name);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
parentUri | string | Yes | URI of the parent directory |
name | string | Yes | Name for the new directory (e.g. "src") |
Returns: Promise<string> -- The URI of the newly created directory.
RPC method name: workspace.fs.createDirectory
RPC params: { parentUri: string, name: string }
Validation rules for name:
- Must not contain
.. - Must not contain
/or\
Example:
const srcUri = await editor.workspace.fs.createDirectory(projectRoot, "src");
const libUri = await editor.workspace.fs.createDirectory(srcUri, "lib");
// Create a file inside the new directory
const fileUri = await editor.workspace.fs.create(libUri, "index.js");
await editor.workspace.fs.write(fileUri, 'export default {};');
workspace.fs.zip
Create a ZIP archive from a folder. The ZIP is saved as a new file.
SDK signature:
editor.workspace.fs.zip(uri, options?)
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the folder to zip |
options | object | No | ZIP options (see below) |
Options object:
| Name | Type | Default | Description |
|---|---|---|---|
destinationUri | string | Parent of folder | URI of the directory where the ZIP file will be saved |
name | string | <folderName>.zip | Name for the ZIP file |
excludeDirs | string[] | [] | Directory names to skip (e.g. ["node_modules", ".git"]) |
Returns: Promise<string> -- The URI of the created ZIP file.
RPC method name: workspace.fs.zip
RPC params: { uri: string, destinationUri?: string, name?: string, excludeDirs?: string[] }
Example:
// Zip the current project's src folder
const zipUri = await editor.workspace.fs.zip(srcFolderUri, {
excludeDirs: ["test"],
name: "source-backup.zip",
});
editor.window.showToast("ZIP created: " + zipUri);
workspace.fs.share
Share a file using the device's system share sheet. Only files are supported (not directories).
SDK signature:
editor.workspace.fs.share(uri, options?)
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the file to share |
options | object | No | Share options (see below) |
Options object:
| Name | Type | Default | Description |
|---|---|---|---|
title | string | null | Title for the share dialog (shown on Android Intent chooser; may not appear on all devices) |
Returns: Promise<boolean> -- true if the file was shared successfully.
RPC method name: workspace.fs.share
RPC params: { uri: string, title?: string }
Example:
const shared = await editor.workspace.fs.share(fileUri, {
title: "Share source file",
});
if (shared) {
editor.window.showToast("File shared!");
}
workspace.fs.openExternal
Open a file with the device's default application (e.g. an image viewer for .png, a PDF reader for .pdf). Only files are supported (not directories).
SDK signature:
editor.workspace.fs.openExternal(uri, options?)
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the file to open |
options | object | No | Open options (see below) |
Options object:
| Name | Type | Default | Description |
|---|---|---|---|
title | string | null | Title for the app chooser dialog (shown on Android Intent chooser; may not appear on all devices) |
Returns: Promise<boolean> -- true if the file was opened successfully.
RPC method name: workspace.fs.openExternal
RPC params: { uri: string, title?: string }
Example:
const opened = await editor.workspace.fs.openExternal(imageUri);
if (!opened) {
editor.window.showToast("No app found to open this file");
}
Related Workspace Methods
workspace.openFile
Open a file in the editor. If the file is already open in a tab, the editor switches to that tab. If the editor screen is not active, the app navigates to it.
SDK signature:
editor.workspace.openFile(uri);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | URI of the file to open |
Returns: Promise<void>
RPC method name: workspace.openFile
RPC params: { uri: string }
Example:
// Create a file and open it in the editor
const newUri = await editor.workspace.fs.create(srcFolder, "app.js");
await editor.workspace.fs.write(newUri, 'console.log("Hello!");');
await editor.workspace.openFile(newUri);
workspace.getActiveFile
Get information about the currently active tab.
SDK signature:
editor.workspace.getActiveFile();
Parameters: None.
Returns: Promise<ActiveFile | null> -- Returns null if no tab is active.
| Property | Type | Description |
|---|---|---|
key | string | Unique tab key |
name | string | Display name of the tab |
uri | string? | File URI (present only for file-type tabs) |
type | string? | Tab type: "file", "terminal", "webview", "info", or "untracked" |
RPC method name: workspace.getActiveFile
Example:
const active = await editor.workspace.getActiveFile();
if (active && active.uri) {
const content = await editor.workspace.fs.read(active.uri);
console.log(`${active.name} has ${content.length} characters`);
}
workspace.getEngineVersion
Get the current extension engine version string.
SDK signature:
editor.workspace.getEngineVersion();
Parameters: None.
Returns: Promise<string> -- The engine version string (e.g. "0.1.0").
RPC method name: workspace.getEngineVersion
Example:
const version = await editor.workspace.getEngineVersion();
console.log("Engine version:", version);
workspace.getAppVersion
Get the app version string.
SDK signature:
editor.workspace.getAppVersion();
Parameters: None.
Returns: Promise<string> -- The app version string (e.g. "1.2.3").
RPC method name: workspace.getAppVersion
workspace.project.create
Create a new project with optional initial files. Requires the projectCreate permission in your manifest.
SDK signature:
editor.workspace.project.create(options);
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Project name. |
parentUri | string | No | URI of the parent directory where the project will be created. |
files | Array<{ path: string, content: string }> | No | Initial files to create inside the project. |
Returns: Promise<{ id: string, name: string, uri: string }> -- The created project's ID, name, and URI.
RPC method name: workspace.project.create
Example:
const project = await editor.workspace.project.create({
name: "my-app",
files: [
{ path: "index.html", content: "<!DOCTYPE html>\n<html></html>" },
{ path: "style.css", content: "body { margin: 0; }" },
],
});
await editor.window.showToast(`Created project: ${project.name}`);
Permission Model
In-project access (no permission required)
Files and directories located within the currently open project can be accessed freely. The editor determines whether a path is within the project by normalizing both the file path and the project root, resolving symlinks, and checking that the file path starts with the project root prefix.
Out-of-project access (requires fileSystem permission)
To access files outside the current project directory, the extension must declare the fileSystem permission in its manifest.json:
{
"id": "my-extension",
"name": "My Extension",
"version": "1.0.0",
"permissions": ["fileSystem"]
}
When an extension attempts to access a path outside the project, the editor checks for an existing permission grant. If no grant exists, the user is prompted with a permission dialog.
Grant scopes
Users can choose from three grant scopes when approving access:
| Scope | Duration | Storage |
|---|---|---|
| One-time | Single operation | Not stored |
| Session | Until app restart | In-memory only |
| Permanent | Survives restarts | Persisted to database |
Granular directory grants
Instead of granting blanket file system access, users can restrict approval to a specific directory. The grant then covers that directory and all of its descendants. For example, granting access to /home/user/projects/ allows the extension to access any path under that directory.
Methods that check both source and destination
workspace.fs.move and workspace.fs.copy validate permissions on both the source URI and the target URI independently. If the source is inside the project but the target is outside, the extension needs a grant for the target path (and vice versa).
Security Considerations
Path traversal prevention
All file URIs that resolve to local file paths are normalized. If the normalized path differs from the original, the request is rejected with a Path traversal not allowed error. This prevents attacks using .. segments to escape allowed directories.
For create and rename, the file name parameter is additionally validated to reject names containing .., /, or \.
SAF URI handling
Android Storage Access Framework (SAF) URIs (content://) bypass the local path security checks because they are governed by Android's own permission model. SAF URIs are always considered "within project" for the purpose of the permission gate.
RPC timeout
File system operations have a 90-second RPC timeout (longer than the default 30-second timeout for most RPC calls) to accommodate permission dialogs that require user interaction.
Rate limiting
Each extension is limited to 50 concurrent RPC calls. If an extension exceeds this limit, subsequent calls are rejected until in-flight calls complete.
Error responses
When a permission is denied (either because the extension lacks the fileSystem permission in its manifest, or the user declines the prompt), the RPC response contains the error string PERMISSION_DENIED: fileSystem. Extensions should handle this gracefully:
try {
const content = await editor.workspace.fs.read(externalPath);
} catch (err) {
if (err.message && err.message.includes("PERMISSION_DENIED")) {
editor.window.showToast("File access was denied by the user");
} else {
editor.window.showToast("Failed to read file: " + err.message);
}
}
File Events
The editor emits events when files are created, deleted, renamed, or moved. These events fire regardless of whether the change was made by your extension, another extension, or the user directly.
editor.events.onFileCreated((data) => {
console.log("Created:", data.name, data.uri, "in", data.parentUri);
});
editor.events.onFileDeleted((data) => {
console.log("Deleted:", data.name, data.uri);
});
editor.events.onFileRenamed((data) => {
console.log(
"Renamed:",
data.oldUri,
"->",
data.newUri,
"new name:",
data.newName
);
});
editor.events.onFileMoved((data) => {
console.log(
"Moved:",
data.oldUri,
"->",
data.newUri,
"target dir:",
data.targetUri
);
});
editor.events.onFileSave((data) => {
console.log("Saved:", data.name, data.uri);
});
All event listeners return an unsubscribe function:
const unsub = editor.events.onFileSave((data) => {
console.log("Saved:", data.name);
});
// Later, when no longer needed:
unsub();
Complete Example
The following example demonstrates a command that finds all TODO comments across JavaScript files in the current project and writes a summary report.
import editor from "@srcnexus/ext-sdk";
editor.onLoad(() => {
editor.commands.registerCommand(
"todo-finder.scan",
async () => {
// Get the project root directory
const projectRoot = await editor.workspace.getProjectRoot();
if (!projectRoot) {
editor.window.showToast("No project is open");
return;
}
// Recursively find all .js files, excluding node_modules
const jsFiles = await editor.workspace.fs.list(projectRoot, {
extensions: [".js"],
excludeDirs: ["node_modules", ".git", "dist"],
recursive: true,
});
const todos = [];
for (const file of jsFiles) {
if (file.isDirectory) continue;
const content = await editor.workspace.fs.read(file.uri);
const lines = content.split("\n");
lines.forEach((line, index) => {
if (line.includes("TODO")) {
todos.push({
file: file.name,
line: index + 1,
text: line.trim(),
});
}
});
}
// Build a report
let report = `# TODO Report\n\nFound ${todos.length} TODOs:\n\n`;
todos.forEach((todo) => {
report += `- **${todo.file}:${todo.line}** ${todo.text}\n`;
});
// Write the report to a file in the project root
const reportUri = await editor.workspace.fs.create(
projectRoot,
"TODO-REPORT.md"
);
await editor.workspace.fs.write(reportUri, report);
await editor.workspace.openFile(reportUri);
editor.window.showToast(`Found ${todos.length} TODOs`);
},
{ description: "Scan project for TODO comments" }
);
});