From 277471242b4a4ff01d7c6c0217c34b9febd68088 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:39:25 +0000 Subject: [PATCH 1/2] Initial plan From 5de7ef5b5880ce0e0604295c4ca16641b893f164 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:51:03 +0000 Subject: [PATCH 2/2] feat: add preview mock filesystem rpc support Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/preview/mock/mockfilesystem.ts | 543 ++++++++++++++++++++++ frontend/preview/mock/mockwaveenv.test.ts | 74 +++ frontend/preview/mock/mockwaveenv.ts | 53 ++- 3 files changed, 658 insertions(+), 12 deletions(-) create mode 100644 frontend/preview/mock/mockfilesystem.ts diff --git a/frontend/preview/mock/mockfilesystem.ts b/frontend/preview/mock/mockfilesystem.ts new file mode 100644 index 0000000000..6652bbb3fe --- /dev/null +++ b/frontend/preview/mock/mockfilesystem.ts @@ -0,0 +1,543 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { arrayToBase64 } from "@/util/util"; + +const MockHomePath = "/Users/mike"; +const MockDirMimeType = "directory"; +const MockDirMode = 0o040755; +const MockFileMode = 0o100644; +const MockDirectoryChunkSize = 128; +const MockFileChunkSize = 64 * 1024; +const MockBaseModTime = Date.parse("2026-03-10T09:00:00.000Z"); +const TinyPngBytes = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, 0x1c, 0x0c, + 0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xff, 0x1f, 0x00, + 0x03, 0x03, 0x01, 0xff, 0xa5, 0xf8, 0x8f, 0xb1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, + 0xae, 0x42, 0x60, 0x82, +]); +const TinyJpegBytes = Uint8Array.from([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, + 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0a, 0x07, + 0x07, 0x06, 0x08, 0x0c, 0x0a, 0x0c, 0x0c, 0x0b, 0x0a, 0x0b, 0x0b, 0x0d, 0x0e, 0x12, 0x10, 0x0d, + 0x0e, 0x11, 0x0e, 0x0b, 0x0b, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0c, 0x0f, + 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, + 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14, + 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xbf, 0xff, 0xd9, +]); + +type MockFsEntry = { + path: string; + dir: string; + name: string; + isdir: boolean; + mimetype: string; + modtime: number; + mode: number; + size: number; + readonly?: boolean; + supportsmkdir?: boolean; + content?: Uint8Array; +}; + +type MockFsEntryInput = { + path: string; + isdir?: boolean; + mimetype?: string; + readonly?: boolean; + content?: string | Uint8Array; +}; + +export type MockFilesystem = { + homePath: string; + fileCount: number; + directoryCount: number; + entryCount: number; + fileInfo: (data: FileData) => Promise; + fileRead: (data: FileData) => Promise; + fileList: (data: FileListData) => Promise; + fileJoin: (paths: string[]) => Promise; + fileReadStream: (data: FileData) => AsyncGenerator; + fileListStream: (data: FileListData) => AsyncGenerator; +}; + +function normalizeMockPath(path: string, basePath = MockHomePath): string { + if (path == null || path === "") { + return basePath; + } + if (path.startsWith("wsh://")) { + const url = new URL(path); + path = url.pathname.replace(/^\/+/, "/"); + } + if (path === "~") { + path = MockHomePath; + } else if (path.startsWith("~/")) { + path = MockHomePath + path.slice(1); + } + if (!path.startsWith("/")) { + path = `${basePath}/${path}`; + } + const parts = path.split("/"); + const resolvedParts: string[] = []; + for (const part of parts) { + if (!part || part === ".") { + continue; + } + if (part === "..") { + resolvedParts.pop(); + continue; + } + resolvedParts.push(part); + } + const resolvedPath = "/" + resolvedParts.join("/"); + return resolvedPath === "" ? "/" : resolvedPath; +} + +function getDirName(path: string): string { + if (path === "/") { + return "/"; + } + const idx = path.lastIndexOf("/"); + if (idx <= 0) { + return "/"; + } + return path.slice(0, idx); +} + +function getBaseName(path: string): string { + if (path === "/") { + return "/"; + } + const idx = path.lastIndexOf("/"); + return idx < 0 ? path : path.slice(idx + 1); +} + +function getMimeType(path: string, isdir: boolean): string { + if (isdir) { + return MockDirMimeType; + } + if (path.endsWith(".md")) { + return "text/markdown"; + } + if (path.endsWith(".json")) { + return "application/json"; + } + if (path.endsWith(".ts")) { + return "text/typescript"; + } + if (path.endsWith(".tsx")) { + return "text/tsx"; + } + if (path.endsWith(".js")) { + return "text/javascript"; + } + if (path.endsWith(".txt") || path.endsWith(".log") || path.endsWith(".bashrc") || path.endsWith(".zprofile")) { + return "text/plain"; + } + if (path.endsWith(".png")) { + return "image/png"; + } + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (path.endsWith(".pdf")) { + return "application/pdf"; + } + if (path.endsWith(".zip")) { + return "application/zip"; + } + if (path.endsWith(".dmg")) { + return "application/x-apple-diskimage"; + } + if (path.endsWith(".svg")) { + return "image/svg+xml"; + } + if (path.endsWith(".yaml") || path.endsWith(".yml")) { + return "application/yaml"; + } + return "application/octet-stream"; +} + +function makeContentBytes(content: string | Uint8Array): Uint8Array { + if (content instanceof Uint8Array) { + return content; + } + return new TextEncoder().encode(content); +} + +function makeMockFsInput(path: string, content?: string | Uint8Array, mimetype?: string): MockFsEntryInput { + return { path, content, mimetype }; +} + +function createMockFilesystemEntries(): MockFsEntryInput[] { + const entries: MockFsEntryInput[] = [ + { path: "/", isdir: true }, + { path: "/Users", isdir: true }, + { path: MockHomePath, isdir: true }, + { path: `${MockHomePath}/Desktop`, isdir: true }, + { path: `${MockHomePath}/Documents`, isdir: true }, + { path: `${MockHomePath}/Downloads`, isdir: true }, + { path: `${MockHomePath}/Pictures`, isdir: true }, + { path: `${MockHomePath}/Projects`, isdir: true }, + { path: `${MockHomePath}/waveterm`, isdir: true }, + { path: `${MockHomePath}/waveterm/docs`, isdir: true }, + { path: `${MockHomePath}/waveterm/images`, isdir: true }, + { path: `${MockHomePath}/.config`, isdir: true }, + makeMockFsInput( + `${MockHomePath}/.bashrc`, + `export PATH="$HOME/bin:$PATH"\nalias gs="git status -sb"\nexport WAVETERM_THEME="midnight"\n`, + "text/plain" + ), + makeMockFsInput(`${MockHomePath}/.gitconfig`), + makeMockFsInput(`${MockHomePath}/.zprofile`), + makeMockFsInput(`${MockHomePath}/todo.txt`), + makeMockFsInput(`${MockHomePath}/notes.txt`), + makeMockFsInput(`${MockHomePath}/shell-aliases`), + makeMockFsInput(`${MockHomePath}/archive.log`), + makeMockFsInput(`${MockHomePath}/session.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/launch-plan.md`), + makeMockFsInput(`${MockHomePath}/Desktop/coffee.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/daily-standup.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/snippets.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/terminal-theme.png`), + makeMockFsInput(`${MockHomePath}/Desktop/macos-shortcuts.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/bug-scrub.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/parking-receipt.pdf`), + makeMockFsInput(`${MockHomePath}/Desktop/demo-script.md`), + makeMockFsInput(`${MockHomePath}/Desktop/roadmap-draft.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/pairing-notes.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/wave-window.jpg`), + makeMockFsInput( + `${MockHomePath}/Documents/meeting-notes.md`, + `# File Preview Notes\n\n- Build a richer preview mock environment.\n- Add a fake filesystem rooted at \`${MockHomePath}\`.\n- Make markdown previews resolve relative assets.\n`, + "text/markdown" + ), + makeMockFsInput(`${MockHomePath}/Documents/architecture-overview.md`), + makeMockFsInput(`${MockHomePath}/Documents/release-checklist.md`), + makeMockFsInput(`${MockHomePath}/Documents/ideas.txt`), + makeMockFsInput(`${MockHomePath}/Documents/customer-feedback.txt`), + makeMockFsInput(`${MockHomePath}/Documents/cli-ux-notes.txt`), + makeMockFsInput(`${MockHomePath}/Documents/migration-plan.md`), + makeMockFsInput(`${MockHomePath}/Documents/design-review.md`), + makeMockFsInput(`${MockHomePath}/Documents/ops-runbook.md`), + makeMockFsInput(`${MockHomePath}/Documents/troubleshooting.txt`), + makeMockFsInput(`${MockHomePath}/Documents/preview-fixtures.txt`), + makeMockFsInput(`${MockHomePath}/Documents/backlog.txt`), + makeMockFsInput(`${MockHomePath}/Documents/feature-flags.yaml`), + makeMockFsInput(`${MockHomePath}/Documents/connections.csv`), + makeMockFsInput(`${MockHomePath}/Documents/ssh-hosts.txt`), + makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-01.md`), + makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-05.md`), + makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-09.md`), + makeMockFsInput(`${MockHomePath}/Downloads/waveterm-nightly.dmg`), + makeMockFsInput(`${MockHomePath}/Downloads/screenshot-pack.zip`), + makeMockFsInput(`${MockHomePath}/Downloads/cli-reference.pdf`), + makeMockFsInput(`${MockHomePath}/Downloads/ssh-cheatsheet.pdf`), + makeMockFsInput(`${MockHomePath}/Downloads/perf-trace.json`), + makeMockFsInput(`${MockHomePath}/Downloads/terminal-icons.zip`), + makeMockFsInput(`${MockHomePath}/Downloads/demo-data.csv`), + makeMockFsInput(`${MockHomePath}/Downloads/deploy-plan.txt`), + makeMockFsInput(`${MockHomePath}/Downloads/customer-audio.m4a`), + makeMockFsInput(`${MockHomePath}/Downloads/mock-shell-history.txt`), + makeMockFsInput(`${MockHomePath}/Downloads/design-assets.zip`), + makeMockFsInput(`${MockHomePath}/Downloads/old-preview-build.dmg`), + makeMockFsInput(`${MockHomePath}/Downloads/testing-samples.tar`), + makeMockFsInput(`${MockHomePath}/Downloads/workflow-failure.log`), + makeMockFsInput(`${MockHomePath}/Downloads/team-photo.jpg`), + makeMockFsInput(`${MockHomePath}/Downloads/preview-recording.mov`), + makeMockFsInput(`${MockHomePath}/Downloads/standup-notes.txt`), + makeMockFsInput(`${MockHomePath}/Downloads/metadata.json`), + makeMockFsInput(`${MockHomePath}/Pictures/beach-sunrise.png`, TinyPngBytes, "image/png"), + makeMockFsInput(`${MockHomePath}/Pictures/terminal-screenshot.jpg`, TinyJpegBytes, "image/jpeg"), + makeMockFsInput(`${MockHomePath}/Pictures/diagram.png`), + makeMockFsInput(`${MockHomePath}/Pictures/launch-party.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/icon-sketch.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-01.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-02.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-03.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-04.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-05.png`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-01.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-02.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-03.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-04.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-05.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/ui-concept.png`), + makeMockFsInput(`${MockHomePath}/Projects/local.env`), + makeMockFsInput(`${MockHomePath}/Projects/db-migration.sql`), + makeMockFsInput(`${MockHomePath}/Projects/prompt-lab.txt`), + makeMockFsInput(`${MockHomePath}/Projects/ui-spikes.tsx`), + makeMockFsInput(`${MockHomePath}/Projects/file-browser.tsx`), + makeMockFsInput(`${MockHomePath}/Projects/mock-data.json`), + makeMockFsInput(`${MockHomePath}/Projects/preview-api.ts`), + makeMockFsInput(`${MockHomePath}/Projects/bug-181.txt`), + makeMockFsInput( + `${MockHomePath}/waveterm/README.md`, + `# Mock WaveTerm Repo\n\nThis fake repo exists only in the preview environment.\nIt gives file previews something realistic to browse.\n`, + "text/markdown" + ), + makeMockFsInput(`${MockHomePath}/waveterm/package.json`), + makeMockFsInput(`${MockHomePath}/waveterm/tsconfig.json`), + makeMockFsInput(`${MockHomePath}/waveterm/Taskfile.yml`), + makeMockFsInput(`${MockHomePath}/waveterm/preview-model.tsx`), + makeMockFsInput(`${MockHomePath}/waveterm/mockwaveenv.ts`), + makeMockFsInput(`${MockHomePath}/waveterm/vite.config.ts`), + makeMockFsInput(`${MockHomePath}/waveterm/CHANGELOG.md`), + makeMockFsInput( + `${MockHomePath}/waveterm/docs/preview-notes.md`, + `# Preview Mocking\n\nUse the preview server to iterate on file previews without Electron.\nRelative markdown assets should resolve through \`FileJoinCommand\`.\n`, + "text/markdown" + ), + makeMockFsInput(`${MockHomePath}/waveterm/docs/filesystem-rpc.md`), + makeMockFsInput(`${MockHomePath}/waveterm/docs/test-plan.md`), + makeMockFsInput(`${MockHomePath}/waveterm/docs/connections.md`), + makeMockFsInput(`${MockHomePath}/waveterm/docs/preview-gallery.md`), + makeMockFsInput(`${MockHomePath}/waveterm/docs/release-notes.md`), + makeMockFsInput(`${MockHomePath}/waveterm/images/wave-logo.png`, TinyPngBytes, "image/png"), + makeMockFsInput(`${MockHomePath}/waveterm/images/hero.png`), + makeMockFsInput(`${MockHomePath}/waveterm/images/avatar.jpg`), + makeMockFsInput(`${MockHomePath}/waveterm/images/icon-16.png`), + makeMockFsInput(`${MockHomePath}/waveterm/images/icon-32.png`), + makeMockFsInput(`${MockHomePath}/waveterm/images/splash.jpg`), + makeMockFsInput( + `${MockHomePath}/.config/settings.json`, + JSON.stringify( + { + "app:theme": "wave-dark", + "preview:lastpath": `${MockHomePath}/Documents/meeting-notes.md`, + "window:magnifiedblockopacity": 0.92, + }, + null, + 2 + ), + "application/json" + ), + makeMockFsInput(`${MockHomePath}/.config/preview-cache.json`), + makeMockFsInput(`${MockHomePath}/.config/recent-workspaces.json`), + makeMockFsInput(`${MockHomePath}/.config/telemetry.log`), + ]; + return entries; +} + +function buildEntries(): Map { + const inputs = createMockFilesystemEntries(); + const entries = new Map(); + const ensureDir = (path: string) => { + const normalizedPath = normalizeMockPath(path, "/"); + if (entries.has(normalizedPath)) { + return; + } + const dir = getDirName(normalizedPath); + if (normalizedPath !== "/") { + ensureDir(dir); + } + entries.set(normalizedPath, { + path: normalizedPath, + dir: normalizedPath === "/" ? "/" : dir, + name: normalizedPath === "/" ? "/" : getBaseName(normalizedPath), + isdir: true, + mimetype: MockDirMimeType, + modtime: MockBaseModTime + entries.size * 60000, + mode: MockDirMode, + size: 0, + supportsmkdir: true, + }); + }; + for (const input of inputs) { + const normalizedPath = normalizeMockPath(input.path, "/"); + const isdir = input.isdir ?? false; + const dir = getDirName(normalizedPath); + if (normalizedPath !== "/") { + ensureDir(dir); + } + const content = input.content == null ? undefined : makeContentBytes(input.content); + entries.set(normalizedPath, { + path: normalizedPath, + dir: normalizedPath === "/" ? "/" : dir, + name: normalizedPath === "/" ? "/" : getBaseName(normalizedPath), + isdir, + mimetype: input.mimetype ?? getMimeType(normalizedPath, isdir), + modtime: MockBaseModTime + entries.size * 60000, + mode: isdir ? MockDirMode : MockFileMode, + size: content?.byteLength ?? 0, + readonly: input.readonly, + supportsmkdir: isdir, + content, + }); + } + return entries; +} + +function toFileInfo(entry: MockFsEntry): FileInfo { + return { + path: entry.path, + dir: entry.dir, + name: entry.name, + size: entry.size, + mode: entry.mode, + modtime: entry.modtime, + isdir: entry.isdir, + supportsmkdir: entry.supportsmkdir, + mimetype: entry.mimetype, + readonly: entry.readonly, + }; +} + +function makeNotFoundInfo(path: string): FileInfo { + const normalizedPath = normalizeMockPath(path); + return { + path: normalizedPath, + dir: getDirName(normalizedPath), + name: getBaseName(normalizedPath), + notfound: true, + supportsmkdir: true, + }; +} + +function sliceEntries(entries: FileInfo[], opts?: FileListOpts): FileInfo[] { + let filteredEntries = entries; + if (!opts?.all) { + filteredEntries = filteredEntries.filter((entry) => entry.name != null && !entry.name.startsWith(".")); + } + const offset = Math.max(opts?.offset ?? 0, 0); + const end = opts?.limit != null && opts.limit >= 0 ? offset + opts.limit : undefined; + return filteredEntries.slice(offset, end); +} + +function joinPaths(paths: string[]): string { + if (paths.length === 0) { + return MockHomePath; + } + let currentPath = normalizeMockPath(paths[0]); + for (const part of paths.slice(1)) { + currentPath = normalizeMockPath(part, currentPath); + } + return currentPath; +} + +function getReadRange(data: FileData, size: number): { offset: number; end: number } { + const offset = Math.max(data?.at?.offset ?? 0, 0); + const end = data?.at?.size != null ? Math.min(offset + data.at.size, size) : size; + return { offset, end: Math.max(offset, end) }; +} + +export function makeMockFilesystem(): MockFilesystem { + const entries = buildEntries(); + const childrenByDir = new Map(); + for (const entry of entries.values()) { + if (entry.path === "/") { + continue; + } + if (!childrenByDir.has(entry.dir)) { + childrenByDir.set(entry.dir, []); + } + childrenByDir.get(entry.dir).push(entry); + } + for (const childEntries of childrenByDir.values()) { + childEntries.sort((a, b) => { + if (a.isdir !== b.isdir) { + return a.isdir ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } + const getEntry = (path: string): MockFsEntry => { + return entries.get(normalizeMockPath(path)); + }; + const fileInfo = async (data: FileData): Promise => { + const entry = getEntry(data?.info?.path ?? MockHomePath); + if (!entry) { + return makeNotFoundInfo(data?.info?.path ?? MockHomePath); + } + return toFileInfo(entry); + }; + const fileRead = async (data: FileData): Promise => { + const info = await fileInfo(data); + if (info.notfound) { + return { info }; + } + const entry = getEntry(info.path); + if (entry.isdir) { + const childEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child)); + return { info, entries: childEntries }; + } + if (entry.content == null || entry.content.byteLength === 0) { + return { info }; + } + const { offset, end } = getReadRange(data, entry.content.byteLength); + return { + info, + data64: arrayToBase64(entry.content.slice(offset, end)), + at: { offset, size: end - offset }, + }; + }; + const fileList = async (data: FileListData): Promise => { + const dirPath = normalizeMockPath(data?.path ?? MockHomePath); + const entry = getEntry(dirPath); + if (entry == null || !entry.isdir) { + return []; + } + const dirEntries = (childrenByDir.get(dirPath) ?? []).map((child) => toFileInfo(child)); + return sliceEntries(dirEntries, data?.opts); + }; + const fileJoin = async (paths: string[]): Promise => { + const path = paths.length === 1 ? normalizeMockPath(paths[0]) : joinPaths(paths); + const entry = getEntry(path); + if (!entry) { + return makeNotFoundInfo(path); + } + return toFileInfo(entry); + }; + const fileReadStream = async function* (data: FileData): AsyncGenerator { + const info = await fileInfo(data); + yield { info }; + if (info.notfound) { + return; + } + const entry = getEntry(info.path); + if (entry.isdir) { + const dirEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child)); + for (let idx = 0; idx < dirEntries.length; idx += MockDirectoryChunkSize) { + yield { entries: dirEntries.slice(idx, idx + MockDirectoryChunkSize) }; + } + return; + } + if (entry.content == null || entry.content.byteLength === 0) { + return; + } + const { offset, end } = getReadRange(data, entry.content.byteLength); + for (let currentOffset = offset; currentOffset < end; currentOffset += MockFileChunkSize) { + const chunkEnd = Math.min(currentOffset + MockFileChunkSize, end); + yield { + data64: arrayToBase64(entry.content.slice(currentOffset, chunkEnd)), + at: { offset: currentOffset, size: chunkEnd - currentOffset }, + }; + } + }; + const fileListStream = async function* (data: FileListData): AsyncGenerator { + const fileInfos = await fileList(data); + for (let idx = 0; idx < fileInfos.length; idx += MockDirectoryChunkSize) { + yield { fileinfo: fileInfos.slice(idx, idx + MockDirectoryChunkSize) }; + } + }; + const fileCount = Array.from(entries.values()).filter((entry) => !entry.isdir).length; + const directoryCount = Array.from(entries.values()).filter((entry) => entry.isdir).length; + return { + homePath: MockHomePath, + fileCount, + directoryCount, + entryCount: entries.size, + fileInfo, + fileRead, + fileList, + fileJoin, + fileReadStream, + fileListStream, + }; +} + +export const DefaultMockFilesystem = makeMockFilesystem(); diff --git a/frontend/preview/mock/mockwaveenv.test.ts b/frontend/preview/mock/mockwaveenv.test.ts index 953e8412d4..25aee22995 100644 --- a/frontend/preview/mock/mockwaveenv.test.ts +++ b/frontend/preview/mock/mockwaveenv.test.ts @@ -1,4 +1,6 @@ +import { base64ToArray, base64ToString } from "@/util/util"; import { describe, expect, it, vi } from "vitest"; +import { DefaultMockFilesystem } from "./mockfilesystem"; const { showPreviewContextMenu } = vi.hoisted(() => ({ showPreviewContextMenu: vi.fn(), @@ -19,4 +21,76 @@ describe("makeMockWaveEnv", () => { expect(showPreviewContextMenu).toHaveBeenCalledWith(menu, event); }); + + it("provides a populated mock filesystem rooted at /Users/mike", () => { + expect(DefaultMockFilesystem.homePath).toBe("/Users/mike"); + expect(DefaultMockFilesystem.fileCount).toBeGreaterThanOrEqual(100); + expect(DefaultMockFilesystem.directoryCount).toBeGreaterThanOrEqual(10); + }); + + it("implements file info, read, list, and join commands", async () => { + const { makeMockWaveEnv } = await import("./mockwaveenv"); + const env = makeMockWaveEnv(); + + const bashrcInfo = await env.rpc.FileInfoCommand(null as any, { + info: { path: "wsh://local//Users/mike/.bashrc" }, + }); + expect(bashrcInfo.path).toBe("/Users/mike/.bashrc"); + expect(bashrcInfo.mimetype).toBe("text/plain"); + + const bashrcData = await env.rpc.FileReadCommand(null as any, { + info: { path: "wsh://local//Users/mike/.bashrc" }, + }); + expect(base64ToString(bashrcData.data64)).toContain('alias gs="git status -sb"'); + + const visibleHomeEntries = await env.rpc.FileListCommand(null as any, { + path: "/Users/mike", + }); + expect(visibleHomeEntries.some((entry) => entry.name === ".bashrc")).toBe(false); + expect(visibleHomeEntries.some((entry) => entry.name === "waveterm")).toBe(true); + + const allHomeEntries = await env.rpc.FileListCommand(null as any, { + path: "/Users/mike", + opts: { all: true }, + }); + expect(allHomeEntries.some((entry) => entry.name === ".bashrc")).toBe(true); + + const dirRead = await env.rpc.FileReadCommand(null as any, { + info: { path: "/Users/mike/waveterm" }, + }); + expect(dirRead.entries.some((entry) => entry.name === "docs" && entry.isdir)).toBe(true); + + const joined = await env.rpc.FileJoinCommand(null as any, [ + "wsh://local//Users/mike/Documents", + "../waveterm/docs", + "preview-notes.md", + ]); + expect(joined.path).toBe("/Users/mike/waveterm/docs/preview-notes.md"); + expect(joined.mimetype).toBe("text/markdown"); + }); + + it("implements file list and read stream commands", async () => { + const { makeMockWaveEnv } = await import("./mockwaveenv"); + const env = makeMockWaveEnv(); + + const listPackets: CommandRemoteListEntriesRtnData[] = []; + for await (const packet of env.rpc.FileListStreamCommand(null as any, { + path: "/Users/mike", + opts: { all: true, limit: 4 }, + })) { + listPackets.push(packet); + } + expect(listPackets).toHaveLength(1); + expect(listPackets[0].fileinfo).toHaveLength(4); + + const readPackets: FileData[] = []; + for await (const packet of env.rpc.FileReadStreamCommand(null as any, { + info: { path: "/Users/mike/Pictures/beach-sunrise.png" }, + })) { + readPackets.push(packet); + } + expect(readPackets[0].info?.path).toBe("/Users/mike/Pictures/beach-sunrise.png"); + const imageBytes = base64ToArray(readPackets[1].data64); + expect(Array.from(imageBytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]); + }); }); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index aaccb0dd32..20911e4b58 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -10,6 +10,7 @@ import { WaveEnv } from "@/app/waveenv/waveenv"; import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai"; import { DefaultFullConfig } from "./defaultconfig"; +import { DefaultMockFilesystem } from "./mockfilesystem"; import { showPreviewContextMenu } from "../preview-contextmenu"; import { previewElectronApi } from "./preview-electron-api"; @@ -32,7 +33,9 @@ import { previewElectronApi } from "./preview-electron-api"; // e.g. { "block": { "GetControllerStatus": (blockId) => myStatus } } type RpcOverrides = { - [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => Promise; + [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: ( + ...args: any[] + ) => Promise | AsyncGenerator; }; type ServiceOverrides = { @@ -176,18 +179,25 @@ type MockWosFns = { }; export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType { - const dispatchMap = new Map Promise>(); - dispatchMap.set("eventpublish", async (_client, data: WaveEvent) => { + const callDispatchMap = new Map Promise>(); + const streamDispatchMap = new Map AsyncGenerator>(); + const setCallHandler = (command: string, fn: (...args: any[]) => Promise) => { + callDispatchMap.set(command, fn); + }; + const setStreamHandler = (command: string, fn: (...args: any[]) => AsyncGenerator) => { + streamDispatchMap.set(command, fn); + }; + setCallHandler("eventpublish", async (_client, data: WaveEvent) => { console.log("[mock eventpublish]", data); handleWaveEvent(data); return null; }); - dispatchMap.set("getmeta", async (_client, data: CommandGetMetaData) => { + setCallHandler("getmeta", async (_client, data: CommandGetMetaData) => { const objAtom = wos.getWaveObjectAtom(data.oref); const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType }; return current?.meta ?? {}; }); - dispatchMap.set("setmeta", async (_client, data: CommandSetMetaData) => { + setCallHandler("setmeta", async (_client, data: CommandSetMetaData) => { const objAtom = wos.getWaveObjectAtom(data.oref); const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType }; const updatedMeta = { ...(current?.meta ?? {}) }; @@ -202,7 +212,7 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp wos.mockSetWaveObj(data.oref, updated); return null; }); - dispatchMap.set("updatetabname", async (_client, data: { args: [string, string] }) => { + setCallHandler("updatetabname", async (_client, data: { args: [string, string] }) => { const [tabId, newName] = data.args; const tabORef = "tab:" + tabId; const objAtom = wos.getWaveObjectAtom(tabORef); @@ -211,7 +221,7 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp wos.mockSetWaveObj(tabORef, updated); return null; }); - dispatchMap.set("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => { + setCallHandler("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => { const [workspaceId, tabIds] = data.args; const wsORef = "workspace:" + workspaceId; const objAtom = wos.getWaveObjectAtom(wsORef); @@ -220,16 +230,30 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp wos.mockSetWaveObj(wsORef, updated); return null; }); + setCallHandler("fileinfo", async (_client, data: FileData) => DefaultMockFilesystem.fileInfo(data)); + setCallHandler("fileread", async (_client, data: FileData) => DefaultMockFilesystem.fileRead(data)); + setCallHandler("filelist", async (_client, data: FileListData) => DefaultMockFilesystem.fileList(data)); + setCallHandler("filejoin", async (_client, data: string[]) => DefaultMockFilesystem.fileJoin(data)); + setStreamHandler("filereadstream", async function* (_client, data: FileData) { + yield* DefaultMockFilesystem.fileReadStream(data); + }); + setStreamHandler("fileliststream", async function* (_client, data: FileListData) { + yield* DefaultMockFilesystem.fileListStream(data); + }); if (overrides) { for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) { const cmdName = key.slice(0, -"Command".length).toLowerCase(); - dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => Promise); + if (cmdName === "filereadstream" || cmdName === "fileliststream") { + setStreamHandler(cmdName, overrides[key] as (...args: any[]) => AsyncGenerator); + } else { + setCallHandler(cmdName, overrides[key] as (...args: any[]) => Promise); + } } } const rpc = new RpcApiType(); rpc.setMockRpcClient({ mockWshRpcCall(_client, command, data, _opts) { - const fn = dispatchMap.get(command); + const fn = callDispatchMap.get(command); if (fn) { return fn(_client, data, _opts); } @@ -237,9 +261,14 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp return Promise.resolve(null); }, async *mockWshRpcStream(_client, command, data, _opts) { - const fn = dispatchMap.get(command); - if (fn) { - yield await fn(_client, data, _opts); + const streamFn = streamDispatchMap.get(command); + if (streamFn) { + yield* streamFn(_client, data, _opts); + return; + } + const callFn = callDispatchMap.get(command); + if (callFn) { + yield await callFn(_client, data, _opts); return; } console.log("[mock rpc stream]", command, data);