diff --git a/CONTEXT.md b/CONTEXT.md index edb7344b..53444e2c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -135,11 +135,11 @@ A sandboxed ES-module card rendered in an iframe within Home. Authored by the ag _Avoid_: Card (ambiguous — the UI has many cards), tile (as the concept name — tile is a size class) **Sidebar**: -The webview in the VS Code activity bar container, showing project navigation and system status. Contains action buttons (open chat, create project), a session-aware unified project tree (Research Projects with lifecycle metadata expanding into file trees; Dev Projects as plain expandable folders), and a collapsible fleet section (deferred). The sidebar is navigation chrome — it follows the active session's project binding but never drives session switching. Single-clicking a file opens it as a tab in Preview (the multi-document file workspace in the side panel); double-clicking opens a native VS Code editor tab. +The webview in the VS Code activity bar container, showing project navigation and system status. Contains action buttons (open chat, create project), a session-aware unified project tree (Research Projects with lifecycle metadata expanding into file trees; Dev Projects as plain expandable folders), and a collapsible fleet section (deferred). The sidebar is navigation chrome — it follows the active session's project binding but never drives session switching. Single-clicking a file opens it in Preview (the multi-document file workspace in the side panel); double-clicking opens a native VS Code editor tab. _Avoid_: Explorer (VS Code's native file explorer is separate), Panel (the in-app dismissible drawer is a different concept) **Preview**: -The multi-document file workspace in the side panel. Holds zero or more files as inner tabs, each rendering its content (markdown rendered with a toggle to edit; text/code files in a CodeMirror editor; images and PDFs inline). Files arrive via Sidebar single-click or a Chat file pill and accumulate as tabs — each closeable and drag-reorderable; opening an already-open file focuses its existing tab rather than duplicating it. A breadcrumb bar under each pane's tab strip shows the active file's project-relative path with interactive sibling navigation. Supports recursive split panes via edge-drop: dragging a tab toward a pane's edge divides the view, and each resulting pane keeps its own tab bar, breadcrumb, zoom, and preview/edit toggle. Empty panes auto-collapse; a minimum pane dimension is enforced so splits can't shrink below a usable size. Opens and activates automatically when the first file is selected; shows a placeholder when no file is open. For committed editing, double-click the Sidebar entry to open a native VS Code tab. +The multi-document file workspace in the side panel. Holds up to eight files as unique inner tabs, each rendering its content (markdown rendered with a toggle to edit; text/code files in a CodeMirror editor; images and PDFs inline). Files arrive via Sidebar single-click or a Chat file pill; opening an already-open file focuses its existing tab rather than duplicating it. Each open file retains its current view while it remains open, including through inner-tab changes, pane moves, and outer-tab changes. A breadcrumb bar under each pane's tab strip provides project-relative sibling navigation. Recursive split panes are created by edge-drop; each pane has its own tab bar, breadcrumb, zoom, and preview/edit toggle. Empty panes auto-collapse, and a minimum pane dimension keeps the layout usable. Preview opens and activates when the first file is selected and shows a placeholder when no file is open. For committed editing, double-click the Sidebar entry to open a native VS Code tab. _Avoid_: Editor (Preview is a multi-document viewer, not a primary editor — committed editing belongs in a native VS Code tab), File browser (the Sidebar is still the primary project-wide file tree; the breadcrumb is a contextual sibling-navigation aid scoped to the open file, not a second tree) ### Orthogonal axes diff --git a/docs/adr/0013-preview-workspace-split-panes.md b/docs/adr/0013-preview-workspace-split-panes.md index 608cc3e2..ac5492e8 100644 --- a/docs/adr/0013-preview-workspace-split-panes.md +++ b/docs/adr/0013-preview-workspace-split-panes.md @@ -1,17 +1,48 @@ -# Preview becomes a multi-file split-pane workspace; single-file companion model retired +# Preview becomes a renderer-preserving multi-file split-pane workspace -Status: proposed (2026-09-09) +Status: amended (2026-09-09) Tracking: harmoniqs/amicode#940 · Glossary update: `CONTEXT.md` (Preview, Sidebar) -Preview — previously a single-file companion viewer that replaced its content on every sidebar click (#931, landed the same day) — becomes a multi-document workspace: files accumulate as closeable inner tabs, a breadcrumb bar under each pane's tab strip provides project-relative path navigation with sibling dropdowns, and tabs can be dragged to edge drop-zones to create recursive split panes, each with independent zoom and preview/edit controls. The outer side-panel tab bar (Home, Files Changed, Context, Pulse Inspector, Preview) is unaffected — Preview remains one tab there; the new tab-and-pane machinery is entirely contained within it. +## Decision -**Why:** The single-file companion model (#931) optimized for a different problem — one file at a time, driven externally, no navigation chrome competing with the Sidebar. In practice, researchers comparing two files (a script and its output, a spec and its implementation) lost their place every time a second file replaced the first. The companion model traded away exactly the capability multi-file work needs. Sidebar remains the project-wide file tree; the breadcrumb is a narrower, contextual navigation aid scoped to the currently open file's siblings, not a second file browser. +Preview remains one outer side-panel tab while becoming a multi-document workspace. Files opened by Sidebar single-click or Chat file pills accumulate as closeable inner tabs. A breadcrumb provides contextual sibling navigation, and dragging an inner tab reorders it, transfers it between panes, or creates a recursive split at a pane edge. The Sidebar remains the project-wide file tree; Preview is not a second file browser. -**Conditions of acceptance:** Files opened via Sidebar single-click or a Chat file pill accumulate as inner tabs in the focused pane rather than replacing the current file; re-opening an already-open file (from any entry point, including breadcrumb sibling navigation) focuses its existing tab instead of duplicating it — enforced workspace-wide, not just within one pane. Tabs close via an explicit control and are drag-reorderable. A breadcrumb bar shows the active file's project-relative path as clickable segments, each expanding to a sibling dropdown. Dragging a tab to a pane's edge splits that pane (horizontal or vertical); splits are recursive, subject to a 150px minimum pane dimension that refuses drops which would violate it and clamps resizes at the same floor. Each pane carries independent zoom and preview/edit-toggle state. The entire workspace — every pane, every tab, all per-tab state including unsaved edits — survives switching to another outer side-panel tab and back. Double-click-to-open-in-VS-Code and the outer tab bar are unchanged. +The workspace is a thin layout shell over the existing Preview renderers. It owns only tab-to-pane assignment, pane geometry, focused-pane state, pane zoom, dirty indicators, and the eight-tab resource limit. Each open file owns one persistent baseline renderer instance. Switching tabs, moving a tab between panes, or switching away from the outer Preview tab preserves that renderer instance rather than reconstructing its document, CodeMirror state, PDF layout, scroll position, or focus. -**Accepted costs:** The workspace reintroduces navigation surface (the breadcrumb) that #931 deliberately removed — a future reader of #931's history will see this as a partial reversal, not a straight line; the ADR exists so that reversal reads as deliberate. Per-pane state (zoom, mode, scroll, unsaved content) roughly doubles or triples the state `SessionPreviewTab` used to hold for a single file, now split across `PreviewWorkspace`/`PreviewPane`. The workspace store must be lifted into the layout context (above `session-side-panel.tsx`'s `` gate) rather than owned locally, because SolidJS disposes a `` branch's reactive scope on every toggle — the single-file `SessionPreviewTab` already loses its local state this way today, and a multi-file, multi-pane workspace makes that loss far more costly if inherited unfixed. At the 330px minimum panel width (`WORK_COLUMN_WIDTH_MIN`), only one horizontal split is practically usable before panes drop below a comfortable reading width — accepted as a constraint of the side-panel form factor. Splitting, cross-pane tab transfer, and pane resizing are pointer/drag-only in this version; there is no keyboard-accessible path for any of the three. +The outer Preview content remains mounted while another outer tab is selected. It is hidden and inert rather than disposed. The pane canvas may provide CSS-only overflow when recursive minimum geometry exceeds the Work Column, but it does not observe, store, restore, or otherwise control scroll position. -**Considered:** (A) Extend `SessionPreviewTab` in place with tab/breadcrumb/split logic (rejected: the component is 119 lines today, but tab-bar, pane-tree, and breadcrumb logic are three distinct responsibilities that would tangle together as each grows independently — not a current-size problem but a projected-shape one); (B) **new `PreviewWorkspace`/`PreviewPane`/`PreviewBreadcrumb` component tree** (chosen: clean separation, each component independently testable, `SessionPreviewTab` shrinks to a thin shell); (C) a generic `SplitPaneLayout` primitive built first and specialized for Preview (rejected for now: speculative reuse — no other surface has asked for splitting yet — and the abstraction would be guessed at rather than derived from a second real use; extracting it from B later is straightforward if that need materializes). +## Why -**Flip condition:** If a second surface (e.g. Files Changed) independently needs split-pane viewing, extract the pane-tree logic from `PreviewWorkspace` into the generic primitive considered as option C, rather than duplicating the tree/drag machinery. If the side panel's minimum width increases substantially in a future layout pass, revisit the 150px pane minimum and how many practical splits it should allow. +The single-file companion model (#931) optimized for externally driven, one-file-at-a-time reading. Researchers comparing a script and its output, or a spec and implementation, lost their place whenever the next selection replaced the first. Multi-file work needs retained documents and panes. + +The original form of this ADR chose a lifted workspace state store plus a renderer-state hydration adapter. That duplicated ownership already held by `PreviewFileView`, CodeMirror, and the PDF renderer. It introduced a canvas-scroll feedback loop and renderer lifecycle races that broke ordinary scrolling and Markdown editing. Preserving the working renderer instances makes the layout shell smaller and gives each layer one owner. + +## Conditions Of Acceptance + +- A path has at most one live inner tab across the workspace; re-opening it focuses its existing pane and tab. +- At most eight renderer instances are open. The ninth open requires an explicit close; clean tabs are never silently evicted. +- A live renderer remains intact through inner-tab changes, pane transfer or split, and outer-tab changes. Draft text, local scroll, selection, and loaded content stay with that renderer. +- Dirty state is tab chrome only. Closing a dirty tab offers save, discard, or cancel; the layout shell never stores draft text. +- Drag is the primary path for reorder, transfer, and edge split. A Preview-scoped nested-DnD spike must prove non-interference with outer tabs before that interaction ships. +- Each leaf has a 150px minimum dimension. When the tree exceeds the Work Column, a CSS-only canvas scrolls without persistence or restoration logic. +- Pane zoom and the existing preview/edit controls are pane-scoped. Breadcrumb navigation is added only after tab, renderer, and pane interaction gates pass. +- Double-click-to-open-in-VS-Code and the outer side-panel tab bar remain unchanged. + +## Rejected Alternatives + +1. **Renderer-state hydration adapter** -- rejected. Capturing and restoring drafts, scroll, and focus creates a second owner for state the renderer already owns. +2. **One active renderer per pane** -- rejected. Inactive tabs lose live editing and view state when their renderer is replaced. +3. **Generic split-pane primitive first** -- deferred. No second surface currently establishes a real reuse boundary. + +## Accepted Costs + +Keeping renderer instances mounted consumes more memory and background resources than hydration. The workspace therefore caps live tabs at eight and requires explicit closure. Keeping Preview mounted while inactive also retains its renderer resources, but avoids destructive remounting during ordinary navigation. + +## Validation + +Browser interaction tests are required before a Dev Host build is vendored. They must prove stable Markdown focus after grammar loading, ordinary Preview scrolling, Cmd+S behavior, persistence across outer-tab switches, renderer identity across drag relocation, dirty-close confirmation, and non-interference between Preview and outer-tab DnD. Unit and type tests support but do not replace these gates. + +## Flip Condition + +If a second surface needs the same renderer-preserving pane behavior, extract only the layout shell after two real uses establish its interface. If eight retained renderers prove insufficient in measured use, revisit the cap with evidence rather than introducing silent eviction or a state adapter. diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 4c7db956..3ae41f02 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -968,10 +968,10 @@ "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/design-polish.css": "42cc6efaefe9a71dedd12fcb0bf2549453d9a46097cb025cf087064b29d3ccce", + "packages/app/src/app.tsx": "91ea1817db2f7e21caae642f3c2e276b4835fcecf499699cb787e7a5521ae20b", + "packages/app/src/design-polish.css": "ee180766e073ef9bf820ead1d2fb02fe92a2e9f73e987d5f161ba2045871c2ef", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", - "packages/app/src/index.css": "08179e06ce2d419a2d98acc96025f91c7709062ea9f3ad245e88dc35e75ff9f7", + "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", "packages/core/src/config.ts": "0b6b81bea6a3a09285daa4bd70757e6bbe93cf2508121e1062b71bb24654d692", "packages/core/src/location-mutation.ts": "5fa852c48e98cec513346f848b422c2d5d6d4b753b39da851c73be073a03511a", @@ -1074,7 +1074,7 @@ "packages/app/src/context/language.tsx": "cb545e04c128bf981b2b72ac407f9220cf9e80337fe8c87482ffe85407e0cf9a", "packages/app/src/context/layout-tabs.test.ts": "4d9fdbe963306f164b2f48eac3b6a28c5a703c1758bf14b2cc4796720d8fa72c", "packages/app/src/context/layout-tabs.ts": "741506ce165f68cdb0b8f2931a2dad3e26c05880f9286c3daf04ec979bac21c5", - "packages/app/src/context/layout.tsx": "cc9dffc8ddacd825038610a9fd4783a41852853f1d2fe1bb65b4e8348db1e78d", + "packages/app/src/context/layout.tsx": "7767c8b5f64efd63390cbe99128686bec2f1da7e8f2b21530df4c3fd67d171bb", "packages/app/src/context/local-agent.test.ts": "a5a9d60bb4401d409218cc247c1cc06ede8b7878ddf01dc6080328f54eb9cade", "packages/app/src/context/local-agent.ts": "0aab67e695dc3bb45a733ac0df80a0a5e14cfe29b2375b6dc3cd07fbccea33e2", "packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", @@ -1425,12 +1425,12 @@ "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", "packages/app/src/components/session/pdf-canvas-view.tsx": "02032c64833583c9b0b62ed0ec7dc9770e7fa368b4659db45730118686ca6a27", - "packages/app/src/components/session/preview-file-view.tsx": "6e1949ed11bb3ea65023021aeec86875f2853bb84948d2147436c6f8be395da4", + "packages/app/src/components/session/preview-file-view.tsx": "1ec448960876937e3e740c960c5780f6244dca7f35a2462de699d9eb3dbda798", "packages/app/src/components/session/session-chats-dropdown.test.ts": "2003d2a15781337ea6bff2c68e730cc5b6df38697f15d927ab26913936444db7", "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": "4eb8127182f34aa9d141f5d9cb8eba330592ef0e2cf7c2cfe422e788f2fc67c5", + "packages/app/src/components/session/session-preview-tab.tsx": "3467aa44fa8db70d313e84ed0b31cc4d11616a53060dcf55bc6e5b68f5001c2f", "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", @@ -1481,8 +1481,8 @@ "packages/app/src/pages/session/helpers.ts": "8d0106a5ec3f01a666bd840e20b6bfb28d0e88b8c8c51fc1fdd7eaa33e9daafc", "packages/app/src/pages/session/session-panel-width.test.ts": "9482afba7fbce254cbbd21ef885e9e9620b981616681635376f466478f611155", "packages/app/src/pages/session/session-panel-width.ts": "02ee3a02ed78db2eac47c2528055cf78aef95f389641e929ddc56c17471dcbe2", - "packages/app/src/pages/session/session-side-panel-structure.test.ts": "c138fe905498c8326f459dccba61b146af6dfb12b78480303723b5a85046931a", - "packages/app/src/pages/session/session-side-panel.tsx": "4c93427154a87f78ac40b2d9b772cade515e5f4b31468fffdc4f5c228a84a908", + "packages/app/src/pages/session/session-side-panel-structure.test.ts": "b2d1d007d9200d0ac4a0889e45c2dbe57a324a10cb26ae9ab8f420d3c890402b", + "packages/app/src/pages/session/session-side-panel.tsx": "120f756cce7139d72a610f9bddb3be861a9a830989ad2af3ef549b2cb5a70f68", "packages/app/src/pages/session/terminal-panel-v2.tsx": "68dad9307f1d2abf3ff9248e451bd08005acf01ddc46b30a6d01b58f4a90dce0", "packages/app/src/pages/session/use-amicode-commands.test.ts": "65671d054c404edd2b799b0f6591a9c055e6ecb454a674fa0bd9a7fa6f68e111", "packages/app/src/pages/session/use-amicode-commands.tsx": "88bfdad9ae6dfd920b8e6a14ffebd2cf9e52b8f67bb9284cb358ad940643ca67", diff --git a/packages/app-bundle/overlay/packages/app/src/app.tsx b/packages/app-bundle/overlay/packages/app/src/app.tsx index 19128cf8..b22536e6 100644 --- a/packages/app-bundle/overlay/packages/app/src/app.tsx +++ b/packages/app-bundle/overlay/packages/app/src/app.tsx @@ -74,6 +74,7 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } import { createSessionLineage } from "@/pages/session/session-lineage" import { bugDockController } from "@/pages/session/composer/bug-dock-controller" import { postBugReportPoke } from "@/utils/amicode-bug-report" +import { adoptExplorerIconTheme } from "@/utils/vscode-explorer-icon-theme" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { LegacyHome } from "@/pages/home/legacy-home" @@ -426,7 +427,7 @@ function DraftProviders(props: ParentProps) { function AmicodeThemeBridge() { const theme = useTheme() const onMsg = (e: MessageEvent) => { - const d = e.data as { source?: string; kind?: string; colorScheme?: string } | undefined + const d = e.data as { source?: string; kind?: string; colorScheme?: string; theme?: unknown } | undefined if (d?.source !== "amicode") return // amicode#200 AC6: the Connect Cloud palette command deep-links into the // defaults capsule's compute-connect flow (consumed when home is showing). @@ -446,11 +447,20 @@ function AmicodeThemeBridge() { adoptWorkspaceProjects((d as { projects?: unknown[] }).projects as Parameters[0]) return } + if (d.kind === "explorer-icon-theme") { + adoptExplorerIconTheme(d.theme) + return + } if (d.kind !== "theme") return if (d.colorScheme === "light" || d.colorScheme === "dark") theme.setColorScheme(d.colorScheme) } window.addEventListener("message", onMsg) onCleanup(() => window.removeEventListener("message", onMsg)) + // Preview tabs need the current icon theme after every iframe boot; the host + // replies with opaque, allowlisted asset bytes rather than a file location. + if (window.parent !== window) { + window.parent.postMessage({ source: "amicode", kind: "explorer-icon-theme-request" }, "*") + } // ⌘⇧P / Ctrl+Shift+P: when embedded in the amicode webview (we have a // parent), the EDITOR's Command Palette wins over the app's own palette — // capture-phase so the in-app binding never sees it; forwarded over the diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx index d193f28b..6f8216ed 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx @@ -27,7 +27,6 @@ import { preprocessMarkdown } from "@opencode-ai/session-ui/v2/markdown-utils" import { RENDERABLE_EXTENSIONS } from "@opencode-ai/session-ui/v2/markdown-utils" import { useSDK } from "@/context/sdk" import { useServerSDK } from "@/context/server-sdk" -import type { PreviewFileState } from "@opencode-ai/session-ui/v2/preview-nav-state" import { PreviewEditor } from "@opencode-ai/session-ui/v2/preview-editor" import { PdfCanvasView } from "./pdf-canvas-view" @@ -69,10 +68,9 @@ const IMAGE_WRAPPER_PADDING = 32 export function PreviewFileView(props: { filePath: string - fileState: PreviewFileState - onModeChange: (mode: "preview" | "edit") => void - onUnsavedContent: (content: string | null) => void - onSave: (path: string, content: string) => void + onDirtyChange: (dirty: boolean) => void + onSaveComplete?: () => void + saveRequest?: () => number onSaveStatusChange?: (status: "idle" | "saving" | "saved") => void zoom: () => number zoomIn: () => void @@ -82,6 +80,8 @@ export function PreviewFileView(props: { const sdk = useSDK() const serverSDK = useServerSDK() const [fileContent, setFileContent] = createSignal("") + const [mode, setMode] = createSignal<"preview" | "edit">("preview") + const [unsavedContent, setUnsavedContent] = createSignal(null) const [loading, setLoading] = createSignal(true) const [fileType, setFileType] = createSignal(null) @@ -171,12 +171,14 @@ export function PreviewFileView(props: { props.onSaveStatusChange?.(saveStatus()) }) - const saveFile = async (filePath: string, content: string) => { + const saveFile = async (filePath: string, content: string, closeAfterSave = false) => { setSaveStatus("saving") try { await serverSDK().client.file.write({ path: filePath, content }) setSaveStatus("saved") - props.onUnsavedContent(null) + setUnsavedContent(null) + props.onDirtyChange(false) + if (closeAfterSave) props.onSaveComplete?.() if (savedTimer) clearTimeout(savedTimer) savedTimer = setTimeout(() => setSaveStatus("idle"), 2000) } catch { @@ -185,16 +187,27 @@ export function PreviewFileView(props: { } const handleEdit = (content: string) => { - props.onUnsavedContent(content) + setUnsavedContent(content) + props.onDirtyChange(true) setFileContent(content) } const handleImmediateSave = () => { - if (props.fileState.unsavedContent !== null) { - saveFile(props.filePath, props.fileState.unsavedContent) - } + const content = unsavedContent() + if (content !== null) void saveFile(props.filePath, content) } + createEffect( + on( + () => props.saveRequest?.() ?? 0, + (request) => { + if (request === 0) return + const content = unsavedContent() + if (content !== null) void saveFile(props.filePath, content, true) + }, + ), + ) + onCleanup(() => { if (savedTimer) clearTimeout(savedTimer) }) @@ -206,7 +219,7 @@ export function PreviewFileView(props: { // Zoom is disabled in edit mode — pill disappears entirely const isEditing = () => { const cat = category() - if (cat === "markdown") return props.fileState.mode === "edit" + if (cat === "markdown") return mode() === "edit" if (cat === "image" || cat === "pdf") return false return true // text/code files are always in edit mode } @@ -434,10 +447,10 @@ export function PreviewFileView(props: {
{ if (value === "preview" || value === "edit") { - props.onModeChange(value) + setMode(value) } }} class="!w-auto" @@ -503,7 +516,7 @@ export function PreviewFileView(props: { {/* Text-based rendering by category */} number; children: JSX.Element }) { + const sortable = useSortable({ + get id() { + return props.path + }, + get index() { + return props.index() + }, + }) -// ─── Main Component ───────────────────────────────────────────────────────── + return ( +
+ {props.children} +
+ ) +} export function SessionPreviewTab(props: { previewFile: Accessor }) { - // ─── Per-file State ────────────────────────────────────────────────────── + // ─── Workspace state ───────────────────────────────────────────────────── - const [fileStates, setFileStates] = createStore({}) + const [dirtyPaths, setDirtyPaths] = createStore>({}) + const [saveRequests, setSaveRequests] = createStore>({}) const [zoom, setZoom] = createSignal(100) + const [openedPaths, setOpenedPaths] = createSignal([]) + const [selectedPath, setSelectedPath] = createSignal(null) + const [capacityMessage, setCapacityMessage] = createSignal(null) + const [closingPath, setClosingPath] = createSignal(null) + let previewTabList: HTMLDivElement | undefined - const zoomIn = () => setZoom((z) => Math.min(z + 10, 500)) - const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) - const onZoomChange = (value: number) => setZoom(Math.round(Math.min(Math.max(value, 50), 500))) - - const getFileState = (path: string): PreviewFileState => { - return fileStates[path] ?? { mode: "preview", scrollPosition: 0, unsavedContent: null } - } - - const setFileState = (path: string, update: Partial) => { - const defaults: PreviewFileState = { mode: "preview", scrollPosition: 0, unsavedContent: null } - setFileStates(path, (prev) => ({ - ...defaults, - ...prev, - ...update, - })) + const openPath = (path: string) => { + if (openedPaths().includes(path)) { + setSelectedPath(path) + setCapacityMessage(null) + return + } + if (openedPaths().length === 8) { + setCapacityMessage("Close an existing Preview tab before opening another file.") + return + } + setOpenedPaths((paths) => [...paths, path]) + setSelectedPath(path) + setCapacityMessage(null) } - // ─── Header Display ───────────────────────────────────────────────────── + createEffect( + on( + () => props.previewFile(), + (path) => { + if (!path) return + openPath(path) + }, + ), + ) - const headerTitle = createMemo(() => { - const file = props.previewFile() - if (file) { - const parts = file.split("/") - return parts[parts.length - 1] + onMount(() => { + const handlePreviewFile = (event: MessageEvent) => { + const data = event.data as { source?: string; kind?: string; path?: string } | undefined + if (data?.source !== "amicode" || data.kind !== "preview-file" || !data.path) return + openPath(data.path) } - return "Preview" + window.addEventListener("message", handlePreviewFile) + onCleanup(() => window.removeEventListener("message", handlePreviewFile)) }) - // ─── Render ───────────────────────────────────────────────────────────── + const zoomIn = () => setZoom((z) => Math.min(z + 10, 500)) + const zoomOut = () => setZoom((z) => Math.max(z - 10, 50)) + const onZoomChange = (value: number) => setZoom(Math.round(Math.min(Math.max(value, 50), 500))) - // Dirty state: true when the current file has unsaved edits - const isUnsaved = createMemo(() => { - const file = props.previewFile() - if (!file) return false - return fileStates[file]?.unsavedContent != null - }) + const removePath = (path: string) => { + setOpenedPaths((paths) => { + const index = paths.indexOf(path) + if (index === -1) return paths + const next = paths.filter((item) => item !== path) + if (selectedPath() === path) setSelectedPath(next[index - 1] ?? next[index] ?? null) + return next + }) + setDirtyPaths(path, false) + } + + const closePath = (path: string) => { + if (dirtyPaths[path]) { + setClosingPath(path) + return + } + removePath(path) + } return (
- {/* Header: filename + unsaved dot */} -
-
- - {headerTitle()} - - -
- + 0}> + + + + event.target instanceof Element && !!event.target.closest('[data-slot="tabs-trigger-close-button"]'), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => previewTabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + setOpenedPaths((paths) => reorderPreviewTabs(paths, source.id.toString(), source.index)) + }} + > + + {(path, index) => ( + + closePath(path)} + > + } + > + + + + } + hideCloseButton + onMiddleClick={() => closePath(path)} + > + + + + )} + + + + + + + + -
+
+ + + {(path) => ( + + )} + {/* Main content */}
- - -

Select a file from the sidebar

-
- } - > + + {/* Empty state — no file selected */} +
+ +

Select a file from the sidebar

+
+
+ {(filePath) => ( - setFileState(filePath(), { mode })} - onUnsavedContent={(content) => setFileState(filePath(), { unsavedContent: content })} - onSave={() => {/* handled by PreviewFileView internally */}} - zoom={zoom} - zoomIn={zoomIn} - zoomOut={zoomOut} - onZoomChange={onZoomChange} - /> +
+ setDirtyPaths(filePath, dirty)} + saveRequest={() => saveRequests[filePath] ?? 0} + onSaveComplete={() => { + removePath(filePath) + setClosingPath(null) + }} + zoom={zoom} + zoomIn={zoomIn} + zoomOut={zoomOut} + onZoomChange={onZoomChange} + /> +
)} - +
) diff --git a/packages/app-bundle/overlay/packages/app/src/context/layout.tsx b/packages/app-bundle/overlay/packages/app/src/context/layout.tsx index 35609af0..09ab2622 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/layout.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/layout.tsx @@ -21,6 +21,12 @@ import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./la import { requireServerKey } from "@/utils/session-route" import { type DraftTab, useTabs } from "./tabs" import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" +import { + DEFAULT_SIDE_PANEL_TAB_ORDER, + normalizeSidePanelTabOrder, + reorderSidePanelTabs, + type SidePanelTabID, +} from "./layout-side-panel-tabs" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -228,6 +234,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } })() + const sidePanelTabs = value.sidePanelTabs + const migratedSidePanelTabs = (() => { + const order = isRecord(sidePanelTabs) ? sidePanelTabs.order : undefined + const normalized = normalizeSidePanelTabOrder(order) + if (Array.isArray(order) && same(order, normalized)) return sidePanelTabs + return { ...(isRecord(sidePanelTabs) ? sidePanelTabs : {}), order: normalized } + })() + const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs) const sessionView = migrateLegacySessionStateKeys(value.sessionView) const migratedSessionTabs = (() => { @@ -258,6 +272,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree && + migratedSidePanelTabs === sidePanelTabs && migratedSessionTabs === value.sessionTabs && sessionView === value.sessionView ) { @@ -269,6 +284,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( sidebar: migratedSidebar, review: migratedReview, fileTree: migratedFileTree, + sidePanelTabs: migratedSidePanelTabs, sessionTabs: migratedSessionTabs, sessionView, } @@ -303,6 +319,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( panelColumn: { width: DEFAULT_PANEL_COLUMN_WIDTH, }, + sidePanelTabs: { + order: [...DEFAULT_SIDE_PANEL_TAB_ORDER], + }, session: { width: DEFAULT_SESSION_WIDTH, }, @@ -724,6 +743,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("panelColumn", { width }) }, }, + sidePanelTabs: { + order: createMemo(() => normalizeSidePanelTabOrder(store.sidePanelTabs?.order)), + move(tab: SidePanelTabID, toIndex: number) { + const order = reorderSidePanelTabs(store.sidePanelTabs?.order ?? DEFAULT_SIDE_PANEL_TAB_ORDER, tab, toIndex) + setStore("sidePanelTabs", { order }) + }, + }, fileTree: { opened: createMemo(() => store.fileTree?.opened ?? true), width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH), diff --git a/packages/app-bundle/overlay/packages/app/src/design-polish.css b/packages/app-bundle/overlay/packages/app/src/design-polish.css index e74ec593..d499deac 100644 --- a/packages/app-bundle/overlay/packages/app/src/design-polish.css +++ b/packages/app-bundle/overlay/packages/app/src/design-polish.css @@ -98,6 +98,24 @@ --elev-float: 0 8px 24px rgb(0 0 0 / 0.35); } +#review-panel [data-component="tabs"].preview-tab-strip [data-slot="tabs-list"] { + height: var(--space-8); + gap: var(--space-1); +} + +#review-panel [data-component="tabs"].preview-tab-strip [data-slot="tabs-trigger-wrapper"] { + height: var(--space-6); +} + +body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-list"] { + height: var(--space-8); + gap: var(--space-1); +} + +body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-trigger-wrapper"] { + height: var(--space-6); +} + /* ── session status dots ── Four states: green (done, unread), grey (done, seen), yellow (running), red (error). Colour is never the only signal: each dot carries diff --git a/packages/app-bundle/overlay/packages/app/src/index.css b/packages/app-bundle/overlay/packages/app/src/index.css index 2f1aa408..404975bb 100644 --- a/packages/app-bundle/overlay/packages/app/src/index.css +++ b/packages/app-bundle/overlay/packages/app/src/index.css @@ -396,3 +396,21 @@ [data-slot="thought-rail-dot"][data-state="done"] { transition: opacity 150ms ease-out; } + +/* Preview tabs mirror the V2 file tree: neutral icons until selection, with no hover recoloring. */ +.preview-tab-strip [data-slot="tabs-trigger"] .tab-fileicon-color { + display: none !important; +} + +.preview-tab-strip [data-slot="tabs-trigger"] .tab-fileicon-mono { + display: block !important; + color: var(--v2-icon-icon-muted); +} + +.preview-tab-strip [data-slot="tabs-trigger"][data-selected] .tab-fileicon-color { + display: block !important; +} + +.preview-tab-strip [data-slot="tabs-trigger"][data-selected] .tab-fileicon-mono { + display: none !important; +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel-structure.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel-structure.test.ts index 10017048..07cd937c 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel-structure.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel-structure.test.ts @@ -21,4 +21,10 @@ describe("work column is vault-free (amicode#105)", () => { expect(source).not.toContain('value="vault"') expect(source).not.toContain("vaultOpen") }) + + test("persists and sorts only the named surface tabs", () => { + expect(source).toContain("layout.sidePanelTabs.order()") + expect(source).toContain("SortableSidePanelSurfaceTab") + expect(source).toContain("handleSurfaceTabDragEnd") + }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx index a2cfdc4c..031afd15 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/session-side-panel.tsx @@ -2,7 +2,7 @@ import { For, Match, Show, Switch, createEffect, createMemo, createResource, cre import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" -import { isSortable } from "@dnd-kit/solid/sortable" +import { isSortable, useSortable } from "@dnd-kit/solid/sortable" import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" import { RestrictToElement } from "@dnd-kit/dom/modifiers" @@ -61,6 +61,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" +import type { SidePanelTabID } from "@/context/layout-side-panel-tabs" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { @@ -78,6 +79,28 @@ import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/ses type PulseInspectorStage = "optimization" | "calibration" | "compilation" +function SortableSidePanelSurfaceTab(props: { + tab: SidePanelTabID + index: () => number + visible: () => boolean + children: JSX.Element +}) { + const sortable = useSortable({ + get id() { + return props.tab + }, + get index() { + return props.index() + }, + }) + + return ( +
+ {props.children} +
+ ) +} + function PulseInspectorContent() { const bridge = useInspectorBridge() const [stage, setStage] = createSignal("optimization") @@ -339,6 +362,12 @@ export function SessionSidePanel(props: { // the bridge message handler can set it from outside the side panel. const previewFile = createMemo(() => view().previewFile.get()) + createEffect( + on(previewFile, (path) => { + if (path) tabs().setActive(SESSION_PREVIEW_TAB) + }), + ) + const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { @@ -414,6 +443,17 @@ export function SessionSidePanel(props: { const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab + // Keep every named trigger mounted. Kobalte validates a controlled value + // against its registered collection and otherwise falls back to the first tab. + // Unmounting Preview while its preview-file event selects it therefore rewrites + // the controlled value to Home before Preview can register. + const surfaceTabVisible = (tab: SidePanelTabID) => { + if (tab === "home") return true + if (tab === "review") return reviewTab() && props.canReview() && reviewTabOpen() + if (tab === "context") return contextOpen() + if (tab === "pulseInspector") return pulseInspectorOpen() + return previewOpen() + } const fileTreeTab = () => layout.fileTree.tab() @@ -544,6 +584,14 @@ export function SessionSidePanel(props: { setStore("activeDraggable", undefined) } + const handleSurfaceTabDragEnd = (event: any) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + + const tab = source.id.toString() as SidePanelTabID + layout.sidePanelTabs.move(tab, source.index) + } + createEffect(() => { if (!file.ready()) return @@ -641,7 +689,7 @@ export function SessionSidePanel(props: { onCleanup(stop) }} > - +
Home
@@ -651,6 +699,7 @@ export function SessionSidePanel(props: {
@@ -667,7 +716,12 @@ export function SessionSidePanel(props: { docs/adr/0001). Do not re-add a tab here: two hosts mirrored through two stores was the desync this column's toggle got blamed for. */} -
+
-
+
-
+
- - -
- -
-
-
+ +
+ +
+
{(tab) => } @@ -847,32 +915,7 @@ export function SessionSidePanel(props: { } > - - event.target instanceof Element && - (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || - !!event.target.closest(".session-review-v2-open-in-app-slot")), - }), - ]} - modifiers={[ - RestrictToHorizontalAxis, - RestrictToElement.configure({ element: () => tabList ?? null }), - ]} - plugins={(defaults) => [ - ...defaults.filter((plugin) => plugin !== Accessibility), - AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), - Feedback.configure({ dropAnimation: null }), - ]} - onDragEnd={(event) => { - const source = event.operation.source - if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return - tabs().move(source.id.toString(), source.index) - }} - > - +
{ @@ -888,166 +931,218 @@ export function SessionSidePanel(props: {
)}
- -
- -
Home
-
-
- - - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - setReviewTabOpen(false)} - aria-label={language.t("common.closeTab")} - /> - - } - > -
- -
- {props.hasReview() - ? "Files Changed" - : language.t("session.tab.review")} -
- -
{props.reviewCount()}
-
-
-
-
-
- - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
-
-
-
-
- - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - tabs().close("pulseInspector")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("pulseInspector")} + + event.target instanceof Element && !!event.target.closest('[data-slot="tabs-trigger-close-button"]'), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={handleSurfaceTabDragEnd} > -
- -
Pulse Inspector
-
-
-
-
- - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - tabs().close(SESSION_PREVIEW_TAB)} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close(SESSION_PREVIEW_TAB)} - > -
- -
Preview
-
-
-
- - {(tab) => ( - tabs().all().indexOf(tab)} - temporary={temporaryTab() === tab} - onTabClose={tabs().close} - onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} - /> - )} - + + {(tab, index) => ( + surfaceTabVisible(tab)}> + + + +
+ +
Home
+
+
+
+ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + setReviewTabOpen(false)} + aria-label={language.t("common.closeTab")} + /> + + } + > +
+ +
{props.hasReview() ? "Files Changed" : language.t("session.tab.review")}
+ +
{props.reviewCount()}
+
+
+
+
+ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("pulseInspector")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("pulseInspector")} + > +
+ +
Pulse Inspector
+
+
+
+ + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close(SESSION_PREVIEW_TAB)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_PREVIEW_TAB)} + > +
+ +
Preview
+
+
+
+
+
+ )} +
+ + + event.target instanceof Element && + (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || + !!event.target.closest(".session-review-v2-open-in-app-slot")), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + tabs().move(source.id.toString(), source.index) + }} + > + + {(tab) => ( + tabs().all().indexOf(tab)} + temporary={temporaryTab() === tab} + onTabClose={tabs().close} + onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} + /> + )} + +
- - -
- -
-
-
+ +
+ +
+
- - +
diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 313fa230..a3c148ea 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import * as fs from "node:fs"; import { opencodeDataDir, opencodeConfigDir } from "./opencode_xdg"; import { findForkedOpencodeBinary } from "./opencode_binary"; +import type { ExplorerIconTheme } from "./explorer_icon_theme"; import { readSkillProviders, addSkillProvider, @@ -88,6 +89,9 @@ export interface BridgeIo { * "reset" = expand selected + collapse others, "expand" = expand selected * only, "none" = highlight only. */ onProjectSelected?: (path: string, mode?: "none" | "expand" | "reset") => void; + previewVisibleChildren?: (root: string, relativeDirectory: string) => Promise>; + /** Returns the currently-selected Explorer file icon theme as opaque assets. */ + explorerIconTheme?: () => ExplorerIconTheme; } const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } => @@ -119,6 +123,38 @@ export function extractReportBugModel( export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean { if (!isAmicode(msg)) return false; + // The sandboxed app requests its Preview-tab icon data explicitly. The host + // owns the only asset reader and returns opaque IDs plus allowlisted bytes. + if (msg.kind === "explorer-icon-theme-request") { + if (io.explorerIconTheme) { + io.postToWebview({ + source: "amicode", + kind: "explorer-icon-theme", + theme: io.explorerIconTheme(), + ...(typeof msg.tab === "string" ? { tab: msg.tab } : {}), + }); + } + return true; + } + + if (msg.kind === "preview-visible-children-request") { + const requestId = (msg as { requestId?: unknown }).requestId; + const root = (msg as { root?: unknown }).root; + const relativeDirectory = (msg as { relativeDirectory?: unknown }).relativeDirectory; + if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 200) return true; + if (typeof root !== "string" || root.length === 0 || root.length > 4096) return true; + if (typeof relativeDirectory !== "string" || relativeDirectory.length > 4096) return true; + if (!io.previewVisibleChildren) { + io.postToWebview({ source: "amicode", kind: "preview-visible-children-result", requestId, error: "Preview navigation is unavailable" }); + return true; + } + void io.previewVisibleChildren(root, relativeDirectory).then( + (entries) => io.postToWebview({ source: "amicode", kind: "preview-visible-children-result", requestId, entries }), + () => io.postToWebview({ source: "amicode", kind: "preview-visible-children-result", requestId, error: "Could not load files" }), + ); + return true; + } + // target=_blank/window.open are dead inside the framed app — open https // links via the editor (system browser). https-only; scheme is // case-insensitive (RFC 3986). diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 4ee0dfec..8d860c4e 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -4,6 +4,7 @@ import { handleAmicodeBridgeMessage } from "./chat_bridge"; import { registerInspectorPoster } from "./inspector_bridge"; import { getBugReport } from "./bug_report"; import { FileWatcherBridge } from "./file_watcher_bridge"; +import { resolveExplorerIconTheme } from "./explorer_icon_theme"; // ============================================================================ // ChatPanel — a WebviewPanel that iframes opencode's SolidJS chat at @@ -50,6 +51,7 @@ export class ChatPanel { * "reset" = expand selected + collapse others, "expand" = expand selected * only, "none" = highlight only. */ private static onProjectSelectedCallback?: (path: string | null, mode?: "none" | "expand" | "reset") => void; + private static previewVisibleChildrenCallback?: (root: string, relativeDirectory: string) => Promise>; /** The `amicode_bug_report=1` boot-param gate (amicode#250 AC5): set from the * staged skill set after every session prep; the composer button renders * only when the report-a-bug skill is there to answer it. */ @@ -81,6 +83,10 @@ export class ChatPanel { ChatPanel.onProjectSelectedCallback = cb; } + static onPreviewVisibleChildren(cb: ((root: string, relativeDirectory: string) => Promise>) | undefined): void { + ChatPanel.previewVisibleChildrenCallback = cb; + } + private constructor( private readonly panel: vscode.WebviewPanel, private readonly tabTitle: string, @@ -104,15 +110,24 @@ export class ChatPanel { // #351: register this panel as an inspector poster — RunsManager / device // poll fan out run/device envelopes to every live chat webview. this.disposables.push(registerInspectorPoster((msg) => void this.panel.webview.postMessage(msg))); - // Live theme bridge: editor theme changes flow extension → outer relay → - // iframe → the app's setColorScheme (boot theme rides ?colorScheme=). + // Live theme bridge: editor theme changes update both the app color scheme + // and icon-theme light variants. The app also requests this after every boot. vscode.window.onDidChangeActiveColorTheme( - (t) => + (t) => { void this.panel.webview.postMessage({ source: "amicode", kind: "theme", colorScheme: themeKindToScheme(t.kind), - }), + }); + this.postExplorerIconTheme(); + }, + null, + this.disposables, + ); + vscode.workspace.onDidChangeConfiguration( + (event) => { + if (event.affectsConfiguration("workbench.iconTheme")) this.postExplorerIconTheme(); + }, null, this.disposables, ); @@ -158,6 +173,8 @@ export class ChatPanel { onProjectSelected: ChatPanel.onProjectSelectedCallback ? (p, mode) => { this.lastProjectPath = p; ChatPanel.onProjectSelectedCallback!(p, mode); } : undefined, + previewVisibleChildren: ChatPanel.previewVisibleChildrenCallback, + explorerIconTheme: resolveExplorerIconTheme, }); if (!handled) console.log("[amicode/chat] webview msg:", msg); }, @@ -179,6 +196,14 @@ export class ChatPanel { ); } + private postExplorerIconTheme(): void { + void this.panel.webview.postMessage({ + source: "amicode", + kind: "explorer-icon-theme", + theme: resolveExplorerIconTheme(), + }); + } + /** `authToken` is the per-boot server credential (#163) as the app's * `?auth_token=` bootstrap value — base64("opencode:"), from * serverAuthToken(). The app adopts it for its authenticated-fetch path and @@ -457,7 +482,7 @@ export class ChatPanel { vscode.postMessage({ source: "amicode", kind: "clipboard-image-read", nonce: d.nonce }); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request")) { vscode.postMessage(d); } return; @@ -467,7 +492,7 @@ export class ChatPanel { // our own envelopes, pinned to the opencode origin. #351 adds // run:*/device:* envelopes for the Work Column inspector tabs. // #934: preview-file — sidebar/chat file routing to the Preview companion tab. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file" || d.kind === "preview-visible-children-result" || d.kind === "explorer-icon-theme")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } @@ -619,12 +644,12 @@ export class ChatPanel { vscode.postMessage({ source: "amicode", kind: "clipboard-image-read", nonce: d.nonce }); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "explorer-icon-theme-request")) { vscode.postMessage(d); } return; } - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file" || d.kind === "explorer-icon-theme")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, origin); } diff --git a/packages/extension/src/deck/shell.ts b/packages/extension/src/deck/shell.ts index 83eb92ad..65785708 100644 --- a/packages/extension/src/deck/shell.ts +++ b/packages/extension/src/deck/shell.ts @@ -401,6 +401,13 @@ window.addEventListener("message", (e) => { boot.colorScheme = d.colorScheme; for (const f of frameByTab.values()) f.contentWindow?.postMessage({ source: "amicode", kind: "theme", colorScheme: d.colorScheme }, boot.origin); } + if (d.kind === "explorer-icon-theme") { + if (typeof d.tab === "string") { + frameByTab.get(d.tab)?.contentWindow?.postMessage(d, boot.origin); + } else { + for (const f of frameByTab.values()) f.contentWindow?.postMessage(d, boot.origin); + } + } if (d.kind === "clipboard" && typeof d.tab === "string") { frameByTab.get(d.tab)?.contentWindow?.postMessage(d, boot.origin); } @@ -492,7 +499,8 @@ window.addEventListener("message", (e) => { d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || - d.kind === "connections-remove" + d.kind === "connections-remove" || + d.kind === "explorer-icon-theme-request" ) { vscode.postMessage({ ...d, tab: tabId }); } diff --git a/packages/extension/src/deck_panel.ts b/packages/extension/src/deck_panel.ts index 7d6d1a52..3c3dac66 100644 --- a/packages/extension/src/deck_panel.ts +++ b/packages/extension/src/deck_panel.ts @@ -4,6 +4,7 @@ import { handleAmicodeBridgeMessage } from "./chat_bridge"; import { registerInspectorPoster } from "./inspector_bridge"; import { tabIconPath, themeKindToScheme } from "./chat_panel"; import { getBugReport } from "./bug_report"; +import { resolveExplorerIconTheme } from "./explorer_icon_theme"; // ============================================================================ // DeckPanel — the Chat Deck: MANY chat panes inside ONE editor tab. The heavy @@ -32,12 +33,21 @@ export class DeckPanel { // Theme fan-out: extension → shell → EVERY pane's iframe (the shell owns // the per-pane relay; boot scheme rides the bootstrap config). vscode.window.onDidChangeActiveColorTheme( - (t) => + (t) => { void this.panel.webview.postMessage({ source: "amicode", kind: "theme", colorScheme: themeKindToScheme(t.kind), - }), + }); + this.postExplorerIconTheme(); + }, + null, + this.disposables, + ); + vscode.workspace.onDidChangeConfiguration( + (event) => { + if (event.affectsConfiguration("workbench.iconTheme")) this.postExplorerIconTheme(); + }, null, this.disposables, ); @@ -56,6 +66,7 @@ export class DeckPanel { // amicode_bug_report boot param, so no dock lives here; wired for // uniformity (the manager drops unknown ids anyway). bugReport: getBugReport()?.sink, + explorerIconTheme: resolveExplorerIconTheme, }); if (!handled) console.log("[amicode/deck] webview msg:", msg); }, @@ -64,6 +75,14 @@ export class DeckPanel { ); } + private postExplorerIconTheme(): void { + void this.panel.webview.postMessage({ + source: "amicode", + kind: "explorer-icon-theme", + theme: resolveExplorerIconTheme(), + }); + } + static openOrReveal( ctx: vscode.ExtensionContext, opencodeUrl: URL, diff --git a/packages/extension/src/explorer_icon_theme.ts b/packages/extension/src/explorer_icon_theme.ts new file mode 100644 index 00000000..31ff24e8 --- /dev/null +++ b/packages/extension/src/explorer_icon_theme.ts @@ -0,0 +1,257 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +export type ExplorerIconAssetMime = "image/svg+xml" | "font/woff" | "font/woff2" | "font/ttf" | "font/otf"; + +export interface ExplorerIconAsset { + mime: ExplorerIconAssetMime; + /** Base64 bytes. Asset keys are opaque; source paths never leave the host. */ + data: string; +} + +export type ExplorerFileIcon = + | { kind: "font"; glyph: string; color?: string } + | { kind: "svg"; asset: string }; + +export interface ExplorerIconTheme { + mode: "font" | "svg" | "none"; + assets: Record; + fileExtensions: Record; + fileNames: Record; + defaultFile?: ExplorerFileIcon; + font?: { asset: string; format: "woff" | "woff2" | "truetype" | "opentype"; size: string }; +} + +const MAX_ICON_ASSET_BYTES = 1_000_000; +const MAX_ICON_THEME_BYTES = 8_000_000; +const MAX_ICON_MAP_ENTRIES = 2_000; +const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/; + +function emptyTheme(): ExplorerIconTheme { + return { mode: "none", assets: {}, fileExtensions: {}, fileNames: {} }; +} + +function isWithin(base: string, candidate: string): boolean { + const relative = path.relative(base, candidate); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".."); +} + +function assetMime(file: string): ExplorerIconAssetMime | undefined { + switch (path.extname(file).toLowerCase()) { + case ".svg": return "image/svg+xml"; + case ".woff": return "font/woff"; + case ".woff2": return "font/woff2"; + case ".ttf": return "font/ttf"; + case ".otf": return "font/otf"; + default: return undefined; + } +} + +/** + * Returns bytes only from the active icon theme's own directory. This is the + * entire asset boundary for the framed app: it receives opaque IDs and bytes, + * never a local path or a general-purpose file endpoint. + */ +function createAssetReader(basePath: string): (assetPath: string) => ExplorerIconAsset | undefined { + let root: string; + try { + root = fs.realpathSync(basePath); + } catch { + return () => undefined; + } + let totalBytes = 0; + return (assetPath) => { + const mime = assetMime(assetPath); + if (!mime) return undefined; + try { + const realPath = fs.realpathSync(assetPath); + if (!isWithin(root, realPath)) return undefined; + const stat = fs.statSync(realPath); + if (!stat.isFile() || stat.size > MAX_ICON_ASSET_BYTES || totalBytes + stat.size > MAX_ICON_THEME_BYTES) return undefined; + totalBytes += stat.size; + return { mime, data: fs.readFileSync(realPath).toString("base64") }; + } catch { + return undefined; + } + }; +} + +function effectiveTheme(themeJson: any, colorThemeKind?: "light" | "dark"): any { + if (colorThemeKind !== "light" || !themeJson.light) return themeJson; + const light = themeJson.light; + return { + ...themeJson, + file: light.file ?? themeJson.file, + fileExtensions: { ...themeJson.fileExtensions, ...light.fileExtensions }, + fileNames: { ...themeJson.fileNames, ...light.fileNames }, + languageIds: { ...themeJson.languageIds, ...light.languageIds }, + }; +} + +function safeFontSize(value: unknown): string { + return typeof value === "string" && /^(?:0|[1-9]\d*)(?:\.\d+)?(?:%|px|em|rem)$/.test(value) + ? value + : "100%"; +} + +function safeColor(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return /^(?:#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([\d.%\s,]+\)|currentColor|inherit|transparent)$/i.test(trimmed) + ? trimmed + : undefined; +} + +function fileNameVariants(target: Record, name: string, icon: ExplorerFileIcon): void { + target[name] = icon; + const lower = name.toLowerCase(); + const upper = name.toUpperCase(); + const dot = name.lastIndexOf("."); + const extension = dot >= 0 ? name.slice(dot) : ""; + const upperBase = (dot >= 0 ? name.slice(0, dot) : name).toUpperCase() + extension; + if (!target[lower]) target[lower] = icon; + if (!target[upper]) target[upper] = icon; + if (!target[upperBase]) target[upperBase] = icon; +} + +function buildLanguageExtensionMap(extensionList: readonly any[]): Record { + const map: Record = {}; + for (const extension of extensionList ?? []) { + for (const language of extension.packageJSON?.contributes?.languages ?? []) { + if (!language.id) continue; + for (const fileExtension of language.extensions ?? []) { + const extensionName = String(fileExtension).replace(/^\./, ""); + if (extensionName && !map[extensionName]) map[extensionName] = language.id; + } + } + } + return map; +} + +/** + * Converts the active Explorer icon theme into a path-free payload for the + * sandboxed app. Only definitions selected by a file mapping become assets. + */ +export function buildExplorerIconTheme( + themeJson: any, + basePath: string, + readAsset: (assetPath: string) => ExplorerIconAsset | undefined, + langExtMap?: Record, + colorThemeKind?: "light" | "dark", +): ExplorerIconTheme { + if (!themeJson || typeof themeJson !== "object") return emptyTheme(); + const theme = effectiveTheme(themeJson, colorThemeKind); + const definitions: Record = themeJson.iconDefinitions ?? {}; + const assets: Record = {}; + const assetIds = new Map(); + + const addAsset = (relativePath: unknown, allowed: readonly ExplorerIconAssetMime[]): string | undefined => { + if (typeof relativePath !== "string") return undefined; + const absolutePath = path.resolve(basePath, relativePath); + if (!isWithin(path.resolve(basePath), absolutePath)) return undefined; + const existing = assetIds.get(absolutePath); + if (existing) return existing; + const asset = readAsset(absolutePath); + if (!asset || !allowed.includes(asset.mime) || !BASE64.test(asset.data)) return undefined; + const id = `asset-${assetIds.size}`; + assetIds.set(absolutePath, id); + assets[id] = asset; + return id; + }; + + if (Array.isArray(theme.fonts) && theme.fonts.length > 0) { + const font = theme.fonts[0]; + const source = font?.src?.[0]; + const asset = addAsset(source?.path, ["font/woff", "font/woff2", "font/ttf", "font/otf"]); + if (!asset) return emptyTheme(); + const formatByMime: Record, "woff" | "woff2" | "truetype" | "opentype"> = { + "font/woff": "woff", + "font/woff2": "woff2", + "font/ttf": "truetype", + "font/otf": "opentype", + }; + const fontAsset = assets[asset]; + const definitionIcon = (definitionName: unknown): ExplorerFileIcon | undefined => { + const definition = definitions[definitionName as string]; + if (typeof definition?.fontCharacter !== "string" || definition.fontCharacter.length === 0 || definition.fontCharacter.length > 32) return undefined; + const color = safeColor(definition.fontColor); + return { kind: "font", glyph: definition.fontCharacter, ...(color ? { color } : {}) }; + }; + return buildFileMappings(theme, definitionIcon, { + mode: "font", + assets, + font: { asset, format: formatByMime[fontAsset.mime as Exclude], size: safeFontSize(font?.size) }, + }, langExtMap); + } + + const definitionIcon = (definitionName: unknown): ExplorerFileIcon | undefined => { + const definition = definitions[definitionName as string]; + const asset = addAsset(definition?.iconPath, ["image/svg+xml"]); + return asset ? { kind: "svg", asset } : undefined; + }; + return buildFileMappings(theme, definitionIcon, { mode: "svg", assets }, langExtMap); +} + +function buildFileMappings( + theme: any, + iconForDefinition: (definitionName: unknown) => ExplorerFileIcon | undefined, + base: Pick, + langExtMap?: Record, +): ExplorerIconTheme { + const fileExtensions: Record = {}; + const fileNames: Record = {}; + const extensionEntries = Object.entries(theme.fileExtensions ?? {}); + const fileNameEntries = Object.entries(theme.fileNames ?? {}); + if (extensionEntries.length > MAX_ICON_MAP_ENTRIES || fileNameEntries.length > MAX_ICON_MAP_ENTRIES) { + return { ...base, fileExtensions, fileNames }; + } + for (const [extension, definitionName] of extensionEntries) { + if (extension.length === 0 || extension.length > 255) continue; + const icon = iconForDefinition(definitionName); + if (icon) fileExtensions[extension] = icon; + } + if (langExtMap) { + for (const [extension, languageId] of Object.entries(langExtMap)) { + if (Object.keys(fileExtensions).length >= MAX_ICON_MAP_ENTRIES) break; + if (extension.length === 0 || extension.length > 255) continue; + if (fileExtensions[extension]) continue; + const icon = iconForDefinition((theme.languageIds ?? {})[languageId]); + if (icon) fileExtensions[extension] = icon; + } + } + for (const [name, definitionName] of fileNameEntries) { + if (name.length === 0 || name.length > 255) continue; + const icon = iconForDefinition(definitionName); + if (icon) fileNameVariants(fileNames, name, icon); + } + return { + ...base, + fileExtensions, + fileNames, + ...(iconForDefinition(theme.file) ? { defaultFile: iconForDefinition(theme.file) } : {}), + }; +} + +/** Read the current VS Code Explorer icon theme without granting the app file access. */ +export function resolveExplorerIconTheme(): ExplorerIconTheme { + try { + const themeId = vscode.workspace.getConfiguration("workbench").get("iconTheme"); + if (!themeId) return emptyTheme(); + const langExtMap = buildLanguageExtensionMap(vscode.extensions.all as any[]); + for (const extension of vscode.extensions.all ?? []) { + const contribution = (extension.packageJSON?.contributes?.iconThemes ?? []).find((theme: any) => theme.id === themeId); + if (!contribution?.path) continue; + const themePath = path.resolve(extension.extensionPath, contribution.path); + const basePath = path.dirname(themePath); + const themeJson = JSON.parse(fs.readFileSync(themePath, "utf8")); + const colorThemeKind = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight + ? "light" + : "dark"; + return buildExplorerIconTheme(themeJson, basePath, createAssetReader(basePath), langExtMap, colorThemeKind); + } + } catch { + // Themes are optional. A malformed extension contribution simply has no icon transport. + } + return emptyTheme(); +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6bdaaca8..59675117 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -421,6 +421,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // the sidebar with the appropriate mode. mode controls expand/collapse // behavior: "reset" for explicit selection, "expand" for session/tab switch. ChatPanel.onProjectSelected((path, mode) => sidebarProvider.setActiveProject(path, mode)); + ChatPanel.onPreviewVisibleChildren((root, relativeDirectory) => sidebarProvider.previewVisibleChildren(root, relativeDirectory)); registerOnboardingPanel(ctx); // #433 — Stage 0 model-setup webview registerFleetPanel(ctx); // #527 — Fleet & Versions: the view over doctor's JSON statusBar = new StatusBarManager(); diff --git a/packages/extension/src/preview_visible_children.ts b/packages/extension/src/preview_visible_children.ts new file mode 100644 index 00000000..df54cdc8 --- /dev/null +++ b/packages/extension/src/preview_visible_children.ts @@ -0,0 +1,10 @@ +import * as path from "node:path"; + +export function resolvePreviewVisibleChildrenDirectory(root: string, relativeDirectory: string, workspaceRoots: readonly string[]) { + if (!workspaceRoots.includes(root)) return { ok: false as const }; + + const directory = path.resolve(root, relativeDirectory); + if (directory !== root && !directory.startsWith(root + path.sep)) return { ok: false as const }; + + return { ok: true as const, directory }; +} diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 63130f28..80af4386 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -16,6 +16,9 @@ import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service"; import { ChatPanel } from "./chat_panel"; import { detectProjectType } from "./project/detect"; import { invalidateEnvironmentCache, readEnvManifest, resolveEnvironment } from "./project/resolve_environment"; +import { resolvePreviewVisibleChildrenDirectory } from "./preview_visible_children"; + +export { buildExplorerIconTheme } from "./explorer_icon_theme"; // ── Icon theme resolution ──────────────────────────────────────────────────── @@ -319,6 +322,21 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { }); } + async previewVisibleChildren(root: string, relativeDirectory: string): Promise> { + const workspaceRoots = this.treeService.getRoots() + .filter((entry) => entry.projectType === "research" || entry.projectType === "dev") + .map((entry) => entry.path); + const resolved = resolvePreviewVisibleChildrenDirectory(root, relativeDirectory, workspaceRoots); + if (!resolved.ok) throw new Error("Preview visible-children request is outside a workspace project"); + const entries = await this.treeService.getChildren(resolved.directory); + return entries.map((entry) => ({ + name: entry.name, + kind: entry.type, + absolute: entry.path, + relative: path.relative(root, entry.path), + })); + } + resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index 26d47d88..71937766 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -2,6 +2,7 @@ // only the runtime members our node-side modules touch; types are erased at // compile time so they need no runtime shape. export const FileType = { Unknown: 0, File: 1, Directory: 2, SymbolicLink: 64 }; +const colorThemeCbs: Array<(theme: { kind: number }) => void> = []; export const window = { showInformationMessage: () => Promise.resolve(undefined), showErrorMessage: () => Promise.resolve(undefined), @@ -30,7 +31,19 @@ export const window = { _opts, }), activeColorTheme: { kind: 2 }, // ColorThemeKind.Dark - onDidChangeActiveColorTheme: (_cb: unknown, _thisArg?: unknown, _subs?: unknown) => ({ dispose() {} }), + onDidChangeActiveColorTheme: (cb: (theme: { kind: number }) => void, _thisArg?: unknown, subs?: unknown) => { + colorThemeCbs.push(cb); + const disposable = { dispose: () => { + const index = colorThemeCbs.indexOf(cb); + if (index >= 0) colorThemeCbs.splice(index, 1); + } }; + if (Array.isArray(subs)) subs.push(disposable); + return disposable; + }, + _fireActiveColorTheme(kind: number) { + window.activeColorTheme.kind = kind; + for (const cb of colorThemeCbs) cb({ kind }); + }, createWebviewPanel: (_viewType: string, _title: string, _column?: unknown, _opts?: unknown) => { const disposeCbs: Array<() => void> = []; const messageCbs: Array<(msg: unknown) => void> = []; @@ -135,6 +148,21 @@ export const workspace = { dispose() {}, }), updateWorkspaceFolders: (_start: number, _deleteCount: number | null, ..._adds: unknown[]) => true, + _configurationCbs: [] as Array<(event: { affectsConfiguration(section: string): boolean }) => void>, + onDidChangeConfiguration: (cb: (event: { affectsConfiguration(section: string): boolean }) => void, _thisArg?: unknown, subs?: unknown) => { + (workspace as any)._configurationCbs.push(cb); + const disposable = { dispose: () => { + const cbs = (workspace as any)._configurationCbs as typeof workspace._configurationCbs; + const index = cbs.indexOf(cb); + if (index >= 0) cbs.splice(index, 1); + } }; + if (Array.isArray(subs)) subs.push(disposable); + return disposable; + }, + _fireConfigurationChange(section: string) { + const event = { affectsConfiguration: (candidate: string) => candidate === section }; + for (const cb of (workspace as any)._configurationCbs) cb(event); + }, _workspaceFoldersCbs: [] as Array<() => void>, onDidChangeWorkspaceFolders: (cb: () => void, _thisArg?: unknown, _subs?: unknown) => { (workspace as any)._workspaceFoldersCbs.push(cb); diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index 4aec3c7c..ef1c4ace 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -95,6 +95,51 @@ describe("amicode bridge — open-file routes to preview-file (#935)", () => { }); }); +describe("amicode bridge — preview visible children", () => { + it("echoes the requestId with Sidebar-filtered child entries", async () => { + const host = io(); + host.previewVisibleChildren = async (root, relativeDirectory) => { + expect(root).toBe("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/workspace/project"); + expect(relativeDirectory).toBe("notes"); + return [{ name: "README.md", kind: "file", absolute: "/workspace/project/notes/README.md", relative: "notes/README.md" }]; + }; + + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "preview-visible-children-request", requestId: "request-1", root: "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/workspace/project", relativeDirectory: "notes" }, host)).toBe(true); + await flush(); + + expect(host.posted).toContainEqual({ + source: "amicode", + kind: "preview-visible-children-result", + requestId: "request-1", + entries: [{ name: "README.md", kind: "file", absolute: "/workspace/project/notes/README.md", relative: "notes/README.md" }], + }); + }); +}); + +describe("amicode bridge — Explorer icon theme", () => { + it("returns the host's opaque icon theme payload only for an explicit request", () => { + const host = io(); + host.explorerIconTheme = () => ({ + mode: "svg", + assets: { "asset-0": { mime: "image/svg+xml", data: "PHN2Zy8+" } }, + fileExtensions: { md: { kind: "svg", asset: "asset-0" } }, + fileNames: {}, + }); + + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "explorer-icon-theme-request" }, host)).toBe(true); + expect(host.posted).toEqual([{ + source: "amicode", + kind: "explorer-icon-theme", + theme: { + mode: "svg", + assets: { "asset-0": { mime: "image/svg+xml", data: "PHN2Zy8+" } }, + fileExtensions: { md: { kind: "svg", asset: "asset-0" } }, + fileNames: {}, + }, + }]); + }); +}); + describe("amicode bridge — open-file with path (native editor, #934)", () => { it("opens absolute path via vscode.open, not preview-file", async () => { const host = io(); diff --git a/packages/extension/test/chat_panel.test.ts b/packages/extension/test/chat_panel.test.ts index 3d12c386..68f986e3 100644 --- a/packages/extension/test/chat_panel.test.ts +++ b/packages/extension/test/chat_panel.test.ts @@ -119,6 +119,46 @@ describe("ChatPanel — the amicode_bug_report boot param (amicode#250 AC5)", () }); }); +describe("ChatPanel — Explorer icon theme bridge", () => { + let restore: (() => void) | undefined; + let created: CapturedPanel[] = []; + afterEach(() => { + for (const p of created) p.dispose(); + restore?.(); + restore = undefined; + created = []; + }); + + it("relays the explicit icon-theme request up and the opaque reply down in both chat render paths", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + const html = created[0].webview.html; + + expect(html).toContain("explorer-icon-theme-request"); + expect(html).toContain("explorer-icon-theme"); + }); + + it("pushes a replacement icon theme when the Explorer or color theme changes", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + const messages: unknown[] = []; + const webview = created[0] as unknown as { webview: { postMessage: (message: unknown) => Promise } }; + webview.webview.postMessage = (message) => { messages.push(message); return Promise.resolve(true); }; + + (vscode.workspace as unknown as { _fireConfigurationChange(section: string): void })._fireConfigurationChange("workbench.iconTheme"); + expect(messages).toContainEqual(expect.objectContaining({ kind: "explorer-icon-theme" })); + + messages.length = 0; + (vscode.window as unknown as { _fireActiveColorTheme(kind: number): void })._fireActiveColorTheme(vscode.ColorThemeKind.Light); + expect(messages).toContainEqual({ source: "amicode", kind: "theme", colorScheme: "light" }); + expect(messages).toContainEqual(expect.objectContaining({ kind: "explorer-icon-theme" })); + }); +}); + describe("ChatPanel — onboarding greeting auto-send (#449)", () => { let restore: (() => void) | undefined; let created: CapturedPanel[] = []; diff --git a/packages/extension/test/deck_panel.test.ts b/packages/extension/test/deck_panel.test.ts index c71e7bd0..cadb5bf8 100644 --- a/packages/extension/test/deck_panel.test.ts +++ b/packages/extension/test/deck_panel.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, afterEach } from "vitest"; import * as vscode from "vscode"; import { DeckPanel } from "../src/deck_panel"; import { mintServerPassword, serverAuthToken } from "../src/server_auth"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; // ============================================================================ // DeckPanel host seam: the bootstrap config (origin, boot credential, scheme) @@ -68,4 +70,12 @@ describe("DeckPanel — host seam", () => { expect(created).toHaveLength(1); expect(created[0].revealCount).toBe(1); }); + + it("routes Explorer icon-theme requests to one pane and broadcasts live replacements", () => { + const shell = readFileSync(resolve(__dirname, "..", "src", "deck", "shell.ts"), "utf8"); + + expect(shell).toContain('d.kind === "explorer-icon-theme-request"'); + expect(shell).toContain('d.kind === "explorer-icon-theme"'); + expect(shell).toContain("for (const f of frameByTab.values())"); + }); }); diff --git a/packages/extension/test/preview_visible_children.test.ts b/packages/extension/test/preview_visible_children.test.ts new file mode 100644 index 00000000..984908bd --- /dev/null +++ b/packages/extension/test/preview_visible_children.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { resolvePreviewVisibleChildrenDirectory } from "../src/preview_visible_children"; + +describe("Preview visible-children boundary", () => { + it("accepts a current workspace-project root and keeps the requested directory inside it", () => { + expect(resolvePreviewVisibleChildrenDirectory("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/workspace/project", "notes", ["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/workspace/project"])).toEqual({ + ok: true, + directory: "/workspace/project/notes", + }); + }); + + it("rejects unknown roots and traversal", () => { + expect(resolvePreviewVisibleChildrenDirectory("/outside", "", ["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/workspace/project"])).toEqual({ ok: false }); + expect(resolvePreviewVisibleChildrenDirectory("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/workspace/project", "../secrets", ["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/workspace/project"])).toEqual({ ok: false }); + }); +}); diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 44b7fe86..08fcd1d3 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1799,6 +1799,56 @@ describe("sidebar — add existing project", () => { // ── Icon theme integration (#673 — use VS Code's active icon theme) ────────── describe("sidebar — icon theme", () => { + it("buildExplorerIconTheme transports only opaque asset IDs for Preview icons", async () => { + vi.resetModules(); + const { buildExplorerIconTheme } = await import("../src/sidebar_view"); + + const themeJson = { + fonts: [{ id: "seti", src: [{ path: "./seti.woff", format: "woff" }], size: "150%" }], + iconDefinitions: { + _default: { fontCharacter: "\\E001", fontColor: "#C5C5C5" }, + _markdown: { fontCharacter: "\\E02A", fontColor: "#519aba" }, + _image: { fontCharacter: "\\E02B", fontColor: "#f2c811" }, + }, + file: "_default", + fileExtensions: { md: "_markdown", png: "_image" }, + fileNames: { "README.md": "_markdown" }, + }; + + const result = buildExplorerIconTheme( + themeJson, + "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/ext/theme", + (assetPath: string) => assetPath.endsWith("seti.woff") + ? { mime: "font/woff", data: "Zm9udA==" } + : undefined, + ); + + expect(result.mode).toBe("font"); + expect(result.font).toEqual({ asset: "asset-0", format: "woff", size: "150%" }); + expect(result.assets).toEqual({ "asset-0": { mime: "font/woff", data: "Zm9udA==" } }); + expect(result.fileExtensions.md).toEqual({ kind: "font", glyph: "\\E02A", color: "#519aba" }); + expect(result.fileExtensions.png).toEqual({ kind: "font", glyph: "\\E02B", color: "#f2c811" }); + expect(result.fileNames["README.md"]).toEqual({ kind: "font", glyph: "\\E02A", color: "#519aba" }); + expect(JSON.stringify(result)).not.toContain("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/ext/theme"); + expect(JSON.stringify(result)).not.toContain("vscode-webview-resource"); + }); + + it("buildExplorerIconTheme never reads an asset outside the active theme directory", async () => { + vi.resetModules(); + const { buildExplorerIconTheme } = await import("../src/sidebar_view"); + const readAsset = vi.fn(() => ({ mime: "image/svg+xml" as const, data: "PHN2Zy8+" })); + + const result = buildExplorerIconTheme({ + iconDefinitions: { outside: { iconPath: "../outside.svg" } }, + file: "outside", + fileExtensions: { md: "outside" }, + }, "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/ext/theme", readAsset); + + expect(readAsset).not.toHaveBeenCalled(); + expect(result.defaultFile).toBeUndefined(); + expect(result.fileExtensions).toEqual({}); + }); + it("buildIconMap produces font-mode data from a Seti-style font-based theme JSON", async () => { vi.resetModules(); const { buildIconMap } = await import("../src/sidebar_view");