MutkaMutka
Modules

Events & File Watching

The narrow set of app events a module may subscribe to, and how directory watching works (and why it's current-directory-only).

A module reacts to the world through host.events.on(event, handler). The set of events it may subscribe to is a whitelist — kept deliberately narrow.

Why the whitelist

The list of subscribable events is a trust surface. A module should only receive notifications it has a legitimate reason to act on, and nothing carrying sensitive state. So subscriptions are checked against an explicit set in eventWhitelist.ts; anything not listed is simply not deliverable. New entries are added deliberately, not by default.

Subscribable events

These events are forwarded with their payload. They either carry nothing sensitive (booleans, theme, module ids) or carry the single path/items a module legitimately needs to act on.

EventFires when…
app:readyAll modules registered + AppBridge connected (the launch hook)
directory:changedThe current directory's contents changed on disk
selection:changedThe user's file selection changed
input:mouse-navigateA back/forward mouse button was pressed
file:modifier-openA file was opened with a modifier (⌘/⇧) held
file:middle-openA file was opened with the middle mouse button
file:open-no-appAn item was opened but no app claims it (offer a picker)
file:external-dropFiles were dropped in from Finder
sidebar:item-removeThe user removed one of this module's sidebar items
action:dispatchA command ran — payload is { actionId } (e.g. core.clipboard.copy), a static feature id (no user data)
navigation:startA folder-open navigation has been initiated (carries { path })
navigation:backThe user navigated back in history
navigation:forwardThe user navigated forward in history
listing:loadedDirectory items fetched and stored (carries { path, count })
listing:renderedFile rows committed to the DOM (carries { path, count })
icons:settledNative icon fetch queue fully drained
theme:changedThe theme changed (carries { preference, resolved })
view:changedA view preference changed (e.g. show-hidden toggled)
settings:changedThe settings overlay was opened or closed
modules-ui:changedThe module-manager overlay was opened or closed
sidebar:changedA sidebar panel was added, removed, or reordered
module:registeredA module was registered (carries { moduleId })
module:unregisteredA module was unregistered (carries { moduleId })
columns:cell-resolvedA custom column cell finished resolving its value
columns:widths-changedThe user resized a column
setup(host) {
  host.events.on("directory:changed", () => {
    // re-read state, re-render a panel, etc.
  });
}

app:ready is the launch hook

app:ready is emitted only after every loader resolves and AppBridge is connected — so subscriptions are wired and host.nav.* reaches real React state. The built-in core.home listens for it to resolve the home directory and run the first navigation. Do app-startup work here, not at module top level.

file:external-drop requires fs:read

Some whitelisted events carry data sensitive enough that receiving them requires a permission. file:external-drop delivers the actual bytes of files the user dragged in from Finder, so your module must declare fs:read to receive it. Without the permission, the subscription is silently dropped — just like a non-whitelisted event.

Notify-only events

A second, smaller set of events is delivered as a bare ping — the event fires, but its payload is stripped to undefined. The occurrence is useful (cache-bust, trigger a re-render), but the payload is profiling-grade: it would expose the full clipboard contents or every open tab's path.

EventWhy the payload is strippedHow to get the data instead
clipboard:changedCarries full clipboard contentsRe-read via host.board.readFiles() (requires clipboard:read)
tabs:changedCarries every open tab's pathReact to the ping only — no read-back capability today
setup(host) {
  host.events.on("clipboard:changed", () => {
    // the payload is undefined — the event is just a signal
    // to get the actual clipboard contents, use a capability:
    const files = await host.board.readFiles();
  });
}

Why strip instead of just blocking?

The occurrence of these events is genuinely useful — a module that displays clipboard state needs to know when to re-read, and a module reacting to tab changes needs to know that something changed. Blocking the event entirely would force polling; stripping the payload gives the signal without the sensitive data. The module that actually needs the data fetches it through a permission-gated capability, so the access is explicit and auditable.

How directory watching works

The host watches only the directory currently in view — not the whole disk, and not arbitrary module-requested paths.

Rust FSEvents watcher (one, re-armed on each navigation)
  → emits "directory-changed"
  → core/file-watch/DirectoryWatcher.ts debounces it
  → re-broadcasts as the whitelisted "directory:changed" event
  → core.auto-refresh re-reads the list; your module reacts too

Why current-directory-only

Two reasons, one cost and one correctness:

  • Bounded cost. There is exactly one watcher. read_dir re-arms it on every navigation (unwatch old + watch new on a persistent watcher) instead of dropping and recreating it — dropping a macOS FSEvents watcher joins its run-loop thread, which previously stalled every listing. Modules cannot request their own watchers, so the watching cost can never grow with the number of modules.
  • Listing is never blocked by watching. Rendering folder content is the priority, so the re-arm runs on a detached thread. Access events are ignored so that merely reading a directory can't loop back into a refresh.

The practical consequence for module authors: you get notified about changes to the folder the user is looking at, which is what a file explorer actually needs. If you need to know about other locations, poll them yourself with host.fs.readDir.

Custom events from community modules

A module can declare its own events via TypeScript declaration merging on EventMap (the interface in src/core/event-bus/events.ts):

declare module "../../core/event-bus/events" {
  interface EventMap {
    "acme.git-status:repo-changed": { path: string };
  }
}

Naming convention

Module-scoped events must follow the pattern "<module-id>:<past-tense-verb>" — e.g. "acme.git-status:repo-changed". The module ID prefix prevents collisions between unrelated modules.

Note that a sandboxed (worker) module cannot reach the EventBus directly — it has no core reference. It only receives the whitelisted events the host forwards to it, and emits side effects through host.* capabilities. Cross-module event broadcasting from within a worker is not a supported path today; custom events are primarily useful for built-in modules that run in-process and can import the EventBus.

On this page