From 1ab522e656ddbeacb1d67546ff49037f0d5ef7a4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 22 Apr 2026 19:39:02 +0300 Subject: [PATCH] fix(session-folders): persist via server endpoint, not client-built home path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client previously built `${homeDirectory}/.config/openchamber/sessions-directories.json` using client-side homeDirectory, which in some boot paths resolved to the active workspace and wrote the file inside the project. Replace with GET/POST /api/session-folders — server uses os.homedir() directly, so the file always lands in ~/.config/openchamber/ regardless of client state. --- .../ui/src/stores/useSessionFoldersStore.ts | 111 ++++-------------- .../web/server/lib/opencode/core-routes.js | 1 + .../lib/opencode/feature-routes-runtime.js | 6 + .../web/server/lib/session-folders/routes.js | 57 +++++++++ 4 files changed, 90 insertions(+), 85 deletions(-) create mode 100644 packages/web/server/lib/session-folders/routes.js diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index 50a9688a..0c09c2b6 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -1,8 +1,6 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { getSafeStorage } from './utils/safeStorage'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { useDirectoryStore } from './useDirectoryStore'; import { isVSCodeRuntime } from '@/lib/desktop'; // --- Types --- @@ -43,7 +41,7 @@ type SessionFoldersStore = SessionFoldersState & SessionFoldersActions; const FOLDERS_STORAGE_KEY = 'oc.sessions.folders'; const COLLAPSED_STORAGE_KEY = 'oc.sessions.folderCollapse'; -const SESSIONS_DIRECTORIES_PATH_SUFFIX = '.config/openchamber/sessions-directories.json'; +const SESSION_FOLDERS_API_PATH = '/api/session-folders'; const DISK_WRITE_DEBOUNCE_MS = 250; const ARCHIVED_SCOPE_PREFIX = '__archived__:'; @@ -68,27 +66,6 @@ const isVSCodeWebview = (): boolean => { return (window as { __VSCODE_CONFIG__?: unknown }).__VSCODE_CONFIG__ !== undefined; }; -const getSessionsDirectoriesPath = (): string | null => { - const directoryState = useDirectoryStore.getState(); - const homeDirectory = typeof directoryState.homeDirectory === 'string' && directoryState.homeDirectory.length > 0 - ? directoryState.homeDirectory - : (safeStorage.getItem('homeDirectory') || ''); - - if (!homeDirectory) { - return null; - } - - return `${homeDirectory.replace(/\/$/, '')}/${SESSIONS_DIRECTORIES_PATH_SUFFIX}`; -}; - -const getParentDirectory = (path: string): string | null => { - const index = path.lastIndexOf('/'); - if (index <= 0) { - return null; - } - return path.slice(0, index); -}; - const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set): void => { if (typeof window === 'undefined') { return; @@ -107,31 +84,17 @@ const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds diskWriteTimer = setTimeout(() => { diskWriteTimer = null; - void (async () => { - const runtimeFiles = getRegisteredRuntimeAPIs()?.files; - if (!runtimeFiles?.writeFile) { - return; - } - - const path = getSessionsDirectoriesPath(); - if (!path) { - return; - } - - const parentDirectory = getParentDirectory(path); - if (parentDirectory) { - await runtimeFiles.createDirectory(parentDirectory).catch(() => undefined); - } - - const payload = { - version: 1, - foldersMap: foldersSnapshot, - collapsedFolderIds: collapsedSnapshot, - updatedAt: Date.now(), - }; - - await runtimeFiles.writeFile(path, JSON.stringify(payload, null, 2)).catch(() => undefined); - })(); + const payload = { + version: 1, + foldersMap: foldersSnapshot, + collapsedFolderIds: collapsedSnapshot, + updatedAt: Date.now(), + }; + void fetch(SESSION_FOLDERS_API_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => { /* best-effort */ }); }, DISK_WRITE_DEBOUNCE_MS); }; @@ -536,35 +499,27 @@ const hydrateSessionFoldersFromDisk = async (): Promise => { return; } - const runtimeFiles = getRegisteredRuntimeAPIs()?.files; - if (!runtimeFiles?.readFile) { - return; - } - - const path = getSessionsDirectoriesPath(); - if (!path) { - return; - } - diskHydrationInFlight = true; - const result = await runtimeFiles.readFile(path).catch(() => null); - if (!result?.content) { - diskHydrationInFlight = false; - diskHydrated = true; - return; - } - try { - const parsed = JSON.parse(result.content) as { + const response = await fetch(SESSION_FOLDERS_API_PATH).catch(() => null); + if (!response || !response.ok) { + return; + } + + const parsed = await response.json().catch(() => null) as { foldersMap?: SessionFoldersMap; collapsedFolderIds?: string[]; - }; + } | null; - const diskFolders = parsed?.foldersMap && typeof parsed.foldersMap === 'object' + if (!parsed) { + return; + } + + const diskFolders = parsed.foldersMap && typeof parsed.foldersMap === 'object' ? parsed.foldersMap : {}; - const diskCollapsed = Array.isArray(parsed?.collapsedFolderIds) + const diskCollapsed = Array.isArray(parsed.collapsedFolderIds) ? new Set(parsed.collapsedFolderIds.filter((value): value is string => typeof value === 'string')) : new Set(); @@ -593,21 +548,7 @@ const bootstrapSessionFoldersDiskHydration = (): void => { return; } - let attempts = 0; - const maxAttempts = 20; - - const runAttempt = () => { - attempts += 1; - void hydrateSessionFoldersFromDisk(); - - if (diskHydrated || attempts >= maxAttempts) { - return; - } - - setTimeout(runAttempt, 500); - }; - - runAttempt(); + void hydrateSessionFoldersFromDisk(); }; bootstrapSessionFoldersDiskHydration(); diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 41865b44..d25cb9ca 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -261,6 +261,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/opencode') || req.path.startsWith('/api/push') || req.path.startsWith('/api/notifications') || + req.path.startsWith('/api/session-folders') || req.path.startsWith('/api/text') || req.path.startsWith('/api/voice') || req.path.startsWith('/api/tts') || diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 3a7d2761..25c52f40 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -3,6 +3,7 @@ import { registerQuotaRoutes } from '../quota/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; import { registerGitRoutes } from '../git/routes.js'; import { registerMagicPromptRoutes } from '../magic-prompts/routes.js'; +import { registerSessionFoldersRoutes } from '../session-folders/routes.js'; import { registerConfigEntityRoutes } from './config-entity-routes.js'; import { registerSettingsUtilityRoutes } from './core-routes.js'; import { registerProjectIconRoutes } from './project-icon-routes.js'; @@ -216,6 +217,11 @@ export const createFeatureRoutesRuntime = (dependencies) => { path, openchamberDataDir, }); + registerSessionFoldersRoutes(app, { + fsPromises, + path, + openchamberDataDir, + }); registerFsRoutes(app, { os, path, diff --git a/packages/web/server/lib/session-folders/routes.js b/packages/web/server/lib/session-folders/routes.js new file mode 100644 index 00000000..6b5e2ad4 --- /dev/null +++ b/packages/web/server/lib/session-folders/routes.js @@ -0,0 +1,57 @@ +const MAX_BODY_BYTES = 4 * 1024 * 1024; + +export const registerSessionFoldersRoutes = (app, dependencies) => { + const { + fsPromises, + path, + openchamberDataDir, + } = dependencies; + + const filePath = path.join(openchamberDataDir, 'sessions-directories.json'); + + const ensureDir = async () => { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + }; + + app.get('/api/session-folders', async (_req, res) => { + try { + const raw = await fsPromises.readFile(filePath, 'utf8').catch((error) => { + if (error && error.code === 'ENOENT') return null; + throw error; + }); + if (!raw) { + return res.json({ version: 1, foldersMap: {}, collapsedFolderIds: [], updatedAt: 0 }); + } + try { + const parsed = JSON.parse(raw); + return res.json(parsed); + } catch { + return res.json({ version: 1, foldersMap: {}, collapsedFolderIds: [], updatedAt: 0 }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to read session folders'; + return res.status(500).json({ error: message }); + } + }); + + app.post('/api/session-folders', async (req, res) => { + const body = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return res.status(400).json({ error: 'Body must be an object' }); + } + const serialized = JSON.stringify(body, null, 2); + if (Buffer.byteLength(serialized, 'utf8') > MAX_BODY_BYTES) { + return res.status(413).json({ error: 'Payload too large' }); + } + try { + await ensureDir(); + const tmp = `${filePath}.tmp`; + await fsPromises.writeFile(tmp, serialized, 'utf8'); + await fsPromises.rename(tmp, filePath); + return res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to write session folders'; + return res.status(500).json({ error: message }); + } + }); +};