diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 76ba6a57..1d9cc014 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -5,6 +5,8 @@ import { decodeLocalProfileId, decodeDesktopUpdateRendererReadyResult, decodeDesktopUpdateState, + decodeProjectionCacheLoadResult, + decodeProjectionCacheSaveResult, type BootstrapResult, type CommandRequest, type CommandResult, @@ -29,6 +31,9 @@ import { type PairRequest, type PairResult, type PairLinksDrainResult, + type ProjectionCacheLoadResult, + type ProjectionCacheSaveRequest, + type ProjectionCacheSaveResult, type RuntimeErrorEvent, type ServiceActionResult, type ServiceAvailabilityIssue, @@ -72,6 +77,10 @@ export interface IpcRuntime { readonly restartToUpdate: () => DesktopUpdateState; readonly subscribe: (listener: (state: DesktopUpdateState) => void) => () => void; }; + readonly projectionCache?: { + readonly load: () => ProjectionCacheLoadResult | Promise; + readonly save: (value: string) => ProjectionCacheSaveResult | Promise; + }; } export class RemotePairingUnavailableError extends Error { readonly code = "remote_pairing_unavailable" as const; @@ -271,6 +280,18 @@ export class DesktopIpcRegistry { openSettings: this.runtime.drainPendingUpdateOpen?.() ?? false, }); }); + this.ipc.handle("app:projection-cache:load", async (event, payload: unknown): Promise => { + this.assertSender(event); + decodeRequest("app:projection-cache:load", payload); + const result = await (this.runtime.projectionCache?.load() ?? { available: false, value: null }); + return decodeProjectionCacheLoadResult(result); + }); + this.ipc.handle("app:projection-cache:save", async (event, payload: unknown): Promise => { + this.assertSender(event); + const input = decodeRequest("app:projection-cache:save", payload).payload as ProjectionCacheSaveRequest; + const result = await (this.runtime.projectionCache?.save(input.value) ?? { saved: false }); + return decodeProjectionCacheSaveResult(result); + }); this.updateUnsubscribe = this.runtime.updateController?.subscribe((state) => { this.emit("app:update:state", state); }); @@ -289,6 +310,7 @@ export class DesktopIpcRegistry { "omp:service:restart", "omp:service:uninstall", "app:update:get-state", "app:update:check", "app:update:download", "app:update:restart", "app:update:renderer-ready", + "app:projection-cache:load", "app:projection-cache:save", ] as const) this.ipc.removeHandler(channel); this.installed = false; } diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index 47d1cf73..f705072e 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -8,7 +8,7 @@ import type { ServiceManager } from "@t4-code/service-manager"; import type { RemoteTargetRegistry } from "./remote-runtime/registry.ts"; import { createDesktopWindow, type DesktopWindowHandle } from "./window.ts"; import { DesktopIpcRegistry, runtimeError, type IpcRuntime } from "./ipc.ts"; -import { ElectronCursorStore, ElectronRemoteTargetStore, ElectronCredentialCiphertextStore, ElectronLocalProfileStore, electronSafeStorage, loadDeviceIdentity, type DeviceIdentity } from "./stores.ts"; +import { ElectronCursorStore, ElectronRemoteTargetStore, ElectronCredentialCiphertextStore, ElectronLocalProfileStore, ElectronProjectionCacheStore, electronSafeStorage, loadDeviceIdentity, type DeviceIdentity } from "./stores.ts"; import { VersionedRemoteTargetRegistry, DeviceCredentialStore } from "./remote-runtime/index.ts"; import { LocalTargetManager, type TargetManagerOptions } from "./target-manager.ts"; import { createAppserverServiceManager, discoverOmpExecutable, OmpAppserverCompatibilityError, probeOmpAppserver, NodeServiceFileSystem } from "./service.ts"; @@ -19,6 +19,8 @@ import { DesktopUpdateController } from "./update-controller.ts"; import { LocalProfileRegistry } from "./local-profiles.ts"; import { LocalProfileRuntime } from "./profile-runtime.ts"; +type ProjectionCacheRuntime = NonNullable; + export function appserverLogsDirectory( homeDirectory: string, platform: NodeJS.Platform = process.platform, @@ -48,6 +50,7 @@ export interface DesktopLifecycleOptions { readonly createRemoteRegistry?: () => RemoteTargetRegistry; readonly createCredentials?: () => DeviceCredentialStore | undefined; readonly createLocalProfileRegistry?: () => LocalProfileRegistry; + readonly createProjectionCache?: () => ProjectionCacheRuntime; readonly discoverExecutable?: () => Promise; readonly probeAppserver?: (executable: string) => Promise; readonly createServiceManager?: (options: Parameters[0]) => ServiceManager; @@ -75,6 +78,7 @@ export class DesktopLifecycle { private readonly remoteRegistryFactory: () => RemoteTargetRegistry; private readonly credentialsFactory: () => DeviceCredentialStore | undefined; private readonly localProfileRegistryFactory: () => LocalProfileRegistry; + private readonly projectionCacheFactory: () => ProjectionCacheRuntime; private readonly executableFactory: () => Promise; private readonly serviceFactory: (options: Parameters[0]) => ServiceManager; private readonly speechServiceFactory: (options: { readonly discoverExecutable: () => Promise }) => DesktopSpeechService; @@ -88,6 +92,7 @@ export class DesktopLifecycle { private localProfileRegistry: LocalProfileRegistry | undefined; private speechService: DesktopSpeechService | undefined; private profileRuntime: LocalProfileRuntime | undefined; + private projectionCache: ProjectionCacheRuntime | undefined; private serviceManager: ServiceManager | undefined; private readonly serviceManagers = new Map(); private serviceAvailabilityIssue: ServiceAvailabilityIssue | undefined; @@ -121,6 +126,7 @@ export class DesktopLifecycle { this.localProfileRegistryFactory = options.createLocalProfileRegistry ?? ( () => new LocalProfileRegistry(new ElectronLocalProfileStore()) ); + this.projectionCacheFactory = options.createProjectionCache ?? (() => new ElectronProjectionCacheStore()); this.executableFactory = options.discoverExecutable ?? (() => discoverOmpExecutable()); this.appserverProbe = options.probeAppserver ?? ((executable) => probeOmpAppserver(executable)); this.serviceFactory = options.createServiceManager ?? createAppserverServiceManager; @@ -165,6 +171,7 @@ export class DesktopLifecycle { ingest(value); }); await this.electronApp.whenReady(); + this.projectionCache = this.projectionCacheFactory(); if (process.platform === "darwin") this.electronApp.setAsDefaultProtocolClient("t4-code"); this.updateController = this.updateControllerFactory(); this.menuInstaller({ onOpenUpdates: () => this.openUpdatesFromMenu() }); @@ -227,6 +234,7 @@ export class DesktopLifecycle { this.mainWindow = undefined; this.updateController?.dispose(); this.updateController = undefined; + this.projectionCache = undefined; const manager = this.manager; this.manager = undefined; const recovery = this.serviceRecoveryPromise; @@ -423,6 +431,7 @@ export class DesktopLifecycle { getServiceAvailabilityIssue: () => this.serviceAvailabilityIssue, ...(this.speechService === undefined ? {} : { speech: this.speechService }), ...(this.profileRuntime === undefined ? {} : { profileRuntime: this.profileRuntime }), + ...(this.projectionCache === undefined ? {} : { projectionCache: this.projectionCache }), drainPairLinks: () => this.pendingPairs.drain(), drainPendingUpdateOpen: () => this.markUpdateRendererReady(), ...(this.updateController === undefined ? {} : { updateController: this.updateController }), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7c922904..c80b35d2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,6 +3,8 @@ import { decodeDesktopEvent, decodeDesktopUpdateRendererReadyResult, decodeDesktopUpdateState, + decodeProjectionCacheLoadResult, + decodeProjectionCacheSaveResult, type BootstrapResult, type CommandRequest, type CommandResult, @@ -39,6 +41,9 @@ import { type TerminalResult, type SpeechRequest, type SpeechResult, + type ProjectionCacheLoadResult, + type ProjectionCacheSaveRequest, + type ProjectionCacheSaveResult, } from "@t4-code/protocol/desktop-ipc"; export interface OmpShellBridge { @@ -62,6 +67,8 @@ export interface OmpShellBridge { readonly serviceStop: () => Promise; readonly serviceRestart: () => Promise; readonly serviceUninstall: () => Promise; + readonly loadProjectionCache: () => Promise; + readonly saveProjectionCache: (request: ProjectionCacheSaveRequest) => Promise; readonly getUpdateState: () => Promise; readonly checkForUpdate: () => Promise; readonly downloadUpdate: () => Promise; @@ -84,6 +91,7 @@ export interface OmpShellBridge { readonly onConnectionState: (listener: (event: ConnectionStateEvent) => void) => () => void; readonly onRuntimeError: (listener: (event: RuntimeErrorEvent) => void) => () => void; readonly onPairLink: (listener: (event: PairLinkEvent) => void) => () => void; + readonly onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; readonly onOpenUpdateSettings: (listener: (event: DesktopUpdateOpenEvent) => void) => () => void; } @@ -92,6 +100,13 @@ function invoke; } +function invokeProjectionCache( + channel: "app:projection-cache:load" | "app:projection-cache:save", + payload: unknown, +): Promise { + return ipcRenderer.invoke(channel, { channel, payload }) as Promise; +} + type SubscriptionPayload = C extends "omp:server-frame" ? RendererServerFrameEvent : C extends "omp:connection-state" @@ -146,6 +161,10 @@ const bridge: OmpShellBridge = { downloadUpdate: () => invoke<"app:update:download", unknown>("app:update:download", {}).then(decodeDesktopUpdateState), restartToUpdate: () => invoke<"app:update:restart", unknown>("app:update:restart", {}).then(decodeDesktopUpdateState), updateRendererReady: () => invoke<"app:update:renderer-ready", unknown>("app:update:renderer-ready", {}).then(decodeDesktopUpdateRendererReadyResult), + loadProjectionCache: () => + invokeProjectionCache("app:projection-cache:load", {}).then(decodeProjectionCacheLoadResult), + saveProjectionCache: (request) => + invokeProjectionCache("app:projection-cache:save", request).then(decodeProjectionCacheSaveResult), listTargets: () => invoke("omp:targets:list", {}), addTarget: (request) => invoke("omp:targets:add", request), removeTarget: (request) => invoke("omp:targets:remove", request), diff --git a/apps/desktop/src/stores.ts b/apps/desktop/src/stores.ts index 048e105c..ba30eaa6 100644 --- a/apps/desktop/src/stores.ts +++ b/apps/desktop/src/stores.ts @@ -2,6 +2,12 @@ import { randomBytes } from "node:crypto"; import { hostname } from "node:os"; import ElectronStore from "electron-store"; import { safeStorage } from "electron"; +import { + decodeProjectionCacheSaveRequestValue, + MAX_PROJECTION_CACHE_BYTES, + type ProjectionCacheLoadResult, + type ProjectionCacheSaveResult, +} from "@t4-code/protocol/desktop-ipc"; import type { CursorRecord, CursorStore } from "@t4-code/client"; import type { CredentialEntry, CredentialVault, PairedHostRecord, TargetRegistry } from "@t4-code/remote"; import type { CredentialCiphertextStore, RemoteTargetRecord, RemoteTargetStore, SafeStorageAdapter } from "./remote-runtime/index.ts"; @@ -21,6 +27,10 @@ interface RegistryState { readonly targets: Record; } interface VaultState { readonly ciphertext: Record; } interface RemoteRegistryState { readonly version: 1; readonly records: readonly RemoteTargetRecord[]; } interface CredentialCiphertextState { readonly version: 1; readonly ciphertexts: Record; } +interface ProjectionCacheCiphertextState { + readonly version: 1; + readonly ciphertext: string | null; +} function recordKey(hostId: string, sessionId: string): string { return `${hostId}\u0000${sessionId}`; } function decodeRemoteState(value: unknown): RemoteRegistryState { @@ -40,6 +50,28 @@ function decodeCredentialState(value: unknown): CredentialCiphertextState { } return { version: 1, ciphertexts }; } +const MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH = + 4 * Math.ceil((MAX_PROJECTION_CACHE_BYTES + 64) / 3); +function decodeProjectionCacheState(value: unknown): ProjectionCacheCiphertextState { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("invalid projection cache state"); + const root = value as Record; + if (Object.keys(root).some((key) => key !== "version" && key !== "ciphertext")) + throw new Error("invalid projection cache state"); + if (root.version !== 1) throw new Error("invalid projection cache state"); + const ciphertext = root.ciphertext; + if (ciphertext === null) return { version: 1, ciphertext: null }; + if ( + typeof ciphertext !== "string" || + ciphertext.length === 0 || + ciphertext.length > MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH || + ciphertext.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/u.test(ciphertext) || + Buffer.from(ciphertext, "base64").toString("base64") !== ciphertext + ) + throw new Error("invalid projection cache state"); + return { version: 1, ciphertext }; +} function enqueueWrite(queue: { tail: Promise }, operation: () => void): Promise { const result = queue.tail.then(operation, operation); queue.tail = result.then(() => undefined, () => undefined); @@ -130,6 +162,71 @@ export class ElectronCredentialCiphertextStore implements CredentialCiphertextSt } } +export class ElectronProjectionCacheStore { + private readonly store: ElectronStore; + private readonly writeQueue = { tail: Promise.resolve() }; + private readonly safeStorage: SafeStorageAdapter; + constructor( + store = new ElectronStore({ + name: "projection-cache", + defaults: { version: 1, ciphertext: null }, + }), + safeStorage: SafeStorageAdapter = electronSafeStorage, + ) { + this.store = store; + this.safeStorage = safeStorage; + } + load(): ProjectionCacheLoadResult { + if (!this.safeStorage.isEncryptionAvailable()) return { available: false, value: null }; + let state: ProjectionCacheCiphertextState; + try { + state = decodeProjectionCacheState(this.store.store); + } catch { + this.clear(); + return { available: true, value: null }; + } + if (state.ciphertext === null) return { available: true, value: null }; + try { + const value = this.safeStorage.decryptString(Buffer.from(state.ciphertext, "base64")); + decodeProjectionCacheSaveRequestValue(value); + return { available: true, value }; + } catch { + this.clear(); + return { available: true, value: null }; + } + } + save(value: string): Promise { + try { + decodeProjectionCacheSaveRequestValue(value); + } catch { + return Promise.resolve({ saved: false }); + } + return enqueueWrite(this.writeQueue, () => { + if (!this.safeStorage.isEncryptionAvailable()) throw new Error("projection cache encryption unavailable"); + const ciphertext = this.safeStorage.encryptString(value).toString("base64"); + if ( + ciphertext.length === 0 || + ciphertext.length > MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH || + ciphertext.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/u.test(ciphertext) || + Buffer.from(ciphertext, "base64").toString("base64") !== ciphertext + ) + throw new Error("projection cache ciphertext is invalid"); + this.store.store = { version: 1, ciphertext }; + }).then( + () => ({ saved: true }), + () => ({ saved: false }), + ); + } + private clear(): void { + try { + this.store.store = { version: 1, ciphertext: null }; + } catch { + /* best-effort removal of corrupt state */ + } + } +} + export class ElectronLocalProfileStore implements LocalProfileStore { private readonly store: ElectronStore; private readonly writeQueue = { tail: Promise.resolve() }; diff --git a/apps/desktop/test/ipc-lifecycle.test.ts b/apps/desktop/test/ipc-lifecycle.test.ts index dc1a2515..46e33de1 100644 --- a/apps/desktop/test/ipc-lifecycle.test.ts +++ b/apps/desktop/test/ipc-lifecycle.test.ts @@ -107,6 +107,42 @@ describe("desktop IPC lifecycle proof", () => { await expect(handler(event, request("omp:targets:list", { extra: true }))).rejects.toThrow(); await expect(handler(event, request("omp:connect", {}))).rejects.toThrow(); }); + it("routes projection cache load/save through the trusted shell service", async () => { + const ipc = new FakeIpc(); + const { runtime: baseRuntime } = makeRuntime(); + const value = JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: { sessions: [], sessionIndex: [], lru: [], freshness: "cached" }, + }); + const saves: string[] = []; + const runtime: IpcRuntime = { + ...baseRuntime, + projectionCache: { + load: () => ({ available: true, value }), + save: (serialized) => { + saves.push(serialized); + return { saved: true }; + }, + }, + }; + new DesktopIpcRegistry(runtime, ipc).install(); + const event = { sender: runtime.window.webContents, senderFrame: runtime.window.webContents.mainFrame }; + expect(await ipc.handlers.get("app:projection-cache:load")!( + event, + request("app:projection-cache:load"), + )).toEqual({ available: true, value }); + expect(await ipc.handlers.get("app:projection-cache:save")!( + event, + request("app:projection-cache:save", { value }), + )).toEqual({ saved: true }); + expect(saves).toEqual([value]); + await expect(ipc.handlers.get("app:projection-cache:save")!( + event, + request("app:projection-cache:save", { value, storageKey: "renderer-selected-file" }), + )).rejects.toThrow(); + expect(saves).toEqual([value]); + }); it("serializes concurrent service actions", async () => { const ipc = new FakeIpc(); const order: string[] = []; diff --git a/apps/desktop/test/lifecycle-runtime.test.ts b/apps/desktop/test/lifecycle-runtime.test.ts index b5abe362..d20d406b 100644 --- a/apps/desktop/test/lifecycle-runtime.test.ts +++ b/apps/desktop/test/lifecycle-runtime.test.ts @@ -87,6 +87,7 @@ function setup( overrides: { readonly discoverExecutable?: () => Promise; readonly createServiceManager?: NonNullable; + readonly createProjectionCache?: NonNullable; } = {}, ) { const app = new FakeApp(); @@ -132,6 +133,7 @@ function setup( createCursorStore: () => ({ load: () => [], save: () => {} }), createCredentials: () => undefined, createLocalProfileRegistry: () => localProfileRegistry, + ...(overrides.createProjectionCache === undefined ? {} : { createProjectionCache: overrides.createProjectionCache }), discoverExecutable: overrides.discoverExecutable ?? (serviceManager === undefined ? async () => undefined : async () => "/opt/omp/bin/omp"), ...( overrides.createServiceManager === undefined && serviceManager === undefined @@ -181,6 +183,29 @@ describe("desktop Electron lifecycle", () => { expect(fixture.managerOptions).toBeDefined(); await fixture.lifecycle.stop(); }); + it("creates one projection cache after readiness and injects it into every IPC binding", async () => { + const cache = { + load: () => ({ available: false, value: null }), + save: (_value: string) => ({ saved: false }), + }; + let cacheCreates = 0; + const fixture = setup(undefined, async () => true, { + createProjectionCache: () => { + cacheCreates += 1; + return cache; + }, + }); + expect(cacheCreates).toBe(0); + await fixture.lifecycle.start(); + expect(cacheCreates).toBe(1); + expect(fixture.runtimes[0]).toMatchObject({ projectionCache: cache }); + fixture.windows[0]!.close(); + fixture.app.listeners.get("activate")?.(); + expect(fixture.runtimes).toHaveLength(2); + expect(fixture.runtimes[1]).toMatchObject({ projectionCache: cache }); + expect(cacheCreates).toBe(1); + await fixture.lifecycle.stop(); + }); it("routes the native update menu to the trusted renderer and schedules one passive check", async () => { const fixture = setup(); await fixture.lifecycle.start(); diff --git a/apps/desktop/test/stores-lifecycle.test.ts b/apps/desktop/test/stores-lifecycle.test.ts index a405b2d7..d09e078b 100644 --- a/apps/desktop/test/stores-lifecycle.test.ts +++ b/apps/desktop/test/stores-lifecycle.test.ts @@ -3,7 +3,7 @@ import type { CursorStore, OmpTransport } from "@t4-code/client"; import { hostId, type WelcomeFrame } from "@t4-code/protocol"; import { DeviceCredentialStore, type CredentialCiphertextStore, type RemoteTargetRecord, type RemoteTargetRegistry } from "../src/remote-runtime/index.ts"; import { DesktopTargetManager } from "../src/target-manager.ts"; -import { ElectronCredentialCiphertextStore, ElectronRemoteTargetStore, loadDeviceIdentity } from "../src/stores.ts"; +import { ElectronCredentialCiphertextStore, ElectronProjectionCacheStore, ElectronRemoteTargetStore, loadDeviceIdentity } from "../src/stores.ts"; type State = Record; class MemoryStore { @@ -99,6 +99,81 @@ describe("desktop persisted lifecycle stores", () => { expect(() => new DeviceCredentialStore(store, { isEncryptionAvailable: () => false, encryptString: (_value: string) => Buffer.alloc(0), decryptString: (_value: Buffer) => "" })).toThrow(); expect(() => new DeviceCredentialStore(store, { isEncryptionAvailable: () => true, selectedStorageBackend: () => "basic_text", encryptString: (_value: string) => Buffer.alloc(0), decryptString: (_value: Buffer) => "" })).toThrow(); }); + it("stores only safeStorage ciphertext and restores valid projection cache", async () => { + const backing = new MemoryStore({ version: 1 as const, ciphertext: null as string | null }); + const safeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`ciphertext:${value}`), + decryptString: (value: Buffer) => { + const encoded = value.toString(); + if (!encoded.startsWith("ciphertext:")) throw new Error("corrupt ciphertext"); + return encoded.slice("ciphertext:".length); + }, + }; + const store = new ElectronProjectionCacheStore(backing as never, safeStorage); + const value = JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: { sessions: [], sessionIndex: [], lru: [], freshness: "cached" }, + }); + expect(await store.save(value)).toEqual({ saved: true }); + expect(backing.store.ciphertext).not.toContain(value); + expect(store.load()).toEqual({ available: true, value }); + }); + it("degrades when safeStorage is unavailable and clears incompatible state", async () => { + const unavailableBacking = new MemoryStore({ version: 1 as const, ciphertext: null as string | null }); + const unavailable = new ElectronProjectionCacheStore(unavailableBacking as never, { + isEncryptionAvailable: () => false, + encryptString: () => Buffer.from("unused"), + decryptString: () => "unused", + }); + expect(unavailable.load()).toEqual({ available: false, value: null }); + expect(await unavailable.save(JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: {}, + }))).toEqual({ saved: false }); + + const corruptBacking = new MemoryStore({ version: 2 as never, ciphertext: "not-valid" }); + const corrupt = new ElectronProjectionCacheStore(corruptBacking as never, { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString(), + }); + expect(corrupt.load()).toEqual({ available: true, value: null }); + expect(corruptBacking.store).toEqual({ version: 1, ciphertext: null }); + const payloadBacking = new MemoryStore({ + version: 1 as const, + ciphertext: Buffer.from("encrypted").toString("base64"), + }); + const payloadCorrupt = new ElectronProjectionCacheStore(payloadBacking as never, { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: () => JSON.stringify({ kind: "t4-code-projection", version: 2, data: {} }), + }); + expect(payloadCorrupt.load()).toEqual({ available: true, value: null }); + expect(payloadBacking.store).toEqual({ version: 1, ciphertext: null }); + }); + it("serializes concurrent projection cache writes", async () => { + const backing = new MemoryStore({ version: 1 as const, ciphertext: null as string | null }); + const store = new ElectronProjectionCacheStore(backing as never, { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString(), + }); + const makeValue = (marker: string) => JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: { marker }, + }); + const first = makeValue("first"); + const second = makeValue("second"); + expect(await Promise.all([store.save(first), store.save(second)])).toEqual([ + { saved: true }, + { saved: true }, + ]); + expect(store.load()).toEqual({ available: true, value: second }); + }); }); describe("pairing credential sink boundaries", () => { diff --git a/apps/web/src/features/panes/FilesPane.tsx b/apps/web/src/features/panes/FilesPane.tsx index 6bed1f63..19589c3a 100644 --- a/apps/web/src/features/panes/FilesPane.tsx +++ b/apps/web/src/features/panes/FilesPane.tsx @@ -1,13 +1,13 @@ // Files pane: lazy workspace tree with search over loaded folders and a // preview surface that stays honest about what it can show — code, images, // binaries, read failures, and offline hosts each get their own state. -import { Badge, cn, Skeleton } from "@t4-code/ui"; +import { Badge, Button, cn, Skeleton } from "@t4-code/ui"; import { ChevronRight, FileText, Folder, ImageIcon, WifiOff } from "lucide-react"; import { useEffect, useMemo } from "react"; import { FamilyEmpty } from "./FamilyEmpty.tsx"; import { PaneHeading } from "./PaneHeading.tsx"; -import { useInspector, type InspectorStoreApi } from "./inspector-store.ts"; +import { useInspector, type FileDraft, type InspectorStoreApi } from "./inspector-store.ts"; import type { FilePreview, FileTreeNode } from "./model.ts"; function formatBytes(bytes: number): string { @@ -203,11 +203,62 @@ function PreviewBody({ preview }: { readonly preview: FilePreview }) { } } +function EditorBody({ + api, + draft, + saveEnabled, +}: { + readonly api: InspectorStoreApi; + readonly draft: FileDraft; + readonly saveEnabled: boolean; +}) { + const saving = draft.status === "saving"; + return ( +
+