rewrite transforms, implement unified transform layer

This commit is contained in:
Nystik 2026-05-15 03:42:56 +02:00
parent d8c43c20f4
commit 47d39098cd
10 changed files with 289 additions and 206 deletions

View file

@ -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. 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.<name>.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
IPC is implemented as a synchronous dispatcher that maps channel names to handlers. IPC is implemented as a synchronous dispatcher that maps channel names to handlers.

View file

@ -3,6 +3,7 @@
// around files without loading them via readFileSync upfront. // around files without loading them via readFileSync upfront.
import { isInputCachePath, inputCacheGet } from "./input-cache.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { resolvePath } from "./transforms.js";
let nextFd = 100; let nextFd = 100;
const openFiles = new Map(); 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 (cached !== null) {
if (typeof cached === "string") { if (typeof cached === "string") {
@ -33,9 +35,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
} }
// Synchronous fetch fallback // Synchronous fetch fallback
console.warn("[shim:fs] fd open cache miss, using sync XHR:", path); console.warn("[shim:fs] fd open cache miss, using sync XHR:", resolved);
const data = transport.readFileSync(path); const data = transport.readFileSync(resolved);
contentCache.set(path, data); contentCache.set(resolved, data);
return data; return data;
} }
@ -56,8 +58,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
function openSync(path, flags, mode) { function openSync(path, flags, mode) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; 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( const err = new Error(
`ENOENT: no such file or directory, open '${path}'`, `ENOENT: no such file or directory, open '${path}'`,
); );
@ -67,7 +70,7 @@ export function createFdOps(metadataCache, contentCache, transport) {
const data = ensureData(path); const data = ensureData(path);
const fd = nextFd++; const fd = nextFd++;
openFiles.set(fd, { path, data }); openFiles.set(fd, { path: resolved, data });
return fd; return fd;
} }

View file

@ -7,7 +7,7 @@ import { createFsWatch } from "./watch.js";
import { createWatcherClient } from "./watcher-client.js"; import { createWatcherClient } from "./watcher-client.js";
import { createFdOps } from "./fd.js"; import { createFdOps } from "./fd.js";
import { constants } from "./constants.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 metadataCache = new MetadataCache();
const contentCache = new ContentCache(); const contentCache = new ContentCache();
@ -41,6 +41,10 @@ export const fsShim = {
watch: fsWatch.watch, watch: fsWatch.watch,
constants, constants,
invalidate(path) {
contentCache.invalidate(resolvePath(path));
},
_metadataCache: metadataCache, _metadataCache: metadataCache,
_contentCache: contentCache, _contentCache: contentCache,
_watcherClient: watcherClient, _watcherClient: watcherClient,

View file

@ -1,19 +1,20 @@
import { markLocalOp } from "./echo-guard.js"; import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.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) { export function createFsPromises(metadataCache, contentCache, transport) {
return { return {
async stat(path) { async stat(path) {
const cached = metadataCache.toStat(path); const resolved = resolvePath(path);
const cached = metadataCache.toStat(resolved);
if (cached) { if (cached) {
return cached; return cached;
} }
const meta = await transport.stat(path); const meta = await transport.stat(resolved);
metadataCache.set(path, meta); metadataCache.set(resolved, meta);
return metadataCache.toStat(path); return metadataCache.toStat(resolved);
}, },
async lstat(path) { async lstat(path) {
@ -46,6 +47,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
} }
const wantText = encoding === "utf8" || encoding === "utf-8"; const wantText = encoding === "utf8" || encoding === "utf-8";
const resolved = resolvePath(path);
let result = null; let result = null;
@ -55,7 +57,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
} }
if (result === null) { if (result === null) {
const meta = metadataCache.get(path); const meta = metadataCache.get(resolved);
if (meta && meta.type === "directory") { if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read"); const e = new Error("EISDIR: illegal operation on a directory, read");
@ -63,7 +65,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
throw e; 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( const e = new Error(
`ENOENT: no such file or directory, open '${path}'`, `ENOENT: no such file or directory, open '${path}'`,
); );
@ -71,16 +74,25 @@ export function createFsPromises(metadataCache, contentCache, transport) {
throw e; throw e;
} }
result = contentCache.get(path); result = contentCache.get(resolved);
} }
if (result === null) { if (result === null) {
try {
result = await transport.readFile(resolved, encoding);
} catch (e) {
if (resolved !== path && e.code === "ENOENT") {
result = await transport.readFile(path, encoding); result = await transport.readFile(path, encoding);
contentCache.set(path, result); } else {
throw e;
}
}
contentCache.set(resolved, result);
} }
// Apply registered read transforms (e.g., patching synced config files). // Apply registered read transforms (e.g., patching synced config files).
result = applyReadTransform(path, result); result = applyReadTransform(resolved, result);
if (wantText) { if (wantText) {
return typeof result === "string" return typeof result === "string"
@ -100,62 +112,74 @@ export function createFsPromises(metadataCache, contentCache, transport) {
encoding = encoding?.encoding; encoding = encoding?.encoding;
} }
markLocalOp(path); const resolved = resolvePath(path);
contentCache.set(path, data); const transformed = applyWriteTransform(resolved, data);
markLocalOp(resolved);
contentCache.set(resolved, transformed);
const size = 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", type: "file",
size, size,
mtime: Date.now(), 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) { if (result.mtime) {
metadataCache.set(path, { metadataCache.set(resolved, {
type: "file", type: "file",
size: result.size || size, size: result.size || size,
mtime: result.mtime, mtime: result.mtime,
ctime: metadataCache.get(path)?.ctime || Date.now(), ctime: metadataCache.get(resolved)?.ctime || Date.now(),
}); });
} }
}, },
async appendFile(path, data, encoding) { async appendFile(path, data, encoding) {
markLocalOp(path); const resolved = resolvePath(path);
contentCache.invalidate(path);
await transport.appendFile(path, data); markLocalOp(resolved);
contentCache.invalidate(resolved);
const meta = await transport.stat(path); await transport.appendFile(resolved, data);
metadataCache.set(path, meta);
const meta = await transport.stat(resolved);
metadataCache.set(resolved, meta);
}, },
async unlink(path) { async unlink(path) {
markLocalOp(path); const resolved = resolvePath(path);
contentCache.delete(path);
metadataCache.delete(path);
await transport.unlink(path); markLocalOp(resolved);
contentCache.delete(resolved);
metadataCache.delete(resolved);
await transport.unlink(resolved);
}, },
async rename(oldPath, newPath) { async rename(oldPath, newPath) {
markLocalOp(oldPath); const resolvedOld = resolvePath(oldPath);
markLocalOp(newPath); const resolvedNew = resolvePath(newPath);
const content = contentCache.get(oldPath);
markLocalOp(resolvedOld);
markLocalOp(resolvedNew);
const content = contentCache.get(resolvedOld);
if (content !== null) { if (content !== null) {
contentCache.set(newPath, content); contentCache.set(resolvedNew, content);
contentCache.delete(oldPath); 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) { async mkdir(path, options) {
@ -178,23 +202,29 @@ export function createFsPromises(metadataCache, contentCache, transport) {
const recursive = const recursive =
typeof options === "object" ? !!options.recursive : false; typeof options === "object" ? !!options.recursive : false;
markLocalOp(path); const resolved = resolvePath(path);
metadataCache.delete(path);
contentCache.delete(path);
await transport.rm(path, recursive); markLocalOp(resolved);
metadataCache.delete(resolved);
contentCache.delete(resolved);
await transport.rm(resolved, recursive);
}, },
async copyFile(src, dest) { async copyFile(src, dest) {
markLocalOp(dest); const resolvedDest = resolvePath(dest);
await transport.copyFile(src, dest);
const meta = await transport.stat(dest); markLocalOp(resolvedDest);
metadataCache.set(dest, meta); await transport.copyFile(src, resolvedDest);
const meta = await transport.stat(resolvedDest);
metadataCache.set(resolvedDest, meta);
}, },
async access(path) { async access(path) {
if (metadataCache.has(path)) { const resolved = resolvePath(path);
if (metadataCache.has(resolved)) {
return; return;
} }
@ -214,18 +244,21 @@ export function createFsPromises(metadataCache, contentCache, transport) {
}, },
async utimes(path, atime, mtime) { async utimes(path, atime, mtime) {
await transport.utimes(path, atime, mtime); const resolved = resolvePath(path);
const meta = metadataCache.get(path);
await transport.utimes(resolved, atime, mtime);
const meta = metadataCache.get(resolved);
if (meta) { if (meta) {
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime(); meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
metadataCache.set(path, meta); metadataCache.set(resolved, meta);
} }
}, },
async open(path, flags) { async open(path, flags) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; 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( const err = new Error(
`ENOENT: no such file or directory, open '${path}'`, `ENOENT: no such file or directory, open '${path}'`,
); );
@ -237,7 +270,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
const fileData = const fileData =
typeof data === "string" ? new TextEncoder().encode(data) : data; typeof data === "string" ? new TextEncoder().encode(data) : data;
const fileStat = metadataCache.toStat(path) || { const fileStat = metadataCache.toStat(resolved) || {
size: fileData.length, size: fileData.length,
isFile: () => true, isFile: () => true,
isDirectory: () => false, isDirectory: () => false,

View file

@ -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));
}

View file

@ -1,6 +1,10 @@
import { markLocalOp } from "./echo-guard.js"; import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.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) { export function createFsSync(metadataCache, contentCache, transport) {
return { return {
@ -9,7 +13,8 @@ export function createFsSync(metadataCache, contentCache, transport) {
return true; return true;
} }
return metadataCache.has(path); const resolved = resolvePath(path);
return metadataCache.has(resolved);
}, },
statSync(path) { 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) { if (!stat) {
const err = new Error( const err = new Error(
@ -45,7 +51,9 @@ export function createFsSync(metadataCache, contentCache, transport) {
return; return;
} }
if (!metadataCache.has(path)) { const resolved = resolvePath(path);
if (!metadataCache.has(resolved)) {
const err = new Error( const err = new Error(
`ENOENT: no such file or directory, access '${path}'`, `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 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") { if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read"); const e = new Error("EISDIR: illegal operation on a directory, read");
e.code = "EISDIR"; e.code = "EISDIR";
@ -80,17 +89,31 @@ export function createFsSync(metadataCache, contentCache, transport) {
} }
if (result === null) { if (result === null) {
result = contentCache.get(path); result = contentCache.get(resolved);
} }
if (result === null) { if (result === null) {
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path); // 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); result = transport.readFileSync(path, encoding);
contentCache.set(path, result); } else {
throw e;
}
}
contentCache.set(resolved, result);
} }
// Apply registered read transforms (e.g., patching synced config files). // Apply registered read transforms (e.g., patching synced config files).
result = applyReadTransform(path, result); result = applyReadTransform(resolved, result);
if (wantText) { if (wantText) {
return typeof result === "string" return typeof result === "string"
@ -106,40 +129,47 @@ export function createFsSync(metadataCache, contentCache, transport) {
encoding = encoding?.encoding; encoding = encoding?.encoding;
} }
markLocalOp(path); const resolved = resolvePath(path);
contentCache.set(path, data); const transformed = applyWriteTransform(resolved, data);
markLocalOp(resolved);
contentCache.set(resolved, transformed);
const size = 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", type: "file",
size, size,
mtime: Date.now(), mtime: Date.now(),
ctime: metadataCache.get(path)?.ctime || Date.now(), ctime: metadataCache.get(resolved)?.ctime || Date.now(),
}); });
// Fire-and-forget async send to server // Fire-and-forget async send to server
transport.writeFile(path, data, encoding).catch((e) => { transport.writeFile(resolved, transformed, encoding).catch((e) => {
console.error( console.error(
"[shim:fs] writeFileSync background save failed:", "[shim:fs] writeFileSync background save failed:",
path, resolved,
e, e,
); );
}); });
}, },
unlinkSync(path) { unlinkSync(path) {
markLocalOp(path); const resolved = resolvePath(path);
contentCache.delete(path);
metadataCache.delete(path); markLocalOp(resolved);
contentCache.delete(resolved);
metadataCache.delete(resolved);
// Fire-and-forget. suppress ENOENT (file already gone) // Fire-and-forget. suppress ENOENT (file already gone)
transport.unlink(path).catch((e) => { transport.unlink(resolved).catch((e) => {
if (e.code !== "ENOENT") { if (e.code !== "ENOENT") {
console.error( console.error(
"[shim:fs] unlinkSync background delete failed:", "[shim:fs] unlinkSync background delete failed:",
path, resolved,
e, e,
); );
} }

View file

@ -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;
}
}

View file

@ -1,10 +1,4 @@
import {
rewriteWorkspacePath,
rewriteWorkspacesContent,
} from "../workspace.js";
const API_BASE = "/api/fs"; const API_BASE = "/api/fs";
const WORKSPACES_PATH = ".obsidian/workspaces.json";
function normPath(p) { function normPath(p) {
return (p || "").replace(/^\/+/, ""); return (p || "").replace(/^\/+/, "");
@ -116,26 +110,10 @@ export const transport = {
}, },
async readFile(path, encoding) { async readFile(path, encoding) {
const norm = normPath(path); const res = await request("GET", "/readFile", {
const rewritten = rewriteWorkspacePath(norm); path: normPath(path),
let res;
try {
res = await request("GET", "/readFile", {
path: rewritten,
encoding: encoding || "", encoding: encoding || "",
}); });
} catch (e) {
if (rewritten !== norm && e.code === "ENOENT") {
res = await request("GET", "/readFile", {
path: norm,
encoding: encoding || "",
});
} else {
throw e;
}
}
if (encoding === "utf8" || encoding === "utf-8") { if (encoding === "utf8" || encoding === "utf-8") {
return res.text(); return res.text();
@ -146,17 +124,10 @@ export const transport = {
}, },
async writeFile(path, content, encoding) { async writeFile(path, content, encoding) {
const norm = normPath(path); const isText = typeof content === "string";
let data = content;
if (norm === WORKSPACES_PATH && typeof data === "string") {
data = rewriteWorkspacesContent(data);
}
const isText = typeof data === "string";
return requestJson("POST", "/writeFile", { return requestJson("POST", "/writeFile", {
path: rewriteWorkspacePath(norm), path: normPath(path),
content: isText ? data : uint8ToBase64(data), content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"), encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText, base64: !isText,
}); });
@ -222,26 +193,10 @@ export const transport = {
}, },
readFileSync(path, encoding) { readFileSync(path, encoding) {
const norm = normPath(path); const xhr = requestSync("GET", "/readFile", {
const rewritten = rewriteWorkspacePath(norm); path: normPath(path),
let xhr;
try {
xhr = requestSync("GET", "/readFile", {
path: rewritten,
encoding: encoding || "", encoding: encoding || "",
}); });
} catch (e) {
if (rewritten !== norm && e.code === "ENOENT") {
xhr = requestSync("GET", "/readFile", {
path: norm,
encoding: encoding || "",
});
} else {
throw e;
}
}
if (encoding === "utf8" || encoding === "utf-8") { if (encoding === "utf8" || encoding === "utf-8") {
return xhr.responseText; return xhr.responseText;
@ -258,17 +213,10 @@ export const transport = {
}, },
writeFileSync(path, content, encoding) { writeFileSync(path, content, encoding) {
const norm = normPath(path); const isText = typeof content === "string";
let data = content;
if (norm === WORKSPACES_PATH && typeof data === "string") {
data = rewriteWorkspacesContent(data);
}
const isText = typeof data === "string";
requestSync("POST", "/writeFile", { requestSync("POST", "/writeFile", {
path: rewriteWorkspacePath(norm), path: normPath(path),
content: isText ? data : uint8ToBase64(data), content: isText ? content : uint8ToBase64(content),
encoding: encoding || (isText ? "utf-8" : "binary"), encoding: encoding || (isText ? "utf-8" : "binary"),
base64: !isText, base64: !isText,
}); });

View file

@ -2,7 +2,7 @@ import { fsShim } from "./fs/index.js";
import { installRequestUrlShim } from "./request-url.js"; import { installRequestUrlShim } from "./request-url.js";
import { vaultService } from "../services/vault-service.js"; import { vaultService } from "../services/vault-service.js";
import { showPluginInstallDialog } from "../ui/bootstrap.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 { resolveWorkspaceName, initWorkspacePatch } from "./workspace.js";
import { prefetchVaultContent } from "./fs/indexer-prefetch.js"; import { prefetchVaultContent } from "./fs/indexer-prefetch.js";

View file

@ -1,30 +1,31 @@
import { fsShim } from "./fs/index.js"; 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 WORKSPACE_PATH = ".obsidian/workspace.json";
const WORKSPACES_PATH = ".obsidian/workspaces.json"; const WORKSPACES_PATH = ".obsidian/workspaces.json";
export function rewriteWorkspacePath(normalizedPath) { // Redirect workspace.json to a per-name file when a workspace is active in this tab.
const name = window.__workspaceName; registerPathResolver(
(path) => path === WORKSPACE_PATH && !!window.__workspaceName,
() => `.obsidian/workspace.${window.__workspaceName}.json`,
);
if (!name) { // Keep workspaces.json's active field at the canonical value on disk so other tabs see a stable state.
return normalizedPath; registerWriteTransform(WORKSPACES_PATH, (content) => {
}
if (normalizedPath === WORKSPACE_PATH) {
return `.obsidian/workspace.${name}.json`;
}
return normalizedPath;
}
export function rewriteWorkspacesContent(content) {
const original = window.__originalActiveWorkspace; const original = window.__originalActiveWorkspace;
if (!original || !window.__workspaceName) { if (!original || !window.__workspaceName) {
return content; return content;
} }
if (typeof content !== "string") {
return content;
}
try { try {
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
@ -35,7 +36,7 @@ export function rewriteWorkspacesContent(content) {
} catch {} } catch {}
return content; return content;
} });
function setWorkspaceParam(name) { function setWorkspaceParam(name) {
const url = new URL(window.location.href); const url = new URL(window.location.href);
@ -136,29 +137,33 @@ export function initWorkspacePatch() {
instance.loadWorkspace = function (name) { instance.loadWorkspace = function (name) {
window.__workspaceName = name; window.__workspaceName = name;
setWorkspaceParam(name); setWorkspaceParam(name);
fsShim._contentCache.invalidate(".obsidian/workspace.json"); fsShim.invalidate(WORKSPACE_PATH);
return origLoad(name); return origLoad(name);
}; };
instance.saveWorkspace = function (name) { instance.saveWorkspace = function (name) {
// Grab the current layout before switching the transport target. // Grab the current layout before changing __workspaceName.
const currentLayout = fsShim._contentCache.get(".obsidian/workspace.json"); let currentLayout = null;
try {
currentLayout = fsShim.readFileSync(WORKSPACE_PATH, "utf-8");
} catch {}
window.__workspaceName = name; window.__workspaceName = name;
setWorkspaceParam(name); setWorkspaceParam(name);
fsShim._contentCache.invalidate(".obsidian/workspace.json"); fsShim.invalidate(WORKSPACE_PATH);
const result = origSave(name); const result = origSave(name);
// Write the layout to the new workspace file so it exists on disk immediately. // Write the layout to the new workspace file so it exists on disk immediately.
if (currentLayout) { if (currentLayout) {
fsShim.writeFileSync(".obsidian/workspace.json", currentLayout, "utf-8"); fsShim.writeFileSync(WORKSPACE_PATH, currentLayout, "utf-8");
} }
return result; return result;
}; };
// Override the active field on reads so the menu matches this tab's workspace. // 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) { if (!window.__workspaceName) {
return data; return data;
} }