From 406b94c2921aa37c547f6f84c9292068037b2eee Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 3 Sep 2026 07:52:28 -0400 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20rescope=20#721=20=E2=80=94=20sessi?= =?UTF-8?q?on=20work=20column=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 200c80e0d97f62c824e5ab07ad1f201d6899cc39 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 8 Sep 2026 17:23:07 -0400 Subject: [PATCH 2/8] feat(preview): project-wide file tree with search + environment support (#725) - Add extension-side recursive directory scan (preview_file_tree.ts) with RENDERABLE_EXTENSIONS filter, hidden-dir/node_modules skip - Add preview-file-tree bridge message: extension pushes filtered file list on app-ready and on refresh request from webview - Add app-side state store (amicode-preview-file-tree.ts) with SolidJS signals for project files, project root, and optional environment - Wire bridge: chat_bridge.ts handles preview-file-tree-request via registered command; extension.ts pushPreviewFileTree on app-ready - Transform session-preview-tab.tsx: when project tree available, show FileTreeV2 in static mode with search/filter input; when bound to a Research Environment, show env files below a labeled divider with colored pill (same 8-color palette as sidebar); preserve session- touched .md flat list as no-project fallback - 14 unit tests for scan filter, 5 for state store, all green --- packages/app-bundle/manifest.json | 4 +- .../overlay/packages/app/src/app.tsx | 11 + .../session/session-preview-tab.tsx | 221 ++++++++++++++---- packages/extension/src/chat_bridge.ts | 11 + packages/extension/src/extension.ts | 52 +++++ packages/extension/src/preview_file_tree.ts | 100 ++++++++ .../extension/test/preview_file_tree.test.ts | 182 +++++++++++++++ 7 files changed, 540 insertions(+), 41 deletions(-) create mode 100644 packages/extension/src/preview_file_tree.ts create mode 100644 packages/extension/test/preview_file_tree.test.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 1059261f..dd66239e 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -954,7 +954,7 @@ "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", "packages/app/public/amico.svg": "a14b9d543d895bcdf0758f7b9ef5908ee0acaac794446494af059b159247db8f", "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", - "packages/app/src/app.tsx": "a72e7cef35d5de80980fbb1fc26c14d8551d1677821e72798c624842927b55fd", + "packages/app/src/app.tsx": "98f367769ed22ed33cbb8ff6067b3b5fc829acef1fad62b791cfd286ed9aec54", "packages/app/src/design-polish.css": "42cc6efaefe9a71dedd12fcb0bf2549453d9a46097cb025cf087064b29d3ccce", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", "packages/app/src/index.css": "08179e06ce2d419a2d98acc96025f91c7709062ea9f3ad245e88dc35e75ff9f7", @@ -1408,7 +1408,7 @@ "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", "packages/app/src/components/session/session-header.tsx": "a46591ed1097d0fdcff61fb0c5529396955e8857cbef748747c51e472d5564c5", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "b6b4b8fe6f751499e0d3005e1f12224351df4f28f221bba10fc2f91c0f09e777", + "packages/app/src/components/session/session-preview-tab.tsx": "103d531614d0f904b7bb255d484ff681aaad5b66aafea308e2774c661f57bb1a", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", "packages/app/src/components/session/use-context-warning.ts": "af7a6d0159a5541aa02ad4d08fd694af1cc4853fc1c0a70763bc264a634d1c53", "packages/app/src/components/settings-v2/data-storage-controller.ts": "fa5d143cc101f3a3b9d5ad445edddc981e0d02783021d52dbfed6c8d8bf62498", diff --git a/packages/app-bundle/overlay/packages/app/src/app.tsx b/packages/app-bundle/overlay/packages/app/src/app.tsx index 19128cf8..d8728421 100644 --- a/packages/app-bundle/overlay/packages/app/src/app.tsx +++ b/packages/app-bundle/overlay/packages/app/src/app.tsx @@ -2,6 +2,7 @@ import "@/index.css" import * as Sentry from "@sentry/solid" import { requestComputeConnect } from "@/components/amicode-defaults-capsule" import { adoptWorkspaceProjects, workspaceProjects } from "@/utils/amicode-workspace-projects" +import { adoptPreviewFileTree } from "@/utils/amicode-preview-file-tree" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" @@ -446,6 +447,16 @@ function AmicodeThemeBridge() { adoptWorkspaceProjects((d as { projects?: unknown[] }).projects as Parameters[0]) return } + // amicode#725: preview file tree push from the extension host. + if (d.kind === "preview-file-tree") { + const msg = d as { files?: string[]; projectRoot?: string; environment?: unknown } + adoptPreviewFileTree( + (msg.files ?? []) as string[], + (msg.projectRoot ?? "") as string, + msg.environment as Parameters[2], + ) + return + } if (d.kind !== "theme") return if (d.colorScheme === "light" || d.colorScheme === "dark") theme.setColorScheme(d.colorScheme) } diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx index 787df200..5118b244 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-preview-tab.tsx @@ -7,8 +7,16 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { SegmentedControlV2, SegmentedControlItemV2 } from "@opencode-ai/ui/v2/segmented-control-v2" import { writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" +import FileTreeV2 from "@/components/file-tree-v2" +import { + previewFileTree, + previewProjectRoot, + previewEnv, + requestPreviewFileTreeRefresh, +} from "@/utils/amicode-preview-file-tree" import { useSDK } from "@/context/sdk" import { useServerSDK } from "@/context/server-sdk" +import type { FileNode } from "@opencode-ai/sdk/v2" // ─── Types ────────────────────────────────────────────────────────────────── @@ -26,16 +34,38 @@ interface PreviewFileState { unsavedContent?: string } -// ─── Main Component ───────────────────────────────────────────────────────── +// ─── Environment pill color palette ───────────────────────────────────────── +// Same 8-color palette the sidebar uses (envColorIndex → CSS class). + +const ENV_PILL_COLORS = [ + "bg-blue-500/15 text-blue-500", + "bg-green-500/15 text-green-500", + "bg-purple-500/15 text-purple-500", + "bg-orange-500/15 text-orange-500", + "bg-pink-500/15 text-pink-500", + "bg-teal-500/15 text-teal-500", + "bg-yellow-500/15 text-yellow-500", + "bg-red-500/15 text-red-500", +] as const + +// ─── Helpers ──────────────────────────────────────────────────────────────── /** * Convert ```math fenced code blocks (GitHub-flavored) to $$...$$ display math * blocks that the Markdown component's KaTeX extension understands. */ -function preprocessMarkdown(text: string): string { +export function preprocessMarkdown(text: string): string { return text.replace(/```math\n([\s\S]*?)```/g, (_match, body: string) => `$$\n${body.trim()}\n$$`) } +/** Extract the filename from an absolute or relative path. */ +function basename(path: string): string { + const parts = path.split("/") + return parts[parts.length - 1] ?? path +} + +// ─── Main Component ───────────────────────────────────────────────────────── + export function SessionPreviewTab(props: { diffs: () => Array<{ file: string; status?: string }> touchedFiles?: () => Array<{ file: string; status: string }> @@ -51,8 +81,12 @@ export function SessionPreviewTab(props: { const zoomIn = () => setZoom((z) => Math.min(z + 10, 200)) const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) + /** Whether the project-wide file tree is available (extension pushed files). */ + const hasProjectTree = createMemo(() => previewFileTree().length > 0) + // Derive the file list from touchedFiles (tool-edit history, persists regardless of git state) // supplemented by diffs for any files not already covered. + // Used as fallback when no project tree is available. const markdownFiles = createMemo((): PreviewFileEntry[] => { const seen = new Set() const entries: PreviewFileEntry[] = [] @@ -61,65 +95,55 @@ export function SessionPreviewTab(props: { const touched = props.touchedFiles?.() ?? [] const suffixToAbsolute = new Map() for (const t of touched) { - // For "/Users/jj/.julia/dev/X", try progressively shorter suffixes const parts = t.file.split("/") for (let i = 1; i < parts.length; i++) { suffixToAbsolute.set(parts.slice(i).join("/"), t.file) } } - // Resolve ~/... to absolute using the suffix map const resolveFile = (file: string): string => { if (!file.startsWith("~/")) return file - const suffix = file.slice(2) // strip ~/ + const suffix = file.slice(2) const absolute = suffixToAbsolute.get(suffix) return absolute ?? file } const toEntry = (file: string, status: string): PreviewFileEntry | null => { if (!file.endsWith(".md")) return null - // Use the resolved absolute path as the canonical key const resolved = resolveFile(file) if (seen.has(resolved)) return null seen.add(resolved) const parts = resolved.split("/") - const basename = parts[parts.length - 1] - // Show a short relative path: strip common leading segments until we hit a recognizable dir + const name = parts[parts.length - 1] const relativePath = resolved.replace(/^\/Users\/[^/]+\//, "") return { path: resolved, relativePath, - basename, + basename: name, extension: ".md" as const, changeType: (status === "added" ? "added" : "modified") as "added" | "modified", } } - // Primary: all files touched by edit tools in this session for (const t of touched) { const entry = toEntry(t.file, t.status) if (entry) entries.push(entry) } - - // Supplement: any diff files not already in touchedFiles for (const d of props.diffs()) { const entry = toEntry(d.file, d.status === "added" ? "added" : "modified") if (entry) entries.push(entry) } - return entries }) - // Load file content when a file is selected + // ─── File content loading ─────────────────────────────────────────────── + createEffect( on(selectedFile, (path) => { if (!path) return setLoading(true) - // Resolve the actual filesystem path from the display path - const fsPath = path.startsWith("~/") - ? path.replace("~", process.env.HOME ?? "") - : path + const fsPath = path.startsWith("~/") ? path.replace("~", process.env.HOME ?? "") : path sdk() .client.file.read({ path: fsPath }) @@ -148,6 +172,22 @@ export function SessionPreviewTab(props: { setSelectedFile(undefined) } + // ─── Handle file selection from project tree ──────────────────────────── + + const handleProjectFileClick = (node: FileNode) => { + // node.path is relative; resolve to absolute using the project root + const root = previewProjectRoot() + const absPath = root ? `${root}/${node.path}` : node.path + setSelectedFile(absPath) + } + + const handleEnvFileClick = (node: FileNode) => { + const env = previewEnv() + if (!env) return + const absPath = `${env.root}/${node.path}` + setSelectedFile(absPath) + } + // ─── Raw Editor Save ──────────────────────────────────────────────────── let saveTimer: ReturnType | undefined @@ -155,16 +195,13 @@ export function SessionPreviewTab(props: { let savedTimer: ReturnType | undefined const saveFile = (path: string, content: string) => { - const fsPath = path.startsWith("~/") - ? path.replace("~", process.env.HOME ?? "") - : path + const fsPath = path.startsWith("~/") ? path.replace("~", process.env.HOME ?? "") : path const baseUrl = serverSDK().url if (!baseUrl) return setSaveStatus("saving") - // POST to the file write endpoint fetch(new URL("/file/write", baseUrl), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -215,7 +252,18 @@ export function SessionPreviewTab(props: {
} + fallback={ + } + > + + + } > {(path) => (
@@ -228,9 +276,7 @@ export function SessionPreviewTab(props: { onClick={goBack} aria-label="Back to file list" /> -
- {markdownFiles().find((f) => f.path === path())?.basename ?? path()} -
+
{basename(path())}
+ } >
void + onEnvFileClick: (node: FileNode) => void +}) { + const [filter, setFilter] = createSignal("") + + const filteredProjectFiles = createMemo(() => { + const q = filter().toLowerCase() + const files = previewFileTree() + if (!q) return files + return files.filter((f) => f.toLowerCase().includes(q)) + }) + + const filteredEnvFiles = createMemo(() => { + const env = previewEnv() + if (!env) return [] + const q = filter().toLowerCase() + if (!q) return env.files + return env.files.filter((f) => f.toLowerCase().includes(q)) + }) + + return ( +
+ {/* Search/filter input */} +
+
+ + setFilter(e.currentTarget.value)} + class="flex-1 min-w-0 text-12-regular text-text-base bg-transparent outline-none placeholder:text-text-faint" + /> + + + +
+
+ + {/* Refresh button row */} +
+ + requestPreviewFileTreeRefresh()} + aria-label="Refresh file tree" + /> + +
+ + {/* File tree(s) */} +
+ + + {/* Environment divider + tree */} + + {(env) => ( + 0}> +
+
+ + {env.name} + +
+
+ + + )} + +
+
+ ) +} + +// ─── Legacy File List (no-project fallback) ───────────────────────────────── function PreviewFileList(props: { files: PreviewFileEntry[]; onSelect: (path: string) => void }) { const copyToClipboard = (text: string) => { @@ -377,12 +516,16 @@ function PreviewFileList(props: { files: PreviewFileEntry[]; onSelect: (path: st copyToClipboard(file.basename)}>Copy filename - { - const fullPath = file.path.startsWith("~/") - ? file.path.replace("~", process.env.HOME ?? "") - : file.path - copyToClipboard(fullPath) - }}>Copy full path + { + const fullPath = file.path.startsWith("~/") + ? file.path.replace("~", process.env.HOME ?? "") + : file.path + copyToClipboard(fullPath) + }} + > + Copy full path + diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 53bed146..8b8a50e4 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -1169,6 +1169,17 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return true; } + // #725: Preview file tree refresh request. The webview asks the extension to + // re-scan the project directory and push an updated file list. The actual + // scan + push is handled by pushPreviewFileTree() wired in extension.ts; + // we just need to trigger it here. + if (msg.kind === "preview-file-tree-request") { + // The push function is registered as a command so we can invoke it from + // the bridge without a direct import cycle. + void vscode.commands.executeCommand("amicode.pushPreviewFileTree"); + return true; + } + return false; } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6bdaaca8..2e10f4db 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -43,6 +43,9 @@ import { registerFleetPanel } from "./fleet_panel"; import { isModelConfigured } from "./onboarding_routing"; import { getWorkspaceProjects, type WorkspaceProjectDeps } from "./workspace_projects"; import { detectProjectType } from "./project/detect"; +import { scanRenderableFiles } from "./preview_file_tree"; +import { resolveEnvironment } from "./project/resolve_environment"; +import { envColorIndex } from "./sidebar_bridge"; import { stagePasqalConnector } from "./pasqal_assets"; import { stageModCards, opencodeGlobalConfigRoot } from "./mode_cards"; import { stageModeBundles } from "@amicode/schema"; @@ -1074,6 +1077,55 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), ); + // ── #725: Preview file tree bridge ───────────────────────────────────────── + // Push a filtered file tree to the chat iframe's Preview tab. Sent on + // app-ready and on manual refresh (triggered by the webview via + // "preview-file-tree-request" → chat_bridge → command). + const pushPreviewFileTree = () => { + const projects = getWorkspaceProjects(workspaceProjectDeps); + const researchProject = projects.find((p) => p.type === "research"); + if (!researchProject) return; + + const files = scanRenderableFiles(researchProject.worktree); + + // Resolve bound environment (if any) + const workspaceRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + let environment: { + files: string[]; + root: string; + name: string; + slug: string; + colorIndex: number; + } | undefined; + + const env = resolveEnvironment(researchProject.worktree, workspaceRoots); + if (env) { + environment = { + files: scanRenderableFiles(env.path), + root: env.path, + name: env.name, + slug: env.slug, + colorIndex: envColorIndex(env.slug), + }; + } + + ChatPanel.postToAll({ + source: "amicode", + kind: "preview-file-tree", + files, + projectRoot: researchProject.worktree, + environment, + }); + }; + + ChatPanel.onAppReadyPersistent(pushPreviewFileTree); + + // Register as a command so chat_bridge can trigger refreshes without + // creating a direct import cycle. + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.pushPreviewFileTree", pushPreviewFileTree), + ); + // Vault setup (#13): first-run popup + `amicode.setupVault` command that creates // a LOCAL personal vault (dotfolder-style; no GitHub). This is the first step of // a broader workspace setup — synced tiers (team/public) and the Julia env are diff --git a/packages/extension/src/preview_file_tree.ts b/packages/extension/src/preview_file_tree.ts new file mode 100644 index 00000000..5de4f6ad --- /dev/null +++ b/packages/extension/src/preview_file_tree.ts @@ -0,0 +1,100 @@ +// preview_file_tree.ts — Extension-side recursive directory scan for the +// Preview tab's project-wide file tree (#725). +// +// Produces a list of relative paths (forward-slash separated) filtered to +// RENDERABLE_EXTENSIONS. Called by pushPreviewFileTree() in extension.ts and +// pushed to the webview via the "preview-file-tree" bridge message. +// +// Design choices: +// - Synchronous fs.readdirSync for simplicity (runs once on app-ready + refresh, +// not in a hot path). Async could be added if projects get very large. +// - Hidden directories (dotfiles), node_modules, __pycache__, .julia skipped. +// - Max depth of 20 to avoid runaway scans in pathological directory structures. + +import { readdirSync, statSync } from "node:fs"; +import { join, extname, relative } from "node:path"; + +// ── Constants ─────────────────────────────────────────────────────────────── + +/** File extensions the Preview tab can render. Used to filter the file tree. */ +export const RENDERABLE_EXTENSIONS = new Set([ + ".md", + ".txt", + ".pdf", + ".tex", + ".bib", + ".tikz", + ".sty", + ".cls", + ".png", + ".jpg", + ".svg", + ".log", +]); + +/** Directories to always skip during recursive scan. */ +const SKIP_DIRS = new Set([ + "node_modules", + "__pycache__", + ".git", + ".hg", + ".svn", + ".julia", + ".vscode", + ".idea", + "build", + "dist", +]); + +const MAX_DEPTH = 20; + +// ── Scanner ───────────────────────────────────────────────────────────────── + +/** + * Recursively scan a directory and return relative paths of renderable files. + * Paths use forward slashes regardless of platform. + * + * Returns an empty array if the directory does not exist or is unreadable. + */ +export function scanRenderableFiles(rootDir: string): string[] { + const results: string[] = []; + + function walk(dir: string, depth: number): void { + if (depth > MAX_DEPTH) return; + + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; // unreadable directory + } + + for (const entry of entries) { + // Skip hidden entries (dotfiles/dotdirs) and known skip dirs + if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue; + + const fullPath = join(dir, entry); + + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; // broken symlink or permission issue + } + + if (stat.isDirectory()) { + walk(fullPath, depth + 1); + } else if (stat.isFile()) { + const ext = extname(entry).toLowerCase(); + if (RENDERABLE_EXTENSIONS.has(ext)) { + // Produce a forward-slash relative path + const rel = relative(rootDir, fullPath).replaceAll("\\", "/"); + results.push(rel); + } + } + } + } + + walk(rootDir, 0); + return results; +} diff --git a/packages/extension/test/preview_file_tree.test.ts b/packages/extension/test/preview_file_tree.test.ts new file mode 100644 index 00000000..1148f91b --- /dev/null +++ b/packages/extension/test/preview_file_tree.test.ts @@ -0,0 +1,182 @@ +// preview_file_tree.test.ts — TDD tests for the Preview tab file tree scanner. +// Tests the extension-side recursive directory scan + renderable-extension filter. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// The module under test — will be created in the GREEN phase. +import { + RENDERABLE_EXTENSIONS, + scanRenderableFiles, +} from "../src/preview_file_tree"; + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +let testDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `amicode-preview-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +/** Create a file at a relative path inside testDir. */ +function touch(relativePath: string, content = ""): void { + const full = join(testDir, relativePath); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); +} + +// ── RENDERABLE_EXTENSIONS ─────────────────────────────────────────────────── + +describe("RENDERABLE_EXTENSIONS", () => { + it("includes all expected renderable types", () => { + const expected = [ + ".md", ".txt", ".pdf", ".tex", ".bib", ".tikz", + ".sty", ".cls", ".png", ".jpg", ".svg", ".log", + ]; + for (const ext of expected) { + expect(RENDERABLE_EXTENSIONS.has(ext), `missing ${ext}`).toBe(true); + } + }); + + it("does not include non-renderable types", () => { + const excluded = [".js", ".ts", ".json", ".toml", ".py", ".jl", ".lock", ".exe"]; + for (const ext of excluded) { + expect(RENDERABLE_EXTENSIONS.has(ext), `should not include ${ext}`).toBe(false); + } + }); +}); + +// ── scanRenderableFiles ───────────────────────────────────────────────────── + +describe("scanRenderableFiles", () => { + it("returns relative paths for renderable files", () => { + touch("paper/main.tex", "\\documentclass{article}"); + touch("paper/refs.bib", "@article{foo}"); + touch("notes.md", "# Notes"); + touch("figure.png", "PNG"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("paper/main.tex"); + expect(result).toContain("paper/refs.bib"); + expect(result).toContain("notes.md"); + expect(result).toContain("figure.png"); + }); + + it("filters out non-renderable files", () => { + touch("src/main.jl", "println()"); + touch("package.json", "{}"); + touch("Manifest.toml", ""); + touch("notes.md", "# ok"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("notes.md"); + expect(result).not.toContain("src/main.jl"); + expect(result).not.toContain("package.json"); + expect(result).not.toContain("Manifest.toml"); + }); + + it("returns empty array for empty directory", () => { + const result = scanRenderableFiles(testDir); + expect(result).toEqual([]); + }); + + it("handles nested directory structures", () => { + touch("a/b/c/deep.tex", "deep"); + touch("a/b/notes.md", "notes"); + touch("top.pdf", "pdf"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("a/b/c/deep.tex"); + expect(result).toContain("a/b/notes.md"); + expect(result).toContain("top.pdf"); + }); + + it("skips hidden directories (dotfiles)", () => { + touch(".git/config", "git"); + touch(".vscode/settings.json", "{}"); + touch("visible.md", "ok"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("visible.md"); + expect(result.some((p) => p.includes(".git"))).toBe(false); + expect(result.some((p) => p.includes(".vscode"))).toBe(false); + }); + + it("skips node_modules", () => { + touch("node_modules/pkg/readme.md", "readme"); + touch("real.md", "ok"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("real.md"); + expect(result.some((p) => p.includes("node_modules"))).toBe(false); + }); + + it("skips __pycache__ and .julia directories", () => { + touch("__pycache__/cache.txt", ""); + touch(".julia/packages.txt", ""); + touch("real.txt", "ok"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("real.txt"); + expect(result.some((p) => p.includes("__pycache__"))).toBe(false); + expect(result.some((p) => p.includes(".julia"))).toBe(false); + }); + + it("returns paths with forward slashes on all platforms", () => { + touch("sub/dir/file.tex", "tex"); + + const result = scanRenderableFiles(testDir); + for (const p of result) { + expect(p).not.toContain("\\"); + } + }); + + it("handles non-existent directory gracefully", () => { + const result = scanRenderableFiles(join(testDir, "nonexistent")); + expect(result).toEqual([]); + }); + + it("respects max depth to avoid runaway scans", () => { + // Create a deeply nested structure (10 levels) + let current = ""; + for (let i = 0; i < 10; i++) { + current = current ? `${current}/d${i}` : `d${i}`; + } + touch(`${current}/deep.md`, "deep"); + touch("shallow.md", "shallow"); + + const result = scanRenderableFiles(testDir); + // Should find the shallow file + expect(result).toContain("shallow.md"); + // The deep file may or may not be found depending on max depth implementation + // but the scan should not hang or crash + expect(Array.isArray(result)).toBe(true); + }); + + it("handles files with multiple dots in name", () => { + touch("paper.v2.final.tex", "tex"); + touch("data.2024-01-01.log", "log"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("paper.v2.final.tex"); + expect(result).toContain("data.2024-01-01.log"); + }); + + it("case-insensitive extension matching", () => { + touch("photo.PNG", "png"); + touch("photo.Jpg", "jpg"); + touch("paper.TEX", "tex"); + + const result = scanRenderableFiles(testDir); + expect(result).toContain("photo.PNG"); + expect(result).toContain("photo.Jpg"); + expect(result).toContain("paper.TEX"); + }); +}); From 0b1775e7960d38a987d8395d359f6704e89424d9 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 8 Sep 2026 17:26:55 -0400 Subject: [PATCH 3/8] chore(overlay): sync renderer dispatch from opencode fork (#726) --- .../session/preview-content-area.tsx | 323 ++++++++++++++++++ .../session/session-preview-tab.tsx | 263 +++++++------- .../app/src/utils/preview-markdown.ts | 11 + .../app/src/utils/renderer-dispatch.ts | 87 +++++ 4 files changed, 563 insertions(+), 121 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/preview-content-area.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/utils/preview-markdown.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/utils/renderer-dispatch.ts diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-content-area.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-content-area.tsx new file mode 100644 index 00000000..56d7a5e4 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-content-area.tsx @@ -0,0 +1,323 @@ +// preview-content-area.tsx — Content area for the Preview tab (#726). +// Routes files to type-appropriate renderers based on renderer kind. +// PDF and CodeMirror are slots until slices 3 and 4 land. + +import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js" +import { Markdown } from "@opencode-ai/session-ui/markdown" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { SegmentedControlV2, SegmentedControlItemV2 } from "@opencode-ai/ui/v2/segmented-control-v2" +import { preprocessMarkdown } from "@/utils/preview-markdown" +import { + rendererForExtension, + toolbarForRenderer, + extFromPath, + isReadOnly, + type RendererKind, +} from "@/utils/renderer-dispatch" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" + +// ─── Types ────────────────────────────────────────────────────────────────── + +type ContentMode = "preview" | "editor" + +// ─── Main Component ───────────────────────────────────────────────────────── + +export function PreviewContentArea(props: { + filePath: string + onBack: () => void +}) { + const sdk = useSDK() + const serverSDK = useServerSDK() + + const ext = createMemo(() => extFromPath(props.filePath)) + const kind = createMemo((): RendererKind => rendererForExtension(ext())) + const toolbar = createMemo(() => toolbarForRenderer(kind(), false /* TODO: wire texAvailable from #729 */)) + const readOnly = createMemo(() => isReadOnly(ext())) + + const [content, setContent] = createSignal("") + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(false) + const [mode, setMode] = createSignal("preview") + const [zoom, setZoom] = createSignal(100) + + const zoomIn = () => setZoom((z) => Math.min(z + 10, 200)) + const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) + + // ─── File content loading ─────────────────────────────────────────────── + + createEffect( + on( + () => props.filePath, + (path) => { + if (!path) return + setLoading(true) + setError(false) + + sdk() + .client.file.read({ path }) + .then((result) => { + const data = result.data + if (data && data.type === "text") { + setContent(data.content) + } else if (data && data.type === "binary") { + // Binary files (images) — store the base64 data + setContent(data.content) + } else { + setContent("") + } + }) + .catch(() => { + setError(true) + setContent("") + }) + .finally(() => { + setLoading(false) + }) + }, + ), + ) + + // ─── Save logic (editor mode) ────────────────────────────────────────── + + let saveTimer: ReturnType | undefined + const [saveStatus, setSaveStatus] = createSignal<"idle" | "saving" | "saved">("idle") + let savedTimer: ReturnType | undefined + + const saveFile = (path: string, newContent: string) => { + const baseUrl = serverSDK().url + if (!baseUrl) return + + setSaveStatus("saving") + + fetch(new URL("/file/write", baseUrl), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, content: newContent }), + }) + .then(() => { + setSaveStatus("saved") + if (savedTimer) clearTimeout(savedTimer) + savedTimer = setTimeout(() => setSaveStatus("idle"), 2000) + }) + .catch(() => { + setSaveStatus("idle") + }) + } + + const handleEdit = (newContent: string) => { + setContent(newContent) + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => saveFile(props.filePath, newContent), 1000) + } + + const handleImmediateSave = () => { + if (saveTimer) clearTimeout(saveTimer) + saveFile(props.filePath, content()) + } + + onCleanup(() => { + if (saveTimer) clearTimeout(saveTimer) + if (savedTimer) clearTimeout(savedTimer) + }) + + // ─── Helpers ──────────────────────────────────────────────────────────── + + const basename = () => { + const parts = props.filePath.split("/") + return parts[parts.length - 1] ?? props.filePath + } + + // ─── Render ───────────────────────────────────────────────────────────── + + return ( +
+ {/* Toolbar */} +
+ +
{basename()}
+ + {/* Save status (editor kind) */} + + + {saveStatus() === "saving" ? "Saving..." : "Saved"} + + + + {/* Zoom control (pdf/image/markdown) */} + +
+ { + const val = parseInt(e.currentTarget.value) + if (!isNaN(val) && val >= 50 && val <= 200) setZoom(val) + }} + onBlur={(e) => { + e.currentTarget.value = `${zoom()}%` + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur() + }} + /> +
+ + +
+
+
+ + {/* Build button (TeX, when available — wired by slice 5) */} + + + + + + + {/* Mode toggle (markdown: preview ↔ editor) */} + + { + if (value === "preview" || value === "editor") setMode(value) + }} + class="!w-auto" + aria-label="View mode" + > + + + + + + + + + + + + +
+ + {/* Content area */} +
+ Loading...
}> + }> + }> + +
+ +
+
+ + + + + + + +
+ {basename()} +
+
+
+
+ +
+
+ ) +} + +// ─── Slot components (replaced by real implementations in later slices) ───── + +/** PDF viewer placeholder — replaced by pdfjs-dist in slice 3 (#727). */ +function PDFSlot(props: { zoom: number }) { + return ( +
+
+ +

PDF viewer

+

Coming in slice 3

+
+
+ ) +} + +/** Editor placeholder — replaced by CodeMirror 6 PlainEditor in slice 4 (#728). */ +function EditorSlot(props: { + content: string + readOnly: boolean + onEdit: (content: string) => void + onSave: () => void +}) { + return ( +