Skip to main content

Virtual Filesystem (VFS)

The Virtual Filesystem provides a POSIX-like file and directory abstraction in browser memory. It enables applications to work with files and folders using familiar Unix-style paths and operations.

Architecture

Core Data Structures

export interface VFSMetadata {
  size: number;
  mtime: string; // ISO string
  owner: string;
  permissions: string;
}

export interface VFSFile {
  type: 'file';
  content: string;
  metadata?: VFSMetadata;
}

export interface VFSFolder {
  type: 'folder';
  children: Record<string, VFSNode>;
  metadata?: VFSMetadata;
}

export type VFSNode = VFSFile | VFSFolder;

VFS Interface

export interface IVFS {
  init(): void;
  resolvePath(cwd: string, path: string): string;
  getNode(path: string): VFSNode | null;
  getChildren(path: string): Record<string, VFSNode> | null;
  touch(path: string, name: string): Promise<void>;
  mkdir(path: string, name: string): Promise<void>;
  rm(path: string, name: string): Promise<boolean>;
  rename(path: string, oldName: string, newName: string): Promise<void>;
  move(oldPath: string, newPath: string): Promise<void>;
  copy(sourcePath: string, destPath: string): Promise<void>;
  writeFile(path: string, content: string): Promise<void>;
  moveToTrash(path: string): Promise<void>;
  restoreFromTrash(name: string): Promise<void>;
  search(basePath: string, query: string, recursive?: boolean): Promise<string[]>;
  getSize(path: string): number;
  exists(path: string): boolean;
}

Initialization

Root Structure

VFS creates a Unix-like root structure on initialization:
export const VFS: IVFS = {
  init(): void {
    const rootPath = '/';
    const homePath = CONFIG.FS.HOME;

    // Build root structure
    const root: VFSFolder = {
      type: 'folder',
      children: {
        bin: { type: 'folder', children: {} },
        etc: {
          type: 'folder',
          children: {
            hostname: { 
              type: 'file', 
              content: 'Debian-CDE' 
            },
            motd: { 
              type: 'file', 
              content: 'Welcome to Debian CDE Workstation' 
            },
            'os-release': {
              type: 'file',
              content: 'PRETTY_NAME="Debian GNU/Linux CDE Edition"\n' +
                       'NAME="Debian GNU/Linux"\nID=debian',
            },
            passwd: {
              type: 'file',
              content: 'root:x:0:0:root:/root:/bin/bash\n' +
                       'victx:x:1000:1000:victx:/home/victxrlarixs:/bin/bash',
            },
          },
        },
        usr: {
          type: 'folder',
          children: {
            bin: { type: 'folder', children: {} },
            lib: { type: 'folder', children: {} },
            src: { type: 'folder', children: {} },
          },
        },
        var: { type: 'folder', children: {} },
        tmp: { type: 'folder', children: {} },
        home: {
          type: 'folder',
          children: {
            victxrlarixs: (filesystemData as any)[homePath],
          },
        },
      },
    };

    rootNode = root;
    flatten(rootPath, rootNode);

    syncDynamicContent(); // Non-blocking
    logger.log(
      '[VFS] Initialized with System Root, entries:', 
      Object.keys(fsMap).length
    );
  }
};

Filesystem Flattening

The nested structure is flattened into a Map for O(1) lookups:
const fsMap: Record<string, VFSNode> = {};

function flatten(basePath: string, node: VFSNode): void {
  // Ensure metadata exists
  if (!node.metadata) {
    node.metadata = {
      size: node.type === 'file' ? node.content.length : 0,
      mtime: new Date().toISOString(),
      owner: 'victx',
      permissions: node.type === 'folder' ? 
        'rwxr-xr-x' : 'rw-r--r--',
    };
  }

  // Add to flat map
  fsMap[basePath] = node;
  
  // Recursively flatten children
  if (node.type === 'folder') {
    for (const [name, child] of Object.entries(node.children)) {
      const fullPath = basePath + name + 
        (child.type === 'folder' ? '/' : '');
      flatten(fullPath, child);
    }
  }
}
This provides O(1) path resolution instead of tree traversal:
// O(1) lookup
getNode(path: string): VFSNode | null {
  return fsMap[path] || null;
}

// vs O(n) tree traversal
function findNode(path: string): VFSNode | null {
  const parts = path.split('/');
  let current = rootNode;
  for (const part of parts) {
    if (!current.children[part]) return null;
    current = current.children[part];
  }
  return current;
}

Path Resolution

Unix-Style Path Handling

resolvePath(cwd: string, path: string): string {
  // Handle tilde expansion
  if (path.startsWith('~')) {
    path = CONFIG.FS.HOME + path.slice(1);
  }
  
  // Convert relative to absolute
  if (!path.startsWith('/')) {
    path = cwd + (cwd.endsWith('/') ? '' : '/') + path;
  }

  const parts = path.split('/').filter(Boolean);
  const resolved: string[] = [];

  for (const part of parts) {
    if (part === '.') continue;      // Current directory
    if (part === '..') {             // Parent directory
      resolved.pop();
      continue;
    }
    resolved.push(part);
  }

  return '/' + resolved.join('/') + 
    (path.endsWith('/') && resolved.length > 0 ? '/' : '');
}

File Operations

Create File (touch)

async touch(path: string, name: string): Promise<void> {
  const dirPath = path.endsWith('/') ? path : path + '/';
  const node = this.getNode(dirPath);
  
  if (node?.type === 'folder') {
    const newFile: VFSFile = {
      type: 'file',
      content: '',
      metadata: {
        size: 0,
        mtime: new Date().toISOString(),
        owner: 'victx',
        permissions: 'rw-r--r--',
      },
    };
    
    node.children[name] = newFile;
    fsMap[dirPath + name] = newFile;

    // Update folder mtime
    if (node.metadata) {
      node.metadata.mtime = new Date().toISOString();
    }

    logger.log(`[VFS] touch: ${dirPath}${name}`);
    dispatchChange(dirPath);
  }
}

Create Directory (mkdir)

async mkdir(path: string, name: string): Promise<void> {
  const dirPath = path.endsWith('/') ? path : path + '/';
  const node = this.getNode(dirPath);
  
  if (node?.type === 'folder') {
    const newFolder: VFSFolder = {
      type: 'folder',
      children: {},
      metadata: {
        size: 0,
        mtime: new Date().toISOString(),
        owner: 'victx',
        permissions: 'rwxr-xr-x',
      },
    };
    
    node.children[name] = newFolder;
    fsMap[dirPath + name + '/'] = newFolder;

    // Update parent mtime
    if (node.metadata) {
      node.metadata.mtime = new Date().toISOString();
    }

    logger.log(`[VFS] mkdir: ${dirPath}${name}/`);
    dispatchChange(dirPath);
  }
}

Remove (rm)

async rm(path: string, name: string): Promise<boolean> {
  const dirPath = path.endsWith('/') ? path : path + '/';
  const node = this.getNode(dirPath);
  
  if (node?.type === 'folder' && node.children[name]) {
    const item = node.children[name];
    const fullPath = dirPath + name + 
      (item.type === 'folder' ? '/' : '');
    
    delete fsMap[fullPath];
    delete node.children[name];
    
    logger.log(`[VFS] rm: ${fullPath}`);
    dispatchChange(dirPath);
    return true;
  }
  return false;
}

Rename

async rename(
  path: string, 
  oldName: string, 
  newName: string
): Promise<void> {
  const dirPath = path.endsWith('/') ? path : path + '/';
  const node = this.getNode(dirPath);
  
  if (node?.type === 'folder' && node.children[oldName]) {
    const item = node.children[oldName];
    const oldPath = dirPath + oldName + 
      (item.type === 'folder' ? '/' : '');
    const newPath = dirPath + newName + 
      (item.type === 'folder' ? '/' : '');

    node.children[newName] = item;
    delete node.children[oldName];

    fsMap[newPath] = item;
    delete fsMap[oldPath];

    logger.log(`[VFS] rename: ${oldPath} -> ${newPath}`);
    dispatchChange(dirPath);
  }
}

Write File

async writeFile(path: string, content: string): Promise<void> {
  const node = this.getNode(path);
  
  if (node && node.type === 'file') {
    node.content = content;
    
    if (node.metadata) {
      node.metadata.size = content.length;
      node.metadata.mtime = new Date().toISOString();
    }
    
    logger.log(`[VFS] writeFile: ${path}`);
    
    // Update parent directory mtime
    const parts = path.split('/').filter(Boolean);
    parts.pop();
    const parentPath = '/' + parts.join('/') + 
      (parts.length > 0 ? '/' : '');
    const parent = this.getNode(parentPath);
    if (parent?.metadata) {
      parent.metadata.mtime = new Date().toISOString();
    }

    dispatchChange(parentPath);
  }
}

Move

async move(oldPath: string, newPath: string): Promise<void> {
  const node = this.getNode(oldPath);
  if (!node) return;

  // Get old parent
  const oldParts = oldPath.split('/').filter(Boolean);
  const name = oldParts.pop()!;
  const oldParentPath = '/' + oldParts.join('/') + 
    (oldParts.length > 0 ? '/' : '');
  const oldParent = this.getNode(oldParentPath);

  // Get new parent
  const newParts = newPath.split('/').filter(Boolean);
  const newName = newParts.pop()!;
  const newParentPath = '/' + newParts.join('/') + 
    (newParts.length > 0 ? '/' : '');
  const newParent = this.getNode(newParentPath);

  if (oldParent?.type === 'folder' && 
      newParent?.type === 'folder') {
    // Remove from old parent
    delete oldParent.children[name];
    delete fsMap[oldPath];

    // Add to new parent
    newParent.children[newName] = node;
    fsMap[newPath] = node;

    // Update nested paths if folder
    if (node.type === 'folder') {
      const updateMap = (base: string, n: VFSNode) => {
        if (n.type === 'folder') {
          for (const [cName, child] of Object.entries(n.children)) {
            const cp = base + cName + 
              (child.type === 'folder' ? '/' : '');
            const oldCp = oldPath + cp.slice(newPath.length);
            delete fsMap[oldCp];
            fsMap[cp] = child;
            updateMap(cp, child);
          }
        }
      };
      updateMap(newPath, node);
    }

    logger.log(`[VFS] move: ${oldPath} -> ${newPath}`);
    dispatchChange(oldParentPath);
    dispatchChange(newParentPath);
  }
}

Copy

async copy(sourcePath: string, destPath: string): Promise<void> {
  const sourceNode = this.getNode(sourcePath);
  if (!sourceNode) {
    logger.error(`[VFS] copy: source not found: ${sourcePath}`);
    return;
  }

  // Deep clone the node
  const cloneNode = (node: VFSNode): VFSNode => {
    if (node.type === 'file') {
      return {
        type: 'file',
        content: node.content,
        metadata: node.metadata
          ? { ...node.metadata, mtime: new Date().toISOString() }
          : undefined,
      };
    } else {
      const cloned: VFSFolder = {
        type: 'folder',
        children: {},
        metadata: node.metadata
          ? { ...node.metadata, mtime: new Date().toISOString() }
          : undefined,
      };
      for (const [name, child] of Object.entries(node.children)) {
        cloned.children[name] = cloneNode(child);
      }
      return cloned;
    }
  };

  const clonedNode = cloneNode(sourceNode);

  // Get destination parent
  const destParts = destPath.split('/').filter(Boolean);
  const destName = destParts.pop()!;
  const destParentPath = '/' + destParts.join('/') + 
    (destParts.length > 0 ? '/' : '');
  const destParent = this.getNode(destParentPath);

  if (destParent?.type === 'folder') {
    destParent.children[destName] = clonedNode;
    const finalPath = destPath + 
      (clonedNode.type === 'folder' ? '/' : '');

    // Recursively add to fsMap
    const addToMap = (base: string, n: VFSNode) => {
      fsMap[base] = n;
      if (n.type === 'folder') {
        for (const [cName, child] of Object.entries(n.children)) {
          const cp = base + cName + 
            (child.type === 'folder' ? '/' : '');
          addToMap(cp, child);
        }
      }
    };
    addToMap(finalPath, clonedNode);

    logger.log(`[VFS] copy: ${sourcePath} -> ${destPath}`);
    dispatchChange(destParentPath);
  }
}

Trash Functionality

Move to Trash

async moveToTrash(path: string): Promise<void> {
  const trashPath = CONFIG.FS.TRASH;
  
  // Ensure trash folder exists
  if (!this.getNode(trashPath)) {
    const parts = trashPath.split('/').filter(Boolean);
    const trashName = parts.pop()!;
    const parentPath = '/' + parts.join('/') + 
      (parts.length > 0 ? '/' : '');
    await this.mkdir(parentPath, trashName);
  }

  const parts = path.split('/').filter(Boolean);
  const name = parts.pop()!;
  await this.move(path, trashPath + name);
}

Restore from Trash

async restoreFromTrash(name: string): Promise<void> {
  const trashItemPath = CONFIG.FS.TRASH + name;
  const restorePath = CONFIG.FS.DESKTOP + name;
  await this.move(trashItemPath, restorePath);
}

Search Operations

async search(
  basePath: string, 
  query: string, 
  recursive = false
): Promise<string[]> {
  const results: string[] = [];
  const lowerQuery = query.toLowerCase();

  const searchDir = (path: string) => {
    const children = this.getChildren(path);
    if (!children) return;

    for (const [name, node] of Object.entries(children)) {
      const fullPath = path + name + 
        (node.type === 'folder' ? '/' : '');

      // Match filename
      if (name.toLowerCase().includes(lowerQuery)) {
        results.push(fullPath);
      }

      // Search file content
      if (node.type === 'file' && 
          node.content.toLowerCase().includes(lowerQuery)) {
        if (!results.includes(fullPath)) {
          results.push(fullPath);
        }
      }

      // Recurse into folders
      if (recursive && node.type === 'folder') {
        searchDir(fullPath);
      }
    }
  };

  searchDir(basePath);
  return results;
}

Utility Operations

Get Size

getSize(path: string): number {
  const node = this.getNode(path);
  if (!node) return 0;

  if (node.type === 'file') {
    return node.content.length;
  }

  // Recursive size for folders
  const calcSize = (n: VFSNode): number => {
    if (n.type === 'file') return n.content.length;
    let sum = 0;
    for (const child of Object.values(n.children)) {
      sum += calcSize(child);
    }
    return sum;
  };

  return calcSize(node);
}

Check Existence

exists(path: string): boolean {
  return !!this.getNode(path);
}

Get Children

getChildren(path: string): Record<string, VFSNode> | null {
  const node = this.getNode(path);
  return node?.type === 'folder' ? node.children : null;
}

Event System

Change Notifications

function dispatchChange(path: string): void {
  window.dispatchEvent(
    new CustomEvent('cde-fs-change', {
      detail: { path },
    })
  );
}

Dynamic Content Sync

Large files are loaded asynchronously after initialization:
async function syncDynamicContent(): Promise<void> {
  const [
    readme,
    gettingStarted,
    xemacsGuide,
    // ... more documentation
  ] = await Promise.all([
    import('../../../README.md?raw'),
    import('../../../docs/user-guide/getting-started.md?raw'),
    import('../../../docs/user-guide/xemacs.md?raw'),
    // ...
  ]);

  // Update VFS with loaded content
  const readmePath = CONFIG.FS.HOME + 'README.md';
  const readmeFile = fsMap[readmePath] as VFSFile;
  if (readmeFile?.type === 'file') {
    readmeFile.content = readme.default;
  }

  // Sync JSON data
  const fontsPath = CONFIG.FS.HOME + 'settings/fonts.json';
  if (fsMap[fontsPath]) {
    (fsMap[fontsPath] as VFSFile).content = 
      JSON.stringify(fontsData, null, 2);
  }

  logger.log('[VFS] Dynamic content synced');
}
This keeps the initial bundle small by deferring large file loads.

Global Exposure

declare global {
  interface Window {
    VirtualFS: IVFS;
  }
}

if (typeof window !== 'undefined') {
  window.VirtualFS = VFS;
}

Future: IndexedDB Integration

The VFS is designed to integrate with IndexedDB for persistent storage:
const DB_NAME = 'cde-time-capsule';
const FILESYSTEM_STORE = 'filesystem';

// Planned structure
interface StoredVFSNode {
  path: string;        // Primary key
  type: 'file' | 'folder';
  content?: string;    // For files
  children?: string[]; // For folders (array of paths)
  metadata: VFSMetadata;
}
This will enable:
  • User-created files persisting across sessions
  • Larger file support
  • Offline file access
  • Progressive sync on load

Performance Considerations

O(1) Lookups

Flattened map provides constant-time access:
// Fast: O(1)
const node = fsMap['/home/victxrlarixs/Desktop/file.txt'];

// vs Slow: O(depth)
function traverse(path: string): VFSNode | null {
  let current = root;
  for (const part of path.split('/')) {
    if (!current.children[part]) return null;
    current = current.children[part];
  }
  return current;
}

Memory Usage

Typical VFS memory footprint:
  • ~1000 files/folders: ~500KB
  • ~10000 entries: ~5MB
  • Content stored as strings (UTF-16)

Batch Operations

Avoid individual operations in loops:
// Bad: Multiple event dispatches
for (const file of files) {
  await VFS.touch(dir, file);
  // Each touch() dispatches 'cde-fs-change'
}

Best Practices

Ensure directory paths end with / for consistency:
// Good
await VFS.mkdir('/home/victxrlarixs/', 'Projects');
const children = VFS.getChildren('/home/victxrlarixs/');

// Bad: May cause lookups to fail
await VFS.mkdir('/home/victxrlarixs', 'Projects');
const children = VFS.getChildren('/home/victxrlarixs');
Always normalize paths before operations:
const userPath = '../Documents/file.txt';
const cwd = '/home/victxrlarixs/Desktop/';
const fullPath = VFS.resolvePath(cwd, userPath);
// => '/home/victxrlarixs/Documents/file.txt'

const node = VFS.getNode(fullPath);
Prevent errors by checking first:
if (VFS.exists('/home/victxrlarixs/Desktop/file.txt')) {
  await VFS.rm('/home/victxrlarixs/Desktop/', 'file.txt');
}
Keep UI synchronized with filesystem:
window.addEventListener('cde-fs-change', (event) => {
  const { path } = event.detail;
  if (path === currentDirectory) {
    refreshFileList();
  }
});