From 47d39098cd752d11c4c2581849cfb53b112a69ba Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Fri, 15 May 2026 03:42:56 +0200 Subject: [PATCH] rewrite transforms, implement unified transform layer --- docs/ARCHITECTURE.md | 10 +++ src/shims/fs/fd.js | 15 ++-- src/shims/fs/index.js | 6 +- src/shims/fs/promises.js | 131 ++++++++++++++++++++------------ src/shims/fs/read-transforms.js | 39 ---------- src/shims/fs/sync.js | 74 ++++++++++++------ src/shims/fs/transforms.js | 89 ++++++++++++++++++++++ src/shims/fs/transport.js | 80 ++++--------------- src/shims/init.js | 2 +- src/shims/workspace.js | 49 ++++++------ 10 files changed, 289 insertions(+), 206 deletions(-) delete mode 100644 src/shims/fs/read-transforms.js create mode 100644 src/shims/fs/transforms.js diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1aacae6..5fc1f43 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,6 +51,16 @@ Writes go through a server-side write coalescer (`server/write-coalescer.js`) de Sync calls use synchronous XHR to ensure blocking behavior. Async calls use fetch. Everything goes through a transport layer that handles vault ID injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values. +### Translation registry + +The shim has a registry (`src/shims/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types: + +- **Path resolvers** map a logical path to a physical path. Used by the workspaces shim to redirect reads and writes of `.obsidian/workspace.json` to `.obsidian/workspace..json` based on the `?workspace=` URL parameter, so each browser tab can hold a separate layout. +- **Read transforms** post-process bytes returned by a read (cache hit or transport miss). Used to mask the Obsidian Sync setting in `core-plugins.json` when headless-sync is active for the vault, and to override the `active` field on reads of `workspaces.json` so each tab sees its own workspace as selected. +- **Write transforms** pre-process bytes before a write hits the cache or transport. Used to override the `active` field on writes to `workspaces.json` so cross-tab disk state stays canonical. + +All hooks are synchronous and registered at module load. Translation happens once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot. + ### IPC IPC is implemented as a synchronous dispatcher that maps channel names to handlers. diff --git a/src/shims/fs/fd.js b/src/shims/fs/fd.js index 570de8c..fadfd78 100644 --- a/src/shims/fs/fd.js +++ b/src/shims/fs/fd.js @@ -3,6 +3,7 @@ // around files without loading them via readFileSync upfront. import { isInputCachePath, inputCacheGet } from "./input-cache.js"; +import { resolvePath } from "./transforms.js"; let nextFd = 100; const openFiles = new Map(); @@ -22,7 +23,8 @@ export function createFdOps(metadataCache, contentCache, transport) { } } - const cached = contentCache.get(path); + const resolved = resolvePath(path); + const cached = contentCache.get(resolved); if (cached !== null) { if (typeof cached === "string") { @@ -33,9 +35,9 @@ export function createFdOps(metadataCache, contentCache, transport) { } // Synchronous fetch fallback - console.warn("[shim:fs] fd open cache miss, using sync XHR:", path); - const data = transport.readFileSync(path); - contentCache.set(path, data); + console.warn("[shim:fs] fd open cache miss, using sync XHR:", resolved); + const data = transport.readFileSync(resolved); + contentCache.set(resolved, data); return data; } @@ -56,8 +58,9 @@ export function createFdOps(metadataCache, contentCache, transport) { function openSync(path, flags, mode) { const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; + const resolved = resolvePath(path); - if (!hasInCache && !metadataCache.has(path)) { + if (!hasInCache && !metadataCache.has(resolved)) { const err = new Error( `ENOENT: no such file or directory, open '${path}'`, ); @@ -67,7 +70,7 @@ export function createFdOps(metadataCache, contentCache, transport) { const data = ensureData(path); const fd = nextFd++; - openFiles.set(fd, { path, data }); + openFiles.set(fd, { path: resolved, data }); return fd; } diff --git a/src/shims/fs/index.js b/src/shims/fs/index.js index 1e70c89..9f37a43 100644 --- a/src/shims/fs/index.js +++ b/src/shims/fs/index.js @@ -7,7 +7,7 @@ import { createFsWatch } from "./watch.js"; import { createWatcherClient } from "./watcher-client.js"; import { createFdOps } from "./fd.js"; import { constants } from "./constants.js"; -import { registerReadTransform, removeReadTransform } from "./read-transforms.js"; +import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js"; const metadataCache = new MetadataCache(); const contentCache = new ContentCache(); @@ -41,6 +41,10 @@ export const fsShim = { watch: fsWatch.watch, constants, + invalidate(path) { + contentCache.invalidate(resolvePath(path)); + }, + _metadataCache: metadataCache, _contentCache: contentCache, _watcherClient: watcherClient, diff --git a/src/shims/fs/promises.js b/src/shims/fs/promises.js index 3f2dbb3..3b4d908 100644 --- a/src/shims/fs/promises.js +++ b/src/shims/fs/promises.js @@ -1,19 +1,20 @@ import { markLocalOp } from "./echo-guard.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js"; -import { applyReadTransform } from "./read-transforms.js"; +import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js"; export function createFsPromises(metadataCache, contentCache, transport) { return { async stat(path) { - const cached = metadataCache.toStat(path); + const resolved = resolvePath(path); + const cached = metadataCache.toStat(resolved); if (cached) { return cached; } - const meta = await transport.stat(path); - metadataCache.set(path, meta); - return metadataCache.toStat(path); + const meta = await transport.stat(resolved); + metadataCache.set(resolved, meta); + return metadataCache.toStat(resolved); }, async lstat(path) { @@ -46,6 +47,7 @@ export function createFsPromises(metadataCache, contentCache, transport) { } const wantText = encoding === "utf8" || encoding === "utf-8"; + const resolved = resolvePath(path); let result = null; @@ -55,7 +57,7 @@ export function createFsPromises(metadataCache, contentCache, transport) { } if (result === null) { - const meta = metadataCache.get(path); + const meta = metadataCache.get(resolved); if (meta && meta.type === "directory") { const e = new Error("EISDIR: illegal operation on a directory, read"); @@ -63,7 +65,8 @@ export function createFsPromises(metadataCache, contentCache, transport) { throw e; } - if (!meta && path) { + if (!meta && resolved && resolved === path) { + // Throw ENOENT only when not redirected; redirected paths fall through to the transport's fallback. const e = new Error( `ENOENT: no such file or directory, open '${path}'`, ); @@ -71,16 +74,25 @@ export function createFsPromises(metadataCache, contentCache, transport) { throw e; } - result = contentCache.get(path); + result = contentCache.get(resolved); } if (result === null) { - result = await transport.readFile(path, encoding); - contentCache.set(path, result); + try { + result = await transport.readFile(resolved, encoding); + } catch (e) { + if (resolved !== path && e.code === "ENOENT") { + result = await transport.readFile(path, encoding); + } else { + throw e; + } + } + + contentCache.set(resolved, result); } // Apply registered read transforms (e.g., patching synced config files). - result = applyReadTransform(path, result); + result = applyReadTransform(resolved, result); if (wantText) { return typeof result === "string" @@ -100,62 +112,74 @@ export function createFsPromises(metadataCache, contentCache, transport) { encoding = encoding?.encoding; } - markLocalOp(path); - contentCache.set(path, data); + const resolved = resolvePath(path); + const transformed = applyWriteTransform(resolved, data); + + markLocalOp(resolved); + contentCache.set(resolved, transformed); const size = - typeof data === "string" ? data.length : data.byteLength || 0; + typeof transformed === "string" + ? transformed.length + : transformed.byteLength || 0; - metadataCache.set(path, { + metadataCache.set(resolved, { type: "file", size, mtime: Date.now(), - ctime: metadataCache.get(path)?.ctime || Date.now(), + ctime: metadataCache.get(resolved)?.ctime || Date.now(), }); - const result = await transport.writeFile(path, data, encoding); + const result = await transport.writeFile(resolved, transformed, encoding); if (result.mtime) { - metadataCache.set(path, { + metadataCache.set(resolved, { type: "file", size: result.size || size, mtime: result.mtime, - ctime: metadataCache.get(path)?.ctime || Date.now(), + ctime: metadataCache.get(resolved)?.ctime || Date.now(), }); } }, async appendFile(path, data, encoding) { - markLocalOp(path); - contentCache.invalidate(path); + const resolved = resolvePath(path); - await transport.appendFile(path, data); + markLocalOp(resolved); + contentCache.invalidate(resolved); - const meta = await transport.stat(path); - metadataCache.set(path, meta); + await transport.appendFile(resolved, data); + + const meta = await transport.stat(resolved); + metadataCache.set(resolved, meta); }, async unlink(path) { - markLocalOp(path); - contentCache.delete(path); - metadataCache.delete(path); + const resolved = resolvePath(path); - await transport.unlink(path); + markLocalOp(resolved); + contentCache.delete(resolved); + metadataCache.delete(resolved); + + await transport.unlink(resolved); }, async rename(oldPath, newPath) { - markLocalOp(oldPath); - markLocalOp(newPath); - const content = contentCache.get(oldPath); + const resolvedOld = resolvePath(oldPath); + const resolvedNew = resolvePath(newPath); + + markLocalOp(resolvedOld); + markLocalOp(resolvedNew); + const content = contentCache.get(resolvedOld); if (content !== null) { - contentCache.set(newPath, content); - contentCache.delete(oldPath); + contentCache.set(resolvedNew, content); + contentCache.delete(resolvedOld); } - metadataCache.rename(oldPath, newPath); + metadataCache.rename(resolvedOld, resolvedNew); - await transport.rename(oldPath, newPath); + await transport.rename(resolvedOld, resolvedNew); }, async mkdir(path, options) { @@ -178,23 +202,29 @@ export function createFsPromises(metadataCache, contentCache, transport) { const recursive = typeof options === "object" ? !!options.recursive : false; - markLocalOp(path); - metadataCache.delete(path); - contentCache.delete(path); + const resolved = resolvePath(path); - await transport.rm(path, recursive); + markLocalOp(resolved); + metadataCache.delete(resolved); + contentCache.delete(resolved); + + await transport.rm(resolved, recursive); }, async copyFile(src, dest) { - markLocalOp(dest); - await transport.copyFile(src, dest); + const resolvedDest = resolvePath(dest); - const meta = await transport.stat(dest); - metadataCache.set(dest, meta); + markLocalOp(resolvedDest); + await transport.copyFile(src, resolvedDest); + + const meta = await transport.stat(resolvedDest); + metadataCache.set(resolvedDest, meta); }, async access(path) { - if (metadataCache.has(path)) { + const resolved = resolvePath(path); + + if (metadataCache.has(resolved)) { return; } @@ -214,18 +244,21 @@ export function createFsPromises(metadataCache, contentCache, transport) { }, async utimes(path, atime, mtime) { - await transport.utimes(path, atime, mtime); - const meta = metadataCache.get(path); + const resolved = resolvePath(path); + + await transport.utimes(resolved, atime, mtime); + const meta = metadataCache.get(resolved); if (meta) { meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime(); - metadataCache.set(path, meta); + metadataCache.set(resolved, meta); } }, async open(path, flags) { const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; + const resolved = resolvePath(path); - if (!hasInCache && !metadataCache.has(path)) { + if (!hasInCache && !metadataCache.has(resolved)) { const err = new Error( `ENOENT: no such file or directory, open '${path}'`, ); @@ -237,7 +270,7 @@ export function createFsPromises(metadataCache, contentCache, transport) { const fileData = typeof data === "string" ? new TextEncoder().encode(data) : data; - const fileStat = metadataCache.toStat(path) || { + const fileStat = metadataCache.toStat(resolved) || { size: fileData.length, isFile: () => true, isDirectory: () => false, diff --git a/src/shims/fs/read-transforms.js b/src/shims/fs/read-transforms.js deleted file mode 100644 index 365c631..0000000 --- a/src/shims/fs/read-transforms.js +++ /dev/null @@ -1,39 +0,0 @@ -// Post-read transforms for specific file paths. -// Allows patching file content after reading but before returning to the caller. -// Used to prevent synced config files from activating conflicting features. - -const transforms = new Map(); - -function normalize(p) { - return (p || "") - .replace(/\\/g, "/") - .replace(/^\/+/, "") - .replace(/\/+$/, ""); -} - -export function registerReadTransform(path, fn) { - transforms.set(normalize(path), fn); -} - -export function removeReadTransform(path) { - transforms.delete(normalize(path)); -} - -export function applyReadTransform(path, data) { - const norm = normalize(path); - const fn = transforms.get(norm); - - if (!fn) { - return data; - } - - try { - return fn(data); - } catch { - return data; - } -} - -export function hasReadTransform(path) { - return transforms.has(normalize(path)); -} diff --git a/src/shims/fs/sync.js b/src/shims/fs/sync.js index 59c3f2a..08549d1 100644 --- a/src/shims/fs/sync.js +++ b/src/shims/fs/sync.js @@ -1,6 +1,10 @@ import { markLocalOp } from "./echo-guard.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js"; -import { applyReadTransform } from "./read-transforms.js"; +import { + applyReadTransform, + applyWriteTransform, + resolvePath, +} from "./transforms.js"; export function createFsSync(metadataCache, contentCache, transport) { return { @@ -9,7 +13,8 @@ export function createFsSync(metadataCache, contentCache, transport) { return true; } - return metadataCache.has(path); + const resolved = resolvePath(path); + return metadataCache.has(resolved); }, statSync(path) { @@ -27,7 +32,8 @@ export function createFsSync(metadataCache, contentCache, transport) { }; } - const stat = metadataCache.toStat(path); + const resolved = resolvePath(path); + const stat = metadataCache.toStat(resolved); if (!stat) { const err = new Error( @@ -45,7 +51,9 @@ export function createFsSync(metadataCache, contentCache, transport) { return; } - if (!metadataCache.has(path)) { + const resolved = resolvePath(path); + + if (!metadataCache.has(resolved)) { const err = new Error( `ENOENT: no such file or directory, access '${path}'`, ); @@ -60,8 +68,9 @@ export function createFsSync(metadataCache, contentCache, transport) { } const wantText = encoding === "utf8" || encoding === "utf-8"; + const resolved = resolvePath(path); - const meta = metadataCache.get(path); + const meta = metadataCache.get(resolved); if (meta && meta.type === "directory") { const e = new Error("EISDIR: illegal operation on a directory, read"); e.code = "EISDIR"; @@ -80,17 +89,31 @@ export function createFsSync(metadataCache, contentCache, transport) { } if (result === null) { - result = contentCache.get(path); + result = contentCache.get(resolved); } if (result === null) { - console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path); - result = transport.readFileSync(path, encoding); - contentCache.set(path, result); + // ENOENT fallback: if the resolved path doesn't exist, try the original. + // Covers per-name workspace files that haven't been saved yet. + try { + result = transport.readFileSync(resolved, encoding); + } catch (e) { + if (resolved !== path && e.code === "ENOENT") { + console.warn( + "[shim:fs] readFileSync cache miss, using sync XHR:", + path, + ); + result = transport.readFileSync(path, encoding); + } else { + throw e; + } + } + + contentCache.set(resolved, result); } // Apply registered read transforms (e.g., patching synced config files). - result = applyReadTransform(path, result); + result = applyReadTransform(resolved, result); if (wantText) { return typeof result === "string" @@ -106,40 +129,47 @@ export function createFsSync(metadataCache, contentCache, transport) { encoding = encoding?.encoding; } - markLocalOp(path); - contentCache.set(path, data); + const resolved = resolvePath(path); + const transformed = applyWriteTransform(resolved, data); + + markLocalOp(resolved); + contentCache.set(resolved, transformed); const size = - typeof data === "string" ? data.length : data.byteLength || 0; + typeof transformed === "string" + ? transformed.length + : transformed.byteLength || 0; - metadataCache.set(path, { + metadataCache.set(resolved, { type: "file", size, mtime: Date.now(), - ctime: metadataCache.get(path)?.ctime || Date.now(), + ctime: metadataCache.get(resolved)?.ctime || Date.now(), }); // Fire-and-forget async send to server - transport.writeFile(path, data, encoding).catch((e) => { + transport.writeFile(resolved, transformed, encoding).catch((e) => { console.error( "[shim:fs] writeFileSync background save failed:", - path, + resolved, e, ); }); }, unlinkSync(path) { - markLocalOp(path); - contentCache.delete(path); - metadataCache.delete(path); + const resolved = resolvePath(path); + + markLocalOp(resolved); + contentCache.delete(resolved); + metadataCache.delete(resolved); // Fire-and-forget. suppress ENOENT (file already gone) - transport.unlink(path).catch((e) => { + transport.unlink(resolved).catch((e) => { if (e.code !== "ENOENT") { console.error( "[shim:fs] unlinkSync background delete failed:", - path, + resolved, e, ); } diff --git a/src/shims/fs/transforms.js b/src/shims/fs/transforms.js new file mode 100644 index 0000000..42e754d --- /dev/null +++ b/src/shims/fs/transforms.js @@ -0,0 +1,89 @@ +// FS shim translation registry. +// Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write. +// All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes. + +function normalize(p) { + return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); +} + +// --- Path resolvers --- + +const pathResolvers = []; + +export function registerPathResolver(matcher, resolver) { + pathResolvers.push({ matcher, resolver }); +} + +export function resolvePath(path) { + const norm = normalize(path); + + for (const { matcher, resolver } of pathResolvers) { + try { + if (matcher(norm)) { + const resolved = resolver(norm); + + if (typeof resolved === "string" && resolved.length > 0) { + return resolved; + } + } + } catch {} + } + + return norm; +} + +// --- Read transforms --- + +const readTransforms = new Map(); + +export function registerReadTransform(path, fn) { + readTransforms.set(normalize(path), fn); +} + +export function removeReadTransform(path) { + readTransforms.delete(normalize(path)); +} + +export function applyReadTransform(path, data) { + const fn = readTransforms.get(normalize(path)); + + if (!fn) { + return data; + } + + try { + return fn(data); + } catch { + return data; + } +} + +export function hasReadTransform(path) { + return readTransforms.has(normalize(path)); +} + +// --- Write transforms --- + +const writeTransforms = new Map(); + +export function registerWriteTransform(path, fn) { + writeTransforms.set(normalize(path), fn); +} + +export function removeWriteTransform(path) { + writeTransforms.delete(normalize(path)); +} + +export function applyWriteTransform(path, data) { + const fn = writeTransforms.get(normalize(path)); + + if (!fn) { + return data; + } + + try { + return fn(data); + } catch { + return data; + } +} diff --git a/src/shims/fs/transport.js b/src/shims/fs/transport.js index 63ded46..1298720 100644 --- a/src/shims/fs/transport.js +++ b/src/shims/fs/transport.js @@ -1,10 +1,4 @@ -import { - rewriteWorkspacePath, - rewriteWorkspacesContent, -} from "../workspace.js"; - const API_BASE = "/api/fs"; -const WORKSPACES_PATH = ".obsidian/workspaces.json"; function normPath(p) { return (p || "").replace(/^\/+/, ""); @@ -116,26 +110,10 @@ export const transport = { }, async readFile(path, encoding) { - const norm = normPath(path); - const rewritten = rewriteWorkspacePath(norm); - - let res; - - try { - res = await request("GET", "/readFile", { - path: rewritten, - encoding: encoding || "", - }); - } catch (e) { - if (rewritten !== norm && e.code === "ENOENT") { - res = await request("GET", "/readFile", { - path: norm, - encoding: encoding || "", - }); - } else { - throw e; - } - } + const res = await request("GET", "/readFile", { + path: normPath(path), + encoding: encoding || "", + }); if (encoding === "utf8" || encoding === "utf-8") { return res.text(); @@ -146,17 +124,10 @@ export const transport = { }, async writeFile(path, content, encoding) { - const norm = normPath(path); - let data = content; - - if (norm === WORKSPACES_PATH && typeof data === "string") { - data = rewriteWorkspacesContent(data); - } - - const isText = typeof data === "string"; + const isText = typeof content === "string"; return requestJson("POST", "/writeFile", { - path: rewriteWorkspacePath(norm), - content: isText ? data : uint8ToBase64(data), + path: normPath(path), + content: isText ? content : uint8ToBase64(content), encoding: encoding || (isText ? "utf-8" : "binary"), base64: !isText, }); @@ -222,26 +193,10 @@ export const transport = { }, readFileSync(path, encoding) { - const norm = normPath(path); - const rewritten = rewriteWorkspacePath(norm); - - let xhr; - - try { - xhr = requestSync("GET", "/readFile", { - path: rewritten, - encoding: encoding || "", - }); - } catch (e) { - if (rewritten !== norm && e.code === "ENOENT") { - xhr = requestSync("GET", "/readFile", { - path: norm, - encoding: encoding || "", - }); - } else { - throw e; - } - } + const xhr = requestSync("GET", "/readFile", { + path: normPath(path), + encoding: encoding || "", + }); if (encoding === "utf8" || encoding === "utf-8") { return xhr.responseText; @@ -258,17 +213,10 @@ export const transport = { }, writeFileSync(path, content, encoding) { - const norm = normPath(path); - let data = content; - - if (norm === WORKSPACES_PATH && typeof data === "string") { - data = rewriteWorkspacesContent(data); - } - - const isText = typeof data === "string"; + const isText = typeof content === "string"; requestSync("POST", "/writeFile", { - path: rewriteWorkspacePath(norm), - content: isText ? data : uint8ToBase64(data), + path: normPath(path), + content: isText ? content : uint8ToBase64(content), encoding: encoding || (isText ? "utf-8" : "binary"), base64: !isText, }); diff --git a/src/shims/init.js b/src/shims/init.js index 5139a93..c9cad05 100644 --- a/src/shims/init.js +++ b/src/shims/init.js @@ -2,7 +2,7 @@ import { fsShim } from "./fs/index.js"; import { installRequestUrlShim } from "./request-url.js"; import { vaultService } from "../services/vault-service.js"; import { showPluginInstallDialog } from "../ui/bootstrap.js"; -import { registerReadTransform } from "./fs/read-transforms.js"; +import { registerReadTransform } from "./fs/transforms.js"; import { resolveWorkspaceName, initWorkspacePatch } from "./workspace.js"; import { prefetchVaultContent } from "./fs/indexer-prefetch.js"; diff --git a/src/shims/workspace.js b/src/shims/workspace.js index d9f8a42..9cd0c42 100644 --- a/src/shims/workspace.js +++ b/src/shims/workspace.js @@ -1,30 +1,31 @@ import { fsShim } from "./fs/index.js"; -import { registerReadTransform } from "./fs/read-transforms.js"; +import { + registerPathResolver, + registerReadTransform, + registerWriteTransform, +} from "./fs/transforms.js"; const WORKSPACE_PATH = ".obsidian/workspace.json"; const WORKSPACES_PATH = ".obsidian/workspaces.json"; -export function rewriteWorkspacePath(normalizedPath) { - const name = window.__workspaceName; +// Redirect workspace.json to a per-name file when a workspace is active in this tab. +registerPathResolver( + (path) => path === WORKSPACE_PATH && !!window.__workspaceName, + () => `.obsidian/workspace.${window.__workspaceName}.json`, +); - if (!name) { - return normalizedPath; - } - - if (normalizedPath === WORKSPACE_PATH) { - return `.obsidian/workspace.${name}.json`; - } - - return normalizedPath; -} - -export function rewriteWorkspacesContent(content) { +// Keep workspaces.json's active field at the canonical value on disk so other tabs see a stable state. +registerWriteTransform(WORKSPACES_PATH, (content) => { const original = window.__originalActiveWorkspace; if (!original || !window.__workspaceName) { return content; } + if (typeof content !== "string") { + return content; + } + try { const parsed = JSON.parse(content); @@ -35,7 +36,7 @@ export function rewriteWorkspacesContent(content) { } catch {} return content; -} +}); function setWorkspaceParam(name) { const url = new URL(window.location.href); @@ -136,29 +137,33 @@ export function initWorkspacePatch() { instance.loadWorkspace = function (name) { window.__workspaceName = name; setWorkspaceParam(name); - fsShim._contentCache.invalidate(".obsidian/workspace.json"); + fsShim.invalidate(WORKSPACE_PATH); return origLoad(name); }; instance.saveWorkspace = function (name) { - // Grab the current layout before switching the transport target. - const currentLayout = fsShim._contentCache.get(".obsidian/workspace.json"); + // Grab the current layout before changing __workspaceName. + let currentLayout = null; + + try { + currentLayout = fsShim.readFileSync(WORKSPACE_PATH, "utf-8"); + } catch {} window.__workspaceName = name; setWorkspaceParam(name); - fsShim._contentCache.invalidate(".obsidian/workspace.json"); + fsShim.invalidate(WORKSPACE_PATH); const result = origSave(name); // Write the layout to the new workspace file so it exists on disk immediately. if (currentLayout) { - fsShim.writeFileSync(".obsidian/workspace.json", currentLayout, "utf-8"); + fsShim.writeFileSync(WORKSPACE_PATH, currentLayout, "utf-8"); } return result; }; // Override the active field on reads so the menu matches this tab's workspace. - registerReadTransform(".obsidian/workspaces.json", (data) => { + registerReadTransform(WORKSPACES_PATH, (data) => { if (!window.__workspaceName) { return data; }