Skip to main content

ADR-018: Local Filesystem Integration

Status: Accepted Date: 2025-10-06 Deciders: Development Team, Architecture Team Related: ADR-017 (WebSocket), ADR-006 (OPFS), ADR-004 (FoundationDB)


Context

The AZ1.AI llm IDE needs access to the user's local filesystem for:

  • Project files (read, write, delete, rename)
  • File watching (detect external changes)
  • Directory operations (create, delete, move)
  • Large file handling (GB+ files, streaming)
  • Multi-session isolation (session-specific workspaces)
  • Offline capability (work without backend connection)

Current State

  • Eclipse theia has built-in filesystem provider (abstract)
  • No implementation for browser-based local filesystem
  • OPFS (Origin Private File System) available in modern browsers
  • Native File System Access API for Chrome/Edge
  • No integration with backend filesystem

Requirements

  1. Local Access: Read/write files on user's local machine
  2. Browser Storage: Cache files in OPFS for offline work
  3. Synchronization: Sync between local FS and browser storage
  4. Performance: Efficient for large files, minimal latency
  5. Security: Sandboxed, user permission-based
  6. Cross-Platform: Works on Windows, macOS, Linux

Decision

We will implement a hybrid filesystem architecture with multiple storage backends:

Architecture

┌─────────────────────────────────────────────────────────────┐
│ Browser (theia Frontend) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Unified Filesystem Service │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ File System Provider (theia API) │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────┼───────────────┐ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌──────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ OPFS │ │File System │ │ Backend │ │ │
│ │ │ Provider │ │Access API │ │ HTTP │ │ │
│ │ │(Fallback)│ │(Preferred) │ │(WebSocket) │ │ │
│ │ └──────────┘ └─────────────┘ └────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

│ WebSocket (filesystem/ methods)

┌─────────────────────────────────────────────────────────────┐
│ Backend (Node.js) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Filesystem Service │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ Node.js fs/promises │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Local Filesystem (OS) │ │
│ │ /workspace/user-projects/ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘


┌─────────────┐
│FoundationDB │
│(File Metadata)│
└─────────────┘

Storage Hierarchy

Tier 1: File System Access API (Chrome/Edge, preferred)

  • Direct access to user's local filesystem
  • Full read/write permissions with user consent
  • Handles files of any size
  • Survives browser restart

Tier 2: Backend WebSocket (all browsers)

  • Backend has access to local filesystem
  • Files transferred via WebSocket
  • Chunked transfer for large files
  • Session-specific workspaces

Tier 3: OPFS (modern browsers, fallback)

  • Browser-native file storage
  • Isolated from OS filesystem
  • Good performance (up to several GB)
  • Offline-capable

Tier 4: FoundationDB (metadata only)

  • File metadata (path, size, modified time)
  • File relationships (imports, dependencies)
  • Session → workspace mapping
  • Search index

Implementation

1. Unified Filesystem Service

// src/browser/services/filesystem-service.ts

import { injectable, inject } from '@theia/core/shared/inversify';
import { FileSystemProvider, FileChangeEvent } from '@theia/filesystem/lib/common/files';

export enum FilesystemBackend {
FileSystemAccessAPI = 'fs-access-api',
BackendHTTP = 'backend-http',
OPFS = 'opfs',
}

@injectable()
export class UnifiedFilesystemService implements FileSystemProvider {
private backend: FilesystemBackend;
private fileHandleCache = new Map<string, FileSystemFileHandle>();

@inject(WebSocketClient)
protected readonly ws!: WebSocketClient;

async initialize(): Promise<void> {
// Detect best available backend
if (await this.isFileSystemAccessAPIAvailable()) {
this.backend = FilesystemBackend.FileSystemAccessAPI;
console.log('Using File System Access API');
} else if (this.ws.isConnected()) {
this.backend = FilesystemBackend.BackendHTTP;
console.log('Using Backend HTTP filesystem');
} else if (await this.isOPFSAvailable()) {
this.backend = FilesystemBackend.OPFS;
console.log('Using OPFS filesystem');
} else {
throw new Error('No filesystem backend available');
}
}

// theia FileSystemProvider implementation
async readFile(uri: URI): Promise<Uint8Array> {
switch (this.backend) {
case FilesystemBackend.FileSystemAccessAPI:
return this.readFileFromFSAccessAPI(uri);
case FilesystemBackend.BackendHTTP:
return this.readFileFromBackend(uri);
case FilesystemBackend.OPFS:
return this.readFileFromOPFS(uri);
}
}

async writeFile(uri: URI, content: Uint8Array): Promise<void> {
switch (this.backend) {
case FilesystemBackend.FileSystemAccessAPI:
await this.writeFileToFSAccessAPI(uri, content);
break;
case FilesystemBackend.BackendHTTP:
await this.writeFileToBackend(uri, content);
break;
case FilesystemBackend.OPFS:
await this.writeFileToOPFS(uri, content);
break;
}

// Always sync metadata to FoundationDB
await this.syncMetadataToFDB(uri, content);
}

async delete(uri: URI): Promise<void> {
switch (this.backend) {
case FilesystemBackend.FileSystemAccessAPI:
await this.deleteFromFSAccessAPI(uri);
break;
case FilesystemBackend.BackendHTTP:
await this.deleteFromBackend(uri);
break;
case FilesystemBackend.OPFS:
await this.deleteFromOPFS(uri);
break;
}

await this.deleteMetadataFromFDB(uri);
}

async readdir(uri: URI): Promise<[string, FileType][]> {
switch (this.backend) {
case FilesystemBackend.FileSystemAccessAPI:
return this.readdirFromFSAccessAPI(uri);
case FilesystemBackend.BackendHTTP:
return this.readdirFromBackend(uri);
case FilesystemBackend.OPFS:
return this.readdirFromOPFS(uri);
}
}

async watch(uri: URI): Promise<Disposable> {
// File watching implementation
switch (this.backend) {
case FilesystemBackend.FileSystemAccessAPI:
return this.watchFSAccessAPI(uri);
case FilesystemBackend.BackendHTTP:
return this.watchViaBackend(uri);
case FilesystemBackend.OPFS:
return this.watchOPFS(uri);
}
}

// File System Access API implementation
private async isFileSystemAccessAPIAvailable(): Promise<boolean> {
return 'showDirectoryPicker' in window;
}

private async readFileFromFSAccessAPI(uri: URI): Promise<Uint8Array> {
const fileHandle = await this.getFileHandle(uri.path);
const file = await fileHandle.getFile();
const arrayBuffer = await file.arrayBuffer();
return new Uint8Array(arrayBuffer);
}

private async writeFileToFSAccessAPI(uri: URI, content: Uint8Array): Promise<void> {
const fileHandle = await this.getFileHandle(uri.path, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
}

private async getFileHandle(path: string, options?: { create?: boolean }): Promise<FileSystemFileHandle> {
if (this.fileHandleCache.has(path)) {
return this.fileHandleCache.get(path)!;
}

// Request directory access if not already granted
const dirHandle = await window.showDirectoryPicker();
const pathParts = path.split('/').filter(p => p);

let currentHandle: FileSystemDirectoryHandle | FileSystemFileHandle = dirHandle;

for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i];
const isLastPart = i === pathParts.length - 1;

if (isLastPart) {
currentHandle = await (currentHandle as FileSystemDirectoryHandle).getFileHandle(part, options);
} else {
currentHandle = await (currentHandle as FileSystemDirectoryHandle).getDirectoryHandle(part, { create: true });
}
}

const fileHandle = currentHandle as FileSystemFileHandle;
this.fileHandleCache.set(path, fileHandle);
return fileHandle;
}

// Backend HTTP implementation
private async readFileFromBackend(uri: URI): Promise<Uint8Array> {
const result = await this.ws.request('filesystem/read', {
path: uri.path,
encoding: 'base64'
});

return this.base64ToUint8Array(result.content);
}

private async writeFileToBackend(uri: URI, content: Uint8Array): Promise<void> {
const base64 = this.uint8ArrayToBase64(content);

await this.ws.request('filesystem/write', {
path: uri.path,
content: base64,
encoding: 'base64'
});
}

private async deleteFromBackend(uri: URI): Promise<void> {
await this.ws.request('filesystem/delete', { path: uri.path });
}

private async readdirFromBackend(uri: URI): Promise<[string, FileType][]> {
const result = await this.ws.request('filesystem/readdir', { path: uri.path });
return result.entries;
}

private async watchViaBackend(uri: URI): Promise<Disposable> {
await this.ws.request('filesystem/watch', { path: uri.path });

const listener = this.ws.onMessage((message) => {
if (message.method === 'filesystem/changed' && message.params.path === uri.path) {
this.fireFileChangeEvent({
type: message.params.changeType,
resource: uri
});
}
});

return {
dispose: () => {
listener.dispose();
this.ws.notify('filesystem/unwatch', { path: uri.path });
}
};
}

// OPFS implementation
private async isOPFSAvailable(): Promise<boolean> {
return 'storage' in navigator && 'getDirectory' in navigator.storage;
}

private async readFileFromOPFS(uri: URI): Promise<Uint8Array> {
const root = await navigator.storage.getDirectory();
const fileHandle = await this.getOPFSFileHandle(root, uri.path);
const file = await fileHandle.getFile();
const arrayBuffer = await file.arrayBuffer();
return new Uint8Array(arrayBuffer);
}

private async writeFileToOPFS(uri: URI, content: Uint8Array): Promise<void> {
const root = await navigator.storage.getDirectory();
const fileHandle = await this.getOPFSFileHandle(root, uri.path, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
}

private async deleteFromOPFS(uri: URI): Promise<void> {
const root = await navigator.storage.getDirectory();
const pathParts = uri.path.split('/').filter(p => p);
const fileName = pathParts.pop()!;

let dirHandle = root;
for (const part of pathParts) {
dirHandle = await dirHandle.getDirectoryHandle(part);
}

await dirHandle.removeEntry(fileName);
}

private async getOPFSFileHandle(
root: FileSystemDirectoryHandle,
path: string,
options?: { create?: boolean }
): Promise<FileSystemFileHandle> {
const pathParts = path.split('/').filter(p => p);
const fileName = pathParts.pop()!;

let dirHandle = root;
for (const part of pathParts) {
dirHandle = await dirHandle.getDirectoryHandle(part, { create: options?.create });
}

return dirHandle.getFileHandle(fileName, options);
}

// FoundationDB metadata sync
private async syncMetadataToFDB(uri: URI, content: Uint8Array): Promise<void> {
await this.fdbService.set(`file:${uri.path}`, {
path: uri.path,
size: content.length,
modified: Date.now(),
checksum: await this.calculateChecksum(content)
});
}

private async deleteMetadataFromFDB(uri: URI): Promise<void> {
await this.fdbService.delete(`file:${uri.path}`);
}

// Utility methods
private base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

private uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}

private async calculateChecksum(content: Uint8Array): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', content);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
}

2. Backend Filesystem Service

// src/backend/services/filesystem-service.ts

import { promises as fs } from 'fs';
import path from 'path';
import chokidar from 'chokidar';

export class BackendFilesystemService {
private workspaceRoot: string;
private watchers = new Map<string, chokidar.FSWatcher>();

constructor(workspaceRoot: string) {
this.workspaceRoot = workspaceRoot;
}

async readFile(filePath: string): Promise<Buffer> {
const fullPath = this.resolvePath(filePath);
return fs.readFile(fullPath);
}

async writeFile(filePath: string, content: Buffer): Promise<void> {
const fullPath = this.resolvePath(filePath);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, content);
}

async deleteFile(filePath: string): Promise<void> {
const fullPath = this.resolvePath(filePath);
await fs.unlink(fullPath);
}

async readDirectory(dirPath: string): Promise<Array<[string, 'file' | 'directory']>> {
const fullPath = this.resolvePath(dirPath);
const entries = await fs.readdir(fullPath, { withFileTypes: true });

return entries.map(entry => [
entry.name,
entry.isDirectory() ? 'directory' : 'file'
]);
}

watchFile(filePath: string, callback: (event: string) => void): () => void {
const fullPath = this.resolvePath(filePath);

const watcher = chokidar.watch(fullPath, {
persistent: true,
ignoreInitial: true
});

watcher
.on('change', () => callback('changed'))
.on('unlink', () => callback('deleted'));

this.watchers.set(filePath, watcher);

return () => {
watcher.close();
this.watchers.delete(filePath);
};
}

unwatchFile(filePath: string): void {
const watcher = this.watchers.get(filePath);
if (watcher) {
watcher.close();
this.watchers.delete(filePath);
}
}

private resolvePath(filePath: string): string {
// Security: prevent path traversal
const resolved = path.resolve(this.workspaceRoot, filePath);
if (!resolved.startsWith(this.workspaceRoot)) {
throw new Error('Invalid path: path traversal not allowed');
}
return resolved;
}
}

3. Session-Specific workspaces

// src/browser/services/workspace-service.ts

@injectable()
export class workspaceService {
@inject(UnifiedFilesystemService)
protected readonly fs!: UnifiedFilesystemService;

@inject(FDBService)
protected readonly fdb!: FDBService;

async createSessionworkspace(sessionId: string): Promise<string> {
const workspacePath = `/sessions/${sessionId}`;

// Create workspace directory
await this.fs.mkdir(workspacePath);

// Save session → workspace mapping in FDB
await this.fdb.set(`session:${sessionId}:workspace`, workspacePath);

return workspacePath;
}

async getSessionworkspace(sessionId: string): Promise<string | null> {
return this.fdb.get(`session:${sessionId}:workspace`);
}

async deleteSessionworkspace(sessionId: string): Promise<void> {
const workspacePath = await this.getSessionworkspace(sessionId);
if (workspacePath) {
await this.fs.delete(workspacePath);
await this.fdb.delete(`session:${sessionId}:workspace`);
}
}
}

Rationale

Why Hybrid Architecture?

Browser Capability Variation:

  • ✅ File System Access API: Best UX, but Chrome/Edge only
  • ✅ Backend WebSocket: Works everywhere, but requires backend
  • ✅ OPFS: Good fallback, offline-capable, but isolated

Graceful Degradation:

  • ✅ Try best option first (FS Access API)
  • ✅ Fall back to backend
  • ✅ Use OPFS for offline work

Why FoundationDB for Metadata?

Performance:

  • ✅ Fast lookups (file search, imports)
  • ✅ Transaction support (atomic operations)
  • ✅ Distributed (scales horizontally)

Features:

  • ✅ Secondary indexes (search by name, date, size)
  • ✅ Relationships (file dependencies)
  • ✅ Session isolation (per-session metadata)

Why Not Store Full Files in FDB?

Size Limitations:

  • ❌ FDB values limited to 100KB
  • ❌ Large files (MB+) would require chunking
  • ❌ High storage cost vs filesystem

Better Alternatives:

  • ✅ Filesystem optimized for large files
  • ✅ OS-level caching
  • ✅ FDB only for metadata

Alternatives Considered

Alternative 1: OPFS Only

Pros:

  • Simple (single backend)
  • Offline-capable
  • No permissions needed

Cons:

  • ❌ Isolated from OS filesystem (can't open local files)
  • ❌ Limited to browser storage quota
  • ❌ No integration with local tools

Rejected: Too limited for IDE use case

Alternative 2: Backend Only

Pros:

  • Full filesystem access
  • Works everywhere
  • No browser limitations

Cons:

  • ❌ Requires backend connection
  • ❌ No offline work
  • ❌ Higher latency

Rejected: Poor offline UX

Alternative 3: IndexedDB

Pros:

  • Wide browser support
  • Good for structured data

Cons:

  • ❌ Not designed for files
  • ❌ Poor performance for large files
  • ❌ Complex API

Rejected: OPFS is better for files


Consequences

Positive

Best UX: Use native FS API when available ✅ Offline Capable: OPFS fallback for offline work ✅ Universal: Backend works in all browsers ✅ Performance: Metadata in FDB, files in FS ✅ Secure: Sandboxed, permission-based ✅ Session Isolation: Per-session workspaces

Negative

Complexity: 3 different backends to maintain ❌ Sync Issues: Must keep FDB metadata in sync ❌ Permission UX: Users must grant FS access ❌ Browser Support: FS Access API limited to Chrome/Edge

Mitigation

Complexity:

  • Abstract behind unified interface
  • Comprehensive tests for each backend
  • Document backend selection logic

Sync Issues:

  • Use checksums to detect drift
  • Background sync job
  • Conflict resolution UI

Permission UX:

  • Clear onboarding flow
  • Explain why access needed
  • Remember permissions

Browser Support:

  • Feature detection
  • Graceful fallback
  • Document browser requirements

Implementation Plan

Phase 1: Core Infrastructure ✅

  • Unified filesystem service interface
  • Backend selection logic
  • FoundationDB metadata schema

Phase 2: File System Access API 🔲

  • Request directory access
  • File handle caching
  • Read/write implementation
  • Directory listing

Phase 3: Backend Integration 🔲

  • WebSocket filesystem methods
  • Chunked file transfer
  • File watching via chokidar
  • Security (path traversal prevention)

Phase 4: OPFS Fallback 🔲

  • OPFS detection
  • Read/write implementation
  • Directory operations
  • Quota management

Phase 5: Metadata Sync 🔲

  • FDB schema for file metadata
  • Sync on read/write
  • Background sync job
  • Conflict resolution

Phase 6: Session workspaces 🔲

  • Session → workspace mapping
  • workspace creation/deletion
  • Isolation enforcement
  • workspace switching

Success Metrics

Performance:

  • < 50ms read time for files < 1MB
  • < 200ms read time for files < 10MB
  • Streaming for files > 10MB

Reliability:

  • 99.9% metadata sync accuracy
  • < 1% file corruption rate
  • Zero data loss on crashes

User Experience:

  • < 5s permission request flow
  • Clear error messages
  • Automatic backend selection


References

File System Access API:

OPFS:

Best Practices:


Status: ✅ Accepted Next Review: 2025-11-06 (1 month) Last Updated: 2025-10-06