improve sync input workaround

This commit is contained in:
Nystik
2026-03-29 22:03:42 +02:00
parent f14cac6490
commit 4a4d904420
5 changed files with 215 additions and 13 deletions

View File

@@ -3,7 +3,7 @@ import {
showConfirmDialog,
showPromptDialog,
} from "../../../ui/bootstrap.js";
import { transport } from "../../fs/transport.js";
import { inputCacheSet, inputCacheDelete } from "../../fs/input-cache.js";
const IMPORTS_DIR = ".obsidian/imports";
const STAGED_TTL_MS = 120_000; // 2 minutes
@@ -24,7 +24,7 @@ function clearStagedFiles() {
console.log("[shim:dialog] Clearing expired staged files");
for (const p of staged.paths) {
transport.unlink(p.replace(/^\//, "")).catch(() => {});
inputCacheDelete(p.replace(/^\//, ""));
}
staged = { paths: [], fingerprint: null, timestamp: 0 };
@@ -72,12 +72,12 @@ function pickFiles(accept, multiple) {
});
}
async function uploadToImports(file) {
async function cacheToImports(file) {
const arrayBuffer = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
const targetPath = IMPORTS_DIR + "/" + file.name;
await transport.writeFile(targetPath, bytes);
inputCacheSet(targetPath, bytes);
return "/" + targetPath;
}
@@ -96,7 +96,7 @@ async function startWorkaroundFlow(options, fingerprint) {
const paths = [];
for (const file of files) {
const vaultPath = await uploadToImports(file);
const vaultPath = await cacheToImports(file);
paths.push(vaultPath);
}
@@ -108,7 +108,7 @@ async function startWorkaroundFlow(options, fingerprint) {
await showMessageDialog(
"Files Ready",
`Uploaded: ${names}\n\nPlease retry the action that brought you here. ` +
`Staged: ${names}\n\nPlease retry the action that brought you here. ` +
"The files will be provided automatically.",
);
}
@@ -134,11 +134,11 @@ export const dialogShim = {
const filePaths = [];
for (const file of files) {
const vaultPath = await uploadToImports(file);
const vaultPath = await cacheToImports(file);
filePaths.push(vaultPath);
}
console.log("[shim:dialog] showOpenDialog - uploaded:", filePaths);
console.log("[shim:dialog] showOpenDialog - cached:", filePaths);
return { canceled: false, filePaths };
},
@@ -187,9 +187,10 @@ export const dialogShim = {
showConfirmDialog(
"Feature Not Available",
"This action requires a native file picker which is not available in the browser.",
"A workaround is available: upload your file first, then retry the action. " +
"Would you like to proceed?",
"Upload File",
"A workaround is available: select your files first, then retry the action. " +
"They will be provided automatically.\n\n" +
"Note: individual files must be under 200 MB.",
"Select Files",
).then((confirmed) => {
if (confirmed) {
startWorkaroundFlow(options, callerFingerprint);

View File

@@ -2,11 +2,26 @@
// Enables libraries like yauzl that use fs.open/fs.read/fs.close to seek
// around files without loading them via readFileSync upfront.
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
let nextFd = 100;
const openFiles = new Map();
export function createFdOps(metadataCache, contentCache, transport) {
function ensureData(path) {
// Check input cache first for files picked via browser file dialogs.
if (isInputCachePath(path)) {
const inputData = inputCacheGet(path);
if (inputData !== null) {
if (typeof inputData === "string") {
return new TextEncoder().encode(inputData);
}
return inputData;
}
}
const cached = contentCache.get(path);
if (cached !== null) {
@@ -40,7 +55,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
// --- Sync ---
function openSync(path, flags, mode) {
if (!metadataCache.has(path)) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
if (!hasInCache && !metadataCache.has(path)) {
const err = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);

123
src/shims/fs/input-cache.js Normal file
View File

@@ -0,0 +1,123 @@
// Dedicated cache for files picked via browser file dialogs.
// Avoids server round trips for input-only files (e.g., importer plugin).
//
// - 200MB size limit (higher than content cache; import batches can be large)
// - 5-minute TTL per entry
// - Entries kept until TTL expires (plugins may read the same file multiple times)
const MAX_SIZE = 200 * 1024 * 1024;
const TTL_MS = 5 * 60 * 1000;
const cache = new Map(); // path -> { data, size, createdAt }
let currentSize = 0;
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
function evictExpired() {
const now = Date.now();
for (const [key, entry] of cache) {
if (now - entry.createdAt > TTL_MS) {
currentSize -= entry.size;
cache.delete(key);
}
}
}
function evictOldest() {
let oldest = null;
let oldestTime = Infinity;
for (const [key, entry] of cache) {
if (entry.createdAt < oldestTime) {
oldest = key;
oldestTime = entry.createdAt;
}
}
if (oldest) {
currentSize -= cache.get(oldest).size;
cache.delete(oldest);
}
}
export function inputCacheHas(path) {
const norm = normalize(path);
const entry = cache.get(norm);
if (!entry) {
return false;
}
if (Date.now() - entry.createdAt > TTL_MS) {
currentSize -= entry.size;
cache.delete(norm);
return false;
}
return true;
}
export function inputCacheGet(path) {
const norm = normalize(path);
const entry = cache.get(norm);
if (!entry) {
return null;
}
if (Date.now() - entry.createdAt > TTL_MS) {
currentSize -= entry.size;
cache.delete(norm);
return null;
}
return entry.data;
}
export function inputCacheSet(path, data) {
const norm = normalize(path);
const size = data ? data.length || data.byteLength || 0 : 0;
// Remove existing entry if replacing
if (cache.has(norm)) {
currentSize -= cache.get(norm).size;
cache.delete(norm);
}
// Evict expired entries first
evictExpired();
// Evict oldest entries if still over limit
while (currentSize + size > MAX_SIZE && cache.size > 0) {
evictOldest();
}
cache.set(norm, { data, size, createdAt: Date.now() });
currentSize += size;
}
export function inputCacheDelete(path) {
const norm = normalize(path);
const entry = cache.get(norm);
if (entry) {
currentSize -= entry.size;
cache.delete(norm);
}
}
export function inputCacheClear() {
cache.clear();
currentSize = 0;
}
export function isInputCachePath(path) {
const norm = normalize(path);
return norm.startsWith(".obsidian/imports/");
}

View File

@@ -1,4 +1,5 @@
import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
export function createFsPromises(metadataCache, contentCache, transport) {
return {
@@ -45,6 +46,25 @@ export function createFsPromises(metadataCache, contentCache, transport) {
const wantText = encoding === "utf8" || encoding === "utf-8";
// Check input cache for files picked via browser file dialogs.
if (isInputCachePath(path)) {
const inputData = inputCacheGet(path);
if (inputData !== null) {
if (wantText) {
return typeof inputData === "string"
? inputData
: new TextDecoder().decode(inputData);
}
if (typeof inputData === "string") {
return new TextEncoder().encode(inputData);
}
return inputData;
}
}
const meta = metadataCache.get(path);
if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read");
@@ -210,7 +230,9 @@ export function createFsPromises(metadataCache, contentCache, transport) {
},
async open(path, flags) {
if (!metadataCache.has(path)) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
if (!hasInCache && !metadataCache.has(path)) {
const err = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);

View File

@@ -1,12 +1,31 @@
import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
export function createFsSync(metadataCache, contentCache, transport) {
return {
existsSync(path) {
if (isInputCachePath(path) && inputCacheGet(path) !== null) {
return true;
}
return metadataCache.has(path);
},
statSync(path) {
if (isInputCachePath(path) && inputCacheGet(path) !== null) {
const data = inputCacheGet(path);
const size = data ? data.length || data.byteLength || 0 : 0;
return {
size,
mtime: new Date(),
ctime: new Date(),
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
};
}
const stat = metadataCache.toStat(path);
if (!stat) {
@@ -21,6 +40,10 @@ export function createFsSync(metadataCache, contentCache, transport) {
},
accessSync(path, mode) {
if (isInputCachePath(path) && inputCacheGet(path) !== null) {
return;
}
if (!metadataCache.has(path)) {
const err = new Error(
`ENOENT: no such file or directory, access '${path}'`,
@@ -42,6 +65,22 @@ export function createFsSync(metadataCache, contentCache, transport) {
throw e;
}
// Check input cache for files picked via browser file dialogs.
// These never hit the server; they exist only in browser memory.
if (isInputCachePath(path)) {
const inputData = inputCacheGet(path);
if (inputData !== null) {
if (encoding === "utf8" || encoding === "utf-8") {
return typeof inputData === "string"
? inputData
: new TextDecoder().decode(inputData);
}
return inputData;
}
}
const cached = contentCache.get(path);
if (cached !== null) {
if (encoding === "utf8" || encoding === "utf-8") {