Skip to main content

Workspace System

The Workspace class is the central orchestrator in Opal Editor, managing the lifecycle and operations of markdown editing projects. It coordinates between the storage layer (Disk), version control (GitRepo), and user interface.

Architecture Overview

The Workspace system follows a layered architecture:
┌─────────────────────────────────────┐
│         Workspace                   │
│  (Coordination & Lifecycle)         │
├─────────────────────────────────────┤
│  Disk    │  GitRepo  │  RemoteAuth  │
│ (Storage)│  (Version)│  (Deploy)    │
└─────────────────────────────────────┘

Core Class: Workspace

Location: ~/workspace/source/src/workspace/Workspace.ts

Class Definition

export class Workspace {
  name: string;
  guid: string;
  private _disk: Disk;
  private _thumbs: Disk;
  private _repo: GitRepo;
  private _playbook: GitPlaybook;
  private _remoteAuths?: RemoteAuthDAO[];
  
  imageCache: ImageCache;
  local: WorkspaceEventsLocal;
  remote: WorkspaceEventsRemote;
  
  // Getters
  get disk(): Disk
  get repo(): GitRepo
  get thumbs(): Disk
  get buildStrategy(): BuildStrategy
}

Key Properties

PropertyTypeDescription
guidstringUnique identifier for the workspace
namestringSlugified workspace name
diskDiskMain storage abstraction for files
thumbsDiskSeparate disk for thumbnail storage
repoGitRepoGit repository instance
imageCacheImageCacheBrowser cache for optimized image loading
remoteAuthsRemoteAuthDAO[]Authentication for deployment providers

Creating Workspaces

Factory Methods

CreateNew - Create from scratch

const workspace = await Workspace.CreateNew({
  name: "my-blog",
  files: {
    "/index.md": "# Welcome",
    "/about.md": "# About"
  },
  diskType: "IndexedDbDisk",
  buildStrategy: "freeform"
});
Parameters:
  • name: Workspace identifier (will be slugified)
  • files: Initial file tree (object or iterable)
  • diskType: Storage backend type
  • diskOptions: Optional configuration for disk
  • buildStrategy: Build system strategy (“freeform” | “eleventy”)

FromDAO - Restore from database

const workspaceDAO = await WorkspaceDAO.FetchFromGuid(guid);
const workspace = Workspace.FromDAO(workspaceDAO);

FromName - Load by name

const workspace = await Workspace.FromName("my-blog");

Initialization

Workspaces must be initialized before use:
await workspace.init();
// or skip event listeners for background operations
await workspace.initNoListen();
Initialization process:
  1. Initialize disk and file indexing
  2. Initialize Git repository
  3. Set up event listeners (cross-tab sync)
  4. Recover from non-OK status if needed

File Operations

Creating Files

// Single file
const path = await workspace.newFile(
  absPath("/blog"), 
  relPath("post.md"), 
  "# New Post"
);

// Multiple files
const paths = await workspace.newFiles([
  [absPath("/posts/1.md"), "# Post 1"],
  [absPath("/posts/2.md"), "# Post 2"]
]);

// Create directory
await workspace.newDir(absPath("/posts"), relPath("2024"));

Reading Files

const content = await workspace.readFile(absPath("/index.md"));

Renaming Files

const fromNode = workspace.nodeFromPath(absPath("/old.md"))!;
const result = await workspace.renameSingle(
  fromNode, 
  absPath("/new.md")
);

// Multiple renames
await workspace.renameMultiple([
  [node1, absPath("/path1.md")],
  [node2, absPath("/path2.md")]
]);

Deleting Files

// Single file
await workspace.removeSingle(absPath("/file.md"));

// Multiple files
await workspace.removeMultiple([
  absPath("/file1.md"),
  absPath("/file2.md")
]);

Trash Operations

// Move to trash
await workspace.trashMultiple([absPath("/file.md")]);

// Restore from trash
await workspace.untrashSingle(absPath("/.trash/file.md"));

// Check if trash has items
if (workspace.hasTrash()) {
  // Empty trash logic
}

File Tree Navigation

Accessing Nodes

// Get tree node by path
const node = workspace.nodeFromPath(absPath("/index.md"));

// Get file tree root
const root = workspace.getFileTreeRoot();

// Get first file
const firstFile = await workspace.getFirstFile();

// Get flat tree with filters
const markdownFiles = workspace.getFlatTree({
  filterIn: (node) => node.isMarkdownFile(),
  filterOut: (node) => node.path.startsWith("/.trash")
});

Image Handling

Upload Images

// Single image
const path = await workspace.uploadSingleImage(
  imageFile, 
  absPath("/images")
);

// Multiple images with concurrency control
const paths = await workspace.uploadMultipleImages(
  imageFiles, 
  absPath("/images"),
  8 // concurrency
);

Thumbnails

// Create/read thumbnail
const thumbBlob = await workspace.readOrMakeThumb(
  absPath("/images/photo.jpg"),
  100 // size in pixels
);

Event System

Workspaces use both local and remote event emitters for cross-tab synchronization.

Listening to File Changes

// Watch for renames
workspace.renameListener((changes) => {
  changes.forEach(({ oldPath, newPath }) => {
    console.log(`Renamed: ${oldPath}${newPath}`);
  });
});

// Watch for creates
workspace.createListener(({ filePaths }) => {
  console.log("Created:", filePaths);
});

// Watch for deletes
workspace.deleteListener(({ filePaths }) => {
  console.log("Deleted:", filePaths);
});

// Watch disk index updates
workspace.watchDiskIndex((fileTree, trigger) => {
  if (trigger?.type === "rename") {
    // Handle rename
  }
});

Workspace Lifecycle Events

// Listen for workspace rename
workspace.renameWorkspaceListener(({ id, oldName, newName }) => {
  console.log(`Workspace renamed: ${oldName}${newName}`);
});

// Listen for workspace deletion
workspace.deleteWorkspaceListener(() => {
  console.log("Workspace deleted");
});

Git Events

// Listen to Git changes
workspace.gitRepoListener((info) => {
  console.log("Current branch:", info.currentBranch);
  console.log("Has changes:", info.hasChanges);
});

Workspace Management

Rename Workspace

const newName = await workspace.rename("new-name");

Destroy Workspace

await workspace.destroy(); // Deletes all data

Tear Down (Cleanup)

await workspace.tearDown(); // Cleanup without deletion

URL Resolution

// Get workspace home URL
const homeUrl = workspace.home(); // "/workspace/my-blog"

// Resolve file URL
const fileUrl = workspace.resolveFileUrl(absPath("/index.md"));
// "/workspace/my-blog/index.md"

// Get first file URL (or home if no files)
const url = await workspace.tryFirstFileUrl();

Parse Workspace Paths

const { workspaceName, filePath } = Workspace.parseWorkspacePath(
  "/workspace/my-blog/posts/hello.md"
);
// workspaceName: "my-blog"
// filePath: "/posts/hello.md"

Copy Operations

// Copy single file
await workspace.copyFile(
  absPath("/source.md"),
  absPath("/dest.md"),
  false // overwrite
);

// Copy from another workspace
await workspace.copyMultipleSourceNodes(
  sourceNodes,
  sourceDisk
);

Serialization

// To JSON
const json = workspace.toJSON();
// Returns: { name, guid, href, disk, thumbs, remoteAuths, ... }

// From JSON
const workspace = Workspace.FromJSON(json);

Integration with Git

Workspaces integrate tightly with Git:
// Access Git repository
const repo = workspace.repo;

// Git operations trigger disk re-indexing
await repo.commit({ message: "Update" });
// Automatically triggers workspace.disk.triggerIndex()

// Check remote repositories
const remotes = workspace.getRemoteGitRepos();

Best Practices

Always Initialize

// ✅ Good
const workspace = await Workspace.FromName("my-blog");
await workspace.init();

// ❌ Bad - operations may fail
const workspace = await Workspace.FromName("my-blog");
await workspace.readFile(absPath("/index.md")); // May fail!

Clean Up Resources

try {
  const workspace = await Workspace.FromName("my-blog");
  await workspace.init();
  // ... operations
} finally {
  await workspace.tearDown();
}

Use Path Helpers

import { absPath, relPath, joinPath } from "@/lib/paths2";

// ✅ Good - type-safe paths
await workspace.newFile(
  absPath("/posts"),
  relPath("hello.md"),
  content
);

// ❌ Bad - plain strings
await workspace.newFile("/posts", "hello.md", content);

Handle Cross-Tab Events

// Listen for changes from other tabs
workspace.watchDiskIndex((fileTree, trigger) => {
  if (trigger?.type === "rename") {
    // Update UI to reflect rename from another tab
  }
});

Common Patterns

Workspace with OPFS Directory Mount

const workspace = await Workspace.CreateNew({
  name: "local-folder",
  files: [],
  diskType: "OpFsDirMountDisk",
  diskOptions: {
    selectedDirectory: await window.showDirectoryPicker()
  }
});

Search Workspace Content

const scannable = workspace.NewScannable();
const results = await scannable.search("search term");

Get All Images

const images = workspace.getImages();
// Returns: AbsPath[] of all image files

Error Handling

import { NotFoundError, BadRequestError } from "@/lib/errors/errors";

try {
  await workspace.removeSingle(absPath("/missing.md"));
} catch (error) {
  if (error instanceof NotFoundError) {
    console.log("File not found");
  }
}

Build docs developers (and LLMs) love