zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Settings & Configuration

An app instance no longer points at a workspace directory. It is bound to exactly one encrypted cloud workspace, and its configuration is split across three layers with three different lifetimes (epic #4204 D2/D3). Knowing which layer a value belongs to answers both "where is it stored" and "does it follow the user to another device".

The three layers

LayerHomeContentsSynced
Local bootstrap identity~/.config/zudotext/<appname>/config.json (v2) + the device key storeworkspace id, sync-server/auth configuration, device id, encryption key material; macOS Doc Cloud token in Keychainnever — device-specific
Synced preferencesworkspace document .zudotext.settings.jsoncolor, editor, shortcuts, frameset, header pins, boards, Doc Cloud preferences (but never its token), …yes
Transient statememory + device-local storageactiveDraft / draftCount, subscription runtime state, derived values; non-macOS Doc Cloud token by defaultnever

The implementation chokepoint is packages/backend-bridge/src/settings-layers.ts: everything written to the workspace document goes through toSyncedSettingsLayer(), which blanks the device id and drops transient state. Validation rebuilds sync field-by-field, so obsolete identity keys from old documents are discarded rather than trusted.

Workspace binding — config.json v2

{
  "workspace": {
    "id": "workspace-abc123"
  }
}

That is the whole schema. There is no workspace path, because the constraint the schema encodes is "one app instance points at exactly one workspace".

Startup resolution has two outcomes, and the distinction is load-bearing:

  • Boundworkspace.id parsed successfully. The app proceeds to unlock that workspace.

  • Unbound — the file is missing, unreadable, unparseable, in the retired v1 {"workspace": …} shape, or v2-shaped with a blank/absent workspace.id. All five fall-throughs resolve to unbound, and unbound routes to onboarding.

Unbound never auto-creates a local directory. The pre-pivot resolver silently fell back to scaffolding ~/Documents/zudo-text/<appname>/, and because it returned a bare String the caller could not tell a real workspace from a fabricated one. That fallback is gone, and the reason is carried on the result so a support report can tell the cases apart.

The binding is persisted only after a successful unlock — after the master password has decrypted a snapshot. Writing it earlier could strand an instance on a workspace it cannot open, with no way out from inside the app. The one exception is LEAF generation, where minting the app and choosing its workspace are the same user action (see App Generation).

Renderer surface: bridge.appBinding.read() / .persist(workspaceId) / .clear(). Rust: tauri-app/core/src/generator/app_config.rs.

Settings document — .zudotext.settings.json

The settings file is a workspace document, not a machine-local file, so colors, editor options, shortcuts, framesets, and pins are the same on every device signed into that workspace. Before unlock the app runs on defaults; nothing is written to a local settings file.

This was only possible once general.projectRoot was removed. That field was a device-absolute path, and it was the sole reason the settings file was excluded from sync.

Full-document last-writer-wins (accepted for v1). The document is pushed whole. Two devices changing different sections at the same moment means the later write's entire document wins. There is no per-section merge and no field-level CRDT. What makes it tolerable is write frequency — settings are essentially only written while a settings dialog is open — plus deviceOverrides, the per-device deep-partial escape hatch for anything that genuinely should differ per machine (see Device override).

Read/write pair: tauri-app/renderer/lib/workspace-settings-document.ts. Reads fall back to bridge.settings before the workspace can serve them (during pre-mount appearance load); writes never fall back, because a write landing in a machine-local file would resurrect the second store this design removes.

Schema

The schema is defined in packages/app-defaults/src/types.ts and validated on load by validateSettings() (numeric ranges, field-name migration, enum checks). An abridged view of the shape:

interface AppSettings {
  general: {
    colorScheme: string;           // Theme name (e.g., "default")
    colorMode: "system" | "light" | "dark"; // Which of the scheme's two palettes to show (epic #5893)
    formatOnArchive: boolean;      // Auto-format when archiving
    windowOpacity: number;         // Window transparency (0–1)
    displayScale: number;          // UI zoom
    appTitle?: string | null;      // Optional OS title-bar override
    disableSpotlightSearch: boolean;
    showMinimap: boolean;
    useSpreadsheet: boolean;
    useSlides: boolean;
  };
  color: ColorStructure;           // { ramps, map } — see @takazudo/color-themes
  activeDraft: number;             // transient — stripped before the workspace write
  draftCount: number;              // transient — stripped before the workspace write
  editor: {
    vimMode: boolean;
    fontFamily: string;            // Default: "JetBrains Mono"
    fontSize: number;              // Default: 16
    lineHeight: number;            // Default: 1.6
    typewriterScrolling: boolean;  // Keep cursor vertically centered
    showStatusBar: boolean;
    markdownListIndent: boolean;   // Auto-continue list markers
    showLineNumbers: boolean;
    showIndentGuides: boolean;
    indentType: "tab" | "spaces";  // Default: "spaces"
    indentSize: number;            // Default: 2
    // …
  };
  vim: {
    clipboardSync: boolean;        // Sync vim registers with system clipboard
    showModeIndicator: boolean;
    vimrc: string;                 // Custom vim key mappings
  };
  shortcuts: {
    // Each shortcut value is string[] — a slot can hold multiple bindings
    toggleEditorPreview: string[]; // e.g. ["Mod+E"]
    commandPalette: string[];      // e.g. ["Mod+K"]
    openSettings: string[];
    newDraft: string[];
    // …
  };
  sync: SyncSettings;              // mixed — identity fields blanked before the workspace write
  docCloud: {
    defaultProjectSlug: string | null;
    checkpointOnEdit: boolean;
    publishNotifications: boolean;
    serverOrigin: string;          // validated bare origin; never a credential
  };
  quickActions: { tiles: QuickAction[] };
  /**
   * Singleton frameset tree — the live layout for the current app window.
   */
  frameset: FramesetTree;
  /**
   * Ordered header pin entries displayed as icon buttons in the toolbar.
   */
  headerLeftPins: HeaderLeftPin[];
  deviceOverrides: Record<string, DeviceOverride>; // within docCloud, only defaultProjectSlug is overridable
  // …plus per-feature sections: menuBar, notifications, pileView,
  // directoryView, preview, mindmap, kanban, slides, inlineAiCommand, ios
}

Two fields that used to be here are gone with the concepts they described: general.projectRoot (no workspace root exists) and the whole terminal section (the terminal frame and the Rust PTY backend were retired together — D6). A Doc Cloud token is likewise deliberately absent: macOS keeps it in Keychain; other environments keep it in memory unless an adapter is constructed with the explicit DEV-only localStorage opt-in.

sync.cloudDeviceId remains device-local and is blanked before the document is written. The behavioural fields — cloudRealtimeEnabled, cloudDeviceName, agentServerUrl, and alwaysRequireWorkspacePassword — are ordinary synced preferences and do travel. Workspace identity comes from sync-auth-state.getWorkspaceId() / config.json v2, encryption readiness from the armed bridge, and the sync-server URL from runtime bootstrap configuration. The authoritative remaining identity list is LOCAL_BOOTSTRAP_IDENTITY_SYNC_FIELDS in packages/app-defaults/src/settings-identity.ts.

Default values

@takazudo/app-defaults (packages/app-defaults/src/defaults.ts) provides defaults for every field:

SettingDefault
Color schemedefault
Color modesystem
Editor fontJetBrains Mono, 16px
Vim modeEnabled
Header pinsInbox, Archives
Content pin directorypins
Doc Cloud default projectnull (Projects)
Doc Cloud checkpoint on first edittrue
Doc Cloud publish notificationstrue
Doc Cloud server-origin override"" (adapter-provided origin)

Local-directory workspace registry (retired)

~/.config/zudotext/<appname>/workspaces.json held the list of registered local workspace directories that backed the old chooser sidebar. Both are gone: an app instance is bound to exactly one workspace, chosen once at onboarding and persisted in config.json v2, so there is nothing left to enumerate or switch between (D2; the renderer-side removal was #4214).

The registry file and the Rust workspace_registry module that read it were deleted with the local-workspace-directory concept in #4223, along with the workspace_* Tauri commands and the corresponding bridge methods (listAll, register, remove, switchTo, addExisting, setDir, scaffold, …). They were not reimplemented against the cloud workspace — they were removed, because with one bound workspace per instance there was nothing left to list or switch. Existing files on disk are left untouched: per the pre-release policy there is no migration, they simply become unused.

Only two read-only methods survive, both transitional, on what is now the bridge.localDir namespace (renamed from the legacy workspace namespace by epic #4991 Wave 1 to free the domain word): getDir() returns a synthetic root (never a real directory) and listFiles() enumerates the workspace model beneath it. Callers are being moved to bridge.workspaceFiles, which speaks workspace-relative paths and never sees the synthetic root.

For the flow that replaced the chooser, see Cloud-first onboarding.

Recovered as the account-workspace switcher

The "nothing left to enumerate or switch between" claim above held only as long as an app instance was hard-bound to one workspace for its lifetime. Epic #4608 reintroduced switching on top of the multi-workspace-per-account model — without resurrecting a registry file. The workspace list comes live from the sync server (AccountWorkspace[]) rather than a local JSON cache, and the switch itself is an in-session hot-swap (tauri-app/renderer/lib/workspace-switch.ts) rather than a process restart; see Workspace Switcher for the user-facing rail this backs.

Two policy points carried over from the old registry needed a new answer under the workspace model:

  • Binding semantics — last-opened-wins in ROOT. There is no separate "default workspace" setting. Every successful open, including a switch, persists the app instance's config.json v2 binding to that workspace (commitBinding: true), so the workspace ROOT opens on its next launch is always whichever one was open most recently in this session.

  • Persist-failure policy — open succeeds, notice shown. A switch's destructive teardown-and-open is not conditioned on the binding write landing. If the workspace opens but the config.json persist fails, nothing is rolled back — the user keeps working in the newly-opened workspace — and a sticky notice (pushWorkspaceSwitchNotice() / WorkspaceSwitchNoticeReplay, both in lib/workspace-switch.ts) explains that the next launch will fall back to the previous workspace instead of this one.

Header Pins (Frameset Arch v2)

Header pins are structural layout templates displayed as icon buttons in the app toolbar. Each pin carries a PinTemplate that rebuilds the singleton frameset tree wholesale when activated. This replaced the old PinConfig directory-browsing model.

interface HeaderLeftPin {
  id: string;           // Stable kebab-case id (e.g. "inbox-default")
  template: PinTemplate; // Frameset tree installed on activation
  label: string;        // Tooltip text
  iconId: string;       // Lucide icon name
  routeSlug: string | null; // Route slug for user pins (/p/:slug); null for built-ins
  visible: boolean;
  providerProps?: Record<string, unknown>; // Per-pin provider config
}

Because pins live in the synced settings document, a pin added on one device shows up on all of them. Workspace-backed per-pin providerProps use workspace-relative paths; the External File Editor's initialFiles and initialTreeRoot remain local absolute paths because it browses the real filesystem. A Doc Cloud pin carries one leaf and may seed only projectSlug, initialSurface, editorLayout, and outlineCollapsed; it never persists a token, page source, tab list, or concurrency guard.

The retired standalone Directory View id is not migrated. Old pre-release pin or frameset state is disposable; an opaque old pin can render provider-not-found until the user removes it or resets settings. The retained directoryView.lastSidebarWidthPx key controls only EFE's embedded tree.

See Frame/Frameset/Pin Architecture for the full design.

Config-driven app instances

Different configurations produce different app instances. This is useful for:

  • Separate content — work vs personal communications, each in its own workspace

  • Different themes — light theme for daytime, dark for night

  • Different pin layouts — project-specific header pin templates

  • Different shortcuts — customized per workflow

Each instance has its own config.json binding and its own workspace; everything inside that workspace — content, settings, pins, framesets — belongs to the instance and travels with the account rather than with the machine. See App Generation.