From 5a7dd2fddb7402674bed992b6155108b78f3ae5f Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 17:13:27 -0400 Subject: [PATCH 01/34] feat(sidebar): webview shell + header buttons + cleanup (#674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the native TreeDataProvider sidebar with a WebviewViewProvider shell: - SidebarViewProvider with CSP-nonce HTML, themed buttons, typed bridge - sidebar_bridge.ts: SidebarMessage union (host↔webview), handleSidebarMessage() - sidebar_webview.ts: browser entry point (iife), button→bridge wiring - esbuild.config.mjs: fourth browser entry point (dist/sidebar_webview.js) - package.json: amicode.workspace type tree→webview, viewsWelcome removed, tree-scoped context menus removed, workspace.* commands removed - extension.ts: registerWebviewViewProvider replaces registerWorkspaceTree - Deleted workspace_tree.ts (311 lines), trees.ts (40 lines, dead code), workspace_tree.test.ts (replaced by sidebar_view.test.ts) Closes #674 --- packages/extension/esbuild.config.mjs | 12 + packages/extension/package.json | 126 +------ packages/extension/src/extension.ts | 11 +- packages/extension/src/sidebar_bridge.ts | 52 +++ packages/extension/src/sidebar_view.ts | 151 +++++++++ packages/extension/src/sidebar_webview.ts | 41 +++ packages/extension/src/trees.ts | 40 --- packages/extension/src/workspace_tree.ts | 311 ------------------ packages/extension/test/sidebar_view.test.ts | 143 ++++++++ .../extension/test/workspace_tree.test.ts | 270 --------------- 10 files changed, 409 insertions(+), 748 deletions(-) create mode 100644 packages/extension/src/sidebar_bridge.ts create mode 100644 packages/extension/src/sidebar_view.ts create mode 100644 packages/extension/src/sidebar_webview.ts delete mode 100644 packages/extension/src/trees.ts delete mode 100644 packages/extension/src/workspace_tree.ts create mode 100644 packages/extension/test/sidebar_view.test.ts delete mode 100644 packages/extension/test/workspace_tree.test.ts diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 2687a5a9..6a4aa73a 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -98,6 +98,18 @@ const targets = [ minify: false, logLevel: "info", }, + // Sidebar webview bundle — workspace panel (#673) + { + entryPoints: ["src/sidebar_webview.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/sidebar_webview.js", + sourcemap: true, + minify: false, + logLevel: "info", + }, ]; if (watch) { diff --git a/packages/extension/package.json b/packages/extension/package.json index f411b369..cb72a413 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -60,16 +60,10 @@ { "id": "amicode.workspace", "name": "Workspace", - "type": "tree" + "type": "webview" } ] }, - "viewsWelcome": [ - { - "view": "amicode.workspace", - "contents": "No folders in this workspace.\n[Add Folder](command:amicode.workspace.addFolder)" - } - ], "commands": [ { "command": "amicode.onboarding.open", @@ -175,53 +169,6 @@ "title": "Amicode: Update canonical opencode (check + adopt)", "category": "Amicode" }, - { - "command": "amicode.workspace.newFile", - "title": "New File", - "icon": "$(new-file)" - }, - { - "command": "amicode.workspace.newFolder", - "title": "New Folder", - "icon": "$(new-folder)" - }, - { - "command": "amicode.workspace.rename", - "title": "Rename" - }, - { - "command": "amicode.workspace.delete", - "title": "Delete" - }, - { - "command": "amicode.workspace.copyPath", - "title": "Copy Path" - }, - { - "command": "amicode.workspace.copyRelativePath", - "title": "Copy Relative Path" - }, - { - "command": "amicode.workspace.revealInOS", - "title": "Reveal in Finder" - }, - { - "command": "amicode.workspace.openInTerminal", - "title": "Open in Terminal" - }, - { - "command": "amicode.workspace.openToSide", - "title": "Open to the Side" - }, - { - "command": "amicode.workspace.removeFromWorkspace", - "title": "Remove Folder from Workspace" - }, - { - "command": "amicode.workspace.addFolder", - "title": "Add Folder to Workspace", - "icon": "$(root-folder-opened)" - }, { "command": "amicode.restartHub", "title": "Amicode: Restart Hub Server (fleet)" @@ -406,75 +353,8 @@ "group": "navigation" } ], - "view/title": [ - { - "command": "amicode.workspace.newFile", - "when": "view == amicode.workspace", - "group": "navigation" - }, - { - "command": "amicode.workspace.newFolder", - "when": "view == amicode.workspace", - "group": "navigation" - }, - { - "command": "amicode.workspace.addFolder", - "when": "view == amicode.workspace", - "group": "navigation" - } - ], - "view/item/context": [ - { - "command": "amicode.workspace.newFile", - "when": "view == amicode.workspace", - "group": "2_workspace@1" - }, - { - "command": "amicode.workspace.newFolder", - "when": "view == amicode.workspace", - "group": "2_workspace@2" - }, - { - "command": "amicode.workspace.openToSide", - "when": "view == amicode.workspace && viewItem == workspaceFile", - "group": "3_open@1" - }, - { - "command": "amicode.workspace.rename", - "when": "view == amicode.workspace", - "group": "7_modification@1" - }, - { - "command": "amicode.workspace.delete", - "when": "view == amicode.workspace", - "group": "7_modification@2" - }, - { - "command": "amicode.workspace.copyPath", - "when": "view == amicode.workspace", - "group": "9_cutcopypaste@1" - }, - { - "command": "amicode.workspace.copyRelativePath", - "when": "view == amicode.workspace", - "group": "9_cutcopypaste@2" - }, - { - "command": "amicode.workspace.revealInOS", - "when": "view == amicode.workspace", - "group": "9_cutcopypaste@3" - }, - { - "command": "amicode.workspace.openInTerminal", - "when": "view == amicode.workspace && viewItem =~ /workspaceFolder|workspaceRoot/", - "group": "9_cutcopypaste@4" - }, - { - "command": "amicode.workspace.removeFromWorkspace", - "when": "view == amicode.workspace && viewItem == workspaceRoot", - "group": "10_workspace@1" - } - ] + "view/title": [], + "view/item/context": [] } }, "scripts": { diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 58a1dddc..909e78fe 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -7,7 +7,7 @@ import { resolveOpencodeBinary, OpencodeMissingError, unsupportedHostAdvice } fr import { resolveSelectedLaunch, HARNESS_REGISTRY } from "./harness"; import { ChatPanel } from "./chat_panel"; import { DeckPanel } from "./deck_panel"; -import { registerWorkspaceTree } from "./workspace_tree"; +import { SidebarViewProvider } from "./sidebar_view"; import { StatusBarManager } from "./status_bar"; import { prepareOpencodeProject, @@ -377,10 +377,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); - // 1. UI surfaces — Workspace sidebar (opencode#215 AC6) - const workspaceTree = registerWorkspaceTree(ctx); + // 1. UI surfaces — Workspace sidebar (webview, #673) + const sidebarProvider = new SidebarViewProvider(ctx.extensionUri); + ctx.subscriptions.push( + vscode.window.registerWebviewViewProvider("amicode.workspace", sidebarProvider), + ); // Mute the "Chat with Amico" button when a chat panel is open - ChatPanel.onLiveChange((count) => workspaceTree.setChatActive(count > 0)); + ChatPanel.onLiveChange((count) => sidebarProvider.setChatActive(count > 0)); 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/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts new file mode 100644 index 00000000..3b96fa05 --- /dev/null +++ b/packages/extension/src/sidebar_bridge.ts @@ -0,0 +1,52 @@ +// sidebar_bridge.ts — Typed message protocol for the sidebar webview. +// Separate from chat_bridge.ts and inspector_bridge.ts — the sidebar has its +// own vocabulary. Messages flow in both directions: +// host → webview: SidebarDownMessage (state pushes) +// webview → host: SidebarUpMessage (user actions) + +// ── Host → Webview (down) ──────────────────────────────────────────────────── + +export type ChatActiveMessage = { kind: "chat-active"; active: boolean }; + +export type SidebarDownMessage = ChatActiveMessage; + +// ── Webview → Host (up) ────────────────────────────────────────────────────── + +export type OpenChatMessage = { kind: "open-chat" }; +export type NewProjectMessage = { kind: "new-project" }; + +export type SidebarUpMessage = OpenChatMessage | NewProjectMessage; + +// ── Combined union (for the bridge type) ───────────────────────────────────── + +export type SidebarMessage = SidebarUpMessage | SidebarDownMessage; + +// ── Handler ────────────────────────────────────────────────────────────────── + +export interface SidebarMessageHandlers { + openChat: () => void; + newProject: () => void; +} + +/** + * Handle a message received from the sidebar webview. + * Dispatches to the appropriate handler based on message kind. + * Unknown kinds are silently ignored (forward-compatible). + */ +export function handleSidebarMessage( + msg: SidebarMessage, + handlers: SidebarMessageHandlers, +): void { + switch (msg.kind) { + case "open-chat": + handlers.openChat(); + break; + case "new-project": + handlers.newProject(); + break; + case "chat-active": + // Down-direction message — no host-side handler needed (the webview + // consumes it). If received on the host side, ignore. + break; + } +} diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts new file mode 100644 index 00000000..ba1fd37e --- /dev/null +++ b/packages/extension/src/sidebar_view.ts @@ -0,0 +1,151 @@ +// sidebar_view.ts — WebviewViewProvider for the Amicode sidebar (#673, slice 1). +// +// Replaces the native TreeDataProvider (workspace_tree.ts) with a webview that +// can render custom UI: styled buttons, project metadata, lifecycle pills, and +// eventually a fleet section. The sidebar is navigation chrome — destinations +// open in the editor area; it never hosts chat or rich visualizations. +// +// Pattern: WebviewViewProvider (sidebar view), CSP nonce, typed bridge. + +import * as vscode from "vscode"; +import { handleSidebarMessage, type SidebarMessageHandlers } from "./sidebar_bridge"; + +/** + * Provides the sidebar webview for the Amicode workspace panel. + * Registered as `amicode.workspace` (type: "webview" in package.json). + */ +export class SidebarViewProvider implements vscode.WebviewViewProvider { + private extensionUri: vscode.Uri; + private view?: vscode.WebviewView; + private chatActive = false; + + constructor(extensionUri: vscode.Uri) { + this.extensionUri = extensionUri; + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, "dist"), + vscode.Uri.joinPath(this.extensionUri, "media"), + ], + }; + + // CSP nonce — regenerated per resolve (not cached). + const nonce = getNonce(); + + // Bundle URI for the browser entry point. + const scriptUri = webviewView.webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, "dist", "sidebar_webview.js"), + ); + + webviewView.webview.html = this.buildHtml(webviewView.webview, nonce, scriptUri); + + // Wire up the bridge: webview → host messages. + webviewView.webview.onDidReceiveMessage((msg) => { + const handlers: SidebarMessageHandlers = { + openChat: () => vscode.commands.executeCommand("amicode.openChat"), + newProject: () => vscode.commands.executeCommand("amicode.newProject"), + }; + handleSidebarMessage(msg, handlers); + }); + } + + /** Push chat-active state to the webview so it can dim the button. */ + setChatActive(active: boolean): void { + if (this.chatActive !== active) { + this.chatActive = active; + this.view?.webview.postMessage({ kind: "chat-active", active }); + } + } + + private buildHtml( + webview: vscode.Webview, + nonce: string, + scriptUri: string | { toString(): string }, + ): string { + const cspSource = webview.cspSource; + return /* html */ ` + + + + + + + + + +
+ + +`; + } +} + +/** Cryptographically random nonce for CSP script-src. */ +function getNonce(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < 32; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts new file mode 100644 index 00000000..ca6ed014 --- /dev/null +++ b/packages/extension/src/sidebar_webview.ts @@ -0,0 +1,41 @@ +// sidebar_webview.ts — Browser entry point for the sidebar webview (#673). +// Runs inside the webview iframe (platform: browser, format: iife). +// Acquires the VS Code API and wires button clicks to bridge messages. + +declare function acquireVsCodeApi(): { + postMessage(msg: unknown): void; + getState(): unknown; + setState(state: unknown): void; +}; + +(function () { + const vscode = acquireVsCodeApi(); + + // ── Button wiring ────────────────────────────────────────────────────────── + + const chatBtn = document.getElementById("btn-chat"); + const newProjectBtn = document.getElementById("btn-new-project"); + + chatBtn?.addEventListener("click", () => { + vscode.postMessage({ kind: "open-chat" }); + }); + + newProjectBtn?.addEventListener("click", () => { + vscode.postMessage({ kind: "new-project" }); + }); + + // ── Host → Webview messages ──────────────────────────────────────────────── + + window.addEventListener("message", (event) => { + const msg = event.data; + if (!msg || typeof msg.kind !== "string") return; + + switch (msg.kind) { + case "chat-active": + if (chatBtn) { + chatBtn.classList.toggle("muted", !!msg.active); + } + break; + } + }); +})(); diff --git a/packages/extension/src/trees.ts b/packages/extension/src/trees.ts deleted file mode 100644 index 3ae9406f..00000000 --- a/packages/extension/src/trees.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as vscode from "vscode"; - -// ============================================================================ -// TreeViews for armonia. -// amicode#204: the old redundant "Vault" + "Armonia" placeholder trees are -// merged into ONE Armonia panel (mounted Vaults are its roots; real rendering -// connects to ArmoniaService when it lands). -// The session catalog (SessionCatalogTree, amicode.catalog) was removed in -// #457 — the vault-backed CatalogStore (packages/amico-run) is the source of -// truth; the activity bar now shows only Armonia + Run Inspector. -// ============================================================================ - -class PlaceholderTree implements vscode.TreeDataProvider { - private readonly _onDidChange = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChange.event; - - constructor(private readonly hint: string) {} - - getTreeItem(element: string): vscode.TreeItem { - return new vscode.TreeItem(element, vscode.TreeItemCollapsibleState.None); - } - getChildren(): string[] { - return [this.hint]; - } - refresh(): void { - this._onDidChange.fire(); - } -} - -export function registerTrees(ctx: vscode.ExtensionContext): { - armonia: PlaceholderTree; -} { - // amicode#204: the single Armonia panel. Its roots are the mounted Vaults; - // until ArmoniaService lands, a product empty state names what collects here. - const armonia = new PlaceholderTree("Your vaults collect here — run Amicode: Set up a vault"); - - ctx.subscriptions.push(vscode.window.registerTreeDataProvider("amicode.armonia", armonia)); - - return { armonia }; -} diff --git a/packages/extension/src/workspace_tree.ts b/packages/extension/src/workspace_tree.ts deleted file mode 100644 index 1797ea63..00000000 --- a/packages/extension/src/workspace_tree.ts +++ /dev/null @@ -1,311 +0,0 @@ -import * as vscode from "vscode"; -import * as path from "node:path"; - -// ============================================================================ -// WorkspaceTreeProvider — AC6 of opencode#215. -// Renders all workspace folders as collapsible roots, expands recursively via -// vscode.workspace.fs.readDirectory(), respects files.exclude + .gitignore, -// shows theme icons, git decorations, opens on click, full context menus, -// and live-updates on filesystem changes. -// -// Context-menu commands are registered as amicode.workspace.* because the -// built-in explorer.* commands only fire within VS Code's native Explorer. -// ============================================================================ - -export type WorkspaceItem = { - uri: vscode.Uri; - type: vscode.FileType; - workspaceFolder?: vscode.WorkspaceFolder; - /** Virtual action items (e.g. "Open Chat") — not real files. */ - action?: string; -}; - -export class WorkspaceTreeProvider implements vscode.TreeDataProvider { - private readonly _onDidChange = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChange.event; - private watcher: vscode.FileSystemWatcher | undefined; - private workspaceSub: vscode.Disposable | undefined; - private decorationProvider: vscode.Disposable | undefined; - private chatActive = false; - private extensionUri?: vscode.Uri; - - constructor() { - // Live updates: watch all files and refresh affected subtree - this.watcher = vscode.workspace.createFileSystemWatcher("**/*"); - this.watcher.onDidCreate(() => this.refresh()); - this.watcher.onDidChange(() => this.refresh()); - this.watcher.onDidDelete(() => this.refresh()); - - // Refresh when workspace folders are added/removed - this.workspaceSub = vscode.workspace.onDidChangeWorkspaceFolders(() => this.refresh()); - - // Git decorations: FileDecorationProvider reading from vscode.scm / git extension - // Minimal: delegate to VS Code's built-in git decorations (theme handles it); - // we provide a provider to surface modified/untracked via badge if available. - this.decorationProvider = vscode.window.registerFileDecorationProvider({ - provideFileDecoration: (_uri) => { - // Let VS Code's git extension handle decorations; we return undefined - // to avoid overriding — the explorer's theme icons already show git status. - return undefined; - }, - }); - } - - refresh(item?: WorkspaceItem): void { - this._onDidChange.fire(item); - } - - /** Set the extension URI for resolving media assets (SVG icons). */ - setExtensionUri(uri: vscode.Uri): void { - this.extensionUri = uri; - } - - /** Mark whether the Amicode chat tab is currently open (mutes the chat item). */ - setChatActive(active: boolean): void { - if (this.chatActive !== active) { - this.chatActive = active; - this.refresh(); - } - } - - getTreeItem(element: WorkspaceItem): vscode.TreeItem { - // Chat action item — custom yellow SVG icon - if (element.action === "openChat") { - const item = new vscode.TreeItem("Chat with Amico", vscode.TreeItemCollapsibleState.None); - item.command = { command: "amicode.openChat", title: "Open Chat" }; - item.contextValue = "chatAction"; - if (this.extensionUri) { - const icon = this.chatActive ? "chat-muted.svg" : "chat-yellow.svg"; - item.iconPath = vscode.Uri.joinPath(this.extensionUri, "media", icon); - } - if (this.chatActive) { - item.description = "(open)"; - item.tooltip = "Amicode chat is open"; - } else { - item.tooltip = "Open Amicode chat"; - } - return item; - } - - const isDir = element.type === vscode.FileType.Directory; - const collapsible = isDir - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None; - const label = path.basename(element.uri.fsPath) || element.uri.fsPath; - const item = new vscode.TreeItem(label, collapsible); - item.resourceUri = element.uri; - // Theme icons: VS Code resolves ThemeIcon.File/Folder automatically via resourceUri - // Root workspace folders get "workspaceRoot" so the "Remove from Workspace" menu targets them. - item.contextValue = isDir - ? (element.workspaceFolder ? "workspaceRoot" : "workspaceFolder") - : "workspaceFile"; - if (!isDir) { - item.command = { - command: "vscode.open", - title: "Open File", - arguments: [element.uri], - }; - } - // Tooltip shows full path - item.tooltip = element.uri.fsPath; - return item; - } - - async getChildren(element?: WorkspaceItem): Promise { - // Root: chat action + workspace folders - if (!element) { - const chatItem: WorkspaceItem = { - uri: vscode.Uri.file("__chat__"), - type: vscode.FileType.File, - action: "openChat", - }; - const folders = vscode.workspace.workspaceFolders ?? []; - return [ - chatItem, - ...folders.map((f) => ({ - uri: f.uri, - type: vscode.FileType.Directory, - workspaceFolder: f, - })), - ]; - } - - // Children: read directory, filter files.exclude + .gitignore, sort dirs first - try { - const entries = await vscode.workspace.fs.readDirectory(element.uri); - // Respect files.exclude (simple prefix check) - const exclude = vscode.workspace.getConfiguration("files", element.uri).get>("exclude", {}); - const excludePatterns = Object.entries(exclude) - .filter(([, v]) => v) - .map(([k]) => k.replace(/\*\*/g, "").replace(/\*/g, "")); - - const filtered = entries.filter(([name]) => { - // Hide the .git directory itself, but not .gitignore, .github, etc. - if (name === ".git") return false; - for (const pat of excludePatterns) { - if (pat && name.includes(pat.replace(/\//g, ""))) return false; - } - return true; - }); - - // Sort: directories first, then files, alphabetically - filtered.sort((a, b) => { - if (a[1] !== b[1]) return a[1] === vscode.FileType.Directory ? -1 : 1; - return a[0].localeCompare(b[0]); - }); - - return filtered.map(([name, type]) => ({ - uri: vscode.Uri.joinPath(element.uri, name), - type, - })); - } catch { - return []; - } - } - - getParent(element: WorkspaceItem): vscode.ProviderResult { - const folders = vscode.workspace.workspaceFolders ?? []; - // If element is a workspace root, no parent - if (folders.some((f) => f.uri.fsPath === element.uri.fsPath)) return undefined; - const parentPath = path.dirname(element.uri.fsPath); - // Find parent item - const folder = vscode.workspace.getWorkspaceFolder(element.uri); - if (!folder) return undefined; - if (parentPath === folder.uri.fsPath) { - return { uri: folder.uri, type: vscode.FileType.Directory, workspaceFolder: folder }; - } - // Generic parent (type unknown, assume directory) - return { uri: vscode.Uri.file(parentPath), type: vscode.FileType.Directory }; - } - - dispose(): void { - this.watcher?.dispose(); - this.workspaceSub?.dispose(); - this.decorationProvider?.dispose(); - this._onDidChange.dispose(); - } -} - -export function registerWorkspaceTree(ctx: vscode.ExtensionContext): WorkspaceTreeProvider { - const provider = new WorkspaceTreeProvider(); - provider.setExtensionUri(ctx.extensionUri); - const treeView = vscode.window.createTreeView("amicode.workspace", { - treeDataProvider: provider, - showCollapseAll: true, - }); - - // ── Context-menu commands ────────────────────────────────────────────────── - // These wrap VS Code's built-in file operations so they work from our custom - // tree view (the built-in explorer.* commands are Explorer-only). - - const cmd = (id: string, handler: (item: WorkspaceItem) => void | Promise) => - vscode.commands.registerCommand(id, handler); - - ctx.subscriptions.push( - treeView, - provider, - - cmd("amicode.workspace.newFile", async (item) => { - const targetDir = resolveDir(item); - if (!targetDir) return; - const name = await vscode.window.showInputBox({ prompt: "File name", placeHolder: "untitled.jl" }); - if (!name) return; - const uri = vscode.Uri.joinPath(targetDir, name); - await vscode.workspace.fs.writeFile(uri, new Uint8Array()); - await vscode.commands.executeCommand("vscode.open", uri); - }), - - cmd("amicode.workspace.newFolder", async (item) => { - const targetDir = resolveDir(item); - if (!targetDir) return; - const name = await vscode.window.showInputBox({ prompt: "Folder name" }); - if (!name) return; - const uri = vscode.Uri.joinPath(targetDir, name); - await vscode.workspace.fs.createDirectory(uri); - }), - - cmd("amicode.workspace.rename", async (item) => { - if (!item?.uri) return; - const oldName = path.basename(item.uri.fsPath); - const newName = await vscode.window.showInputBox({ prompt: "New name", value: oldName }); - if (!newName || newName === oldName) return; - const newUri = vscode.Uri.joinPath(vscode.Uri.file(path.dirname(item.uri.fsPath)), newName); - await vscode.workspace.fs.rename(item.uri, newUri); - }), - - cmd("amicode.workspace.delete", async (item) => { - if (!item?.uri) return; - const name = path.basename(item.uri.fsPath); - const confirm = await vscode.window.showWarningMessage( - `Delete "${name}"?`, { modal: true }, "Move to Trash", "Delete Permanently" - ); - if (confirm === "Move to Trash") { - await vscode.workspace.fs.delete(item.uri, { useTrash: true, recursive: true }); - } else if (confirm === "Delete Permanently") { - await vscode.workspace.fs.delete(item.uri, { recursive: true }); - } - }), - - cmd("amicode.workspace.copyPath", (item) => { - if (!item?.uri) return; - vscode.env.clipboard.writeText(item.uri.fsPath); - }), - - cmd("amicode.workspace.copyRelativePath", (item) => { - if (!item?.uri) return; - const folder = vscode.workspace.getWorkspaceFolder(item.uri); - const rel = folder ? path.relative(folder.uri.fsPath, item.uri.fsPath) : item.uri.fsPath; - vscode.env.clipboard.writeText(rel); - }), - - cmd("amicode.workspace.revealInOS", (item) => { - if (!item?.uri) return; - vscode.commands.executeCommand("revealFileInOS", item.uri); - }), - - cmd("amicode.workspace.openInTerminal", (item) => { - const dir = resolveDir(item); - if (!dir) return; - const terminal = vscode.window.createTerminal({ cwd: dir.fsPath }); - terminal.show(); - }), - - cmd("amicode.workspace.openToSide", (item) => { - if (!item?.uri || item.type === vscode.FileType.Directory) return; - vscode.commands.executeCommand("vscode.open", item.uri, vscode.ViewColumn.Beside); - }), - - cmd("amicode.workspace.removeFromWorkspace", (item) => { - if (!item?.workspaceFolder) return; - const folders = vscode.workspace.workspaceFolders ?? []; - const idx = folders.indexOf(item.workspaceFolder); - if (idx >= 0) { - vscode.workspace.updateWorkspaceFolders(idx, 1); - } - }), - - cmd("amicode.workspace.addFolder", async () => { - const uris = await vscode.window.showOpenDialog({ - canSelectFolders: true, - canSelectFiles: false, - canSelectMany: true, - openLabel: "Add Folder to Workspace", - }); - if (!uris?.length) return; - const folders = vscode.workspace.workspaceFolders ?? []; - vscode.workspace.updateWorkspaceFolders( - folders.length, 0, - ...uris.map((uri) => ({ uri })), - ); - }), - ); - - return provider; -} - -/** Resolve the target directory URI: if the item is a file, use its parent. */ -function resolveDir(item: WorkspaceItem | undefined): vscode.Uri | undefined { - if (!item?.uri) return undefined; - if (item.type === vscode.FileType.Directory) return item.uri; - return vscode.Uri.file(path.dirname(item.uri.fsPath)); -} diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts new file mode 100644 index 00000000..6862b07d --- /dev/null +++ b/packages/extension/test/sidebar_view.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import * as vscode from "vscode"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeWebviewView() { + const messageCbs: Array<(msg: unknown) => void> = []; + let html = ""; + return { + webview: { + get html() { return html; }, + set html(v: string) { html = v; }, + cspSource: "https://test.vscode-resource.vscode-cdn.net", + options: {} as Record, + asWebviewUri: (uri: { fsPath: string }) => `vscode-resource:${uri.fsPath}`, + onDidReceiveMessage: (cb: (msg: unknown) => void) => { + messageCbs.push(cb); + return { dispose() {} }; + }, + postMessage: vi.fn().mockResolvedValue(true), + _simulateMessage(msg: unknown) { for (const cb of messageCbs) cb(msg); }, + }, + _messageCbs: messageCbs, + }; +} + +function makeExtensionUri(base = "/ext") { + return vscode.Uri.file(base); +} + +// ── SidebarViewProvider ────────────────────────────────────────────────────── + +describe("SidebarViewProvider", () => { + let SidebarViewProvider: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../src/sidebar_view"); + SidebarViewProvider = mod.SidebarViewProvider; + }); + + it("resolves a webview with CSP nonce, script tag, and both buttons", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + const html = view.webview.html; + // CSP with nonce + expect(html).toMatch(/Content-Security-Policy/); + expect(html).toMatch(/nonce-[a-z0-9]+/); + // Script tag loads the bundled entry point + expect(html).toContain("sidebar_webview.js"); + // Both header buttons present + expect(html).toContain("Chat with Amico"); + expect(html).toContain("New Project"); + }); +}); + +// ── Sidebar bridge ─────────────────────────────────────────────────────────── + +describe("sidebar bridge — handleSidebarMessage", () => { + let handleSidebarMessage: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../src/sidebar_bridge"); + handleSidebarMessage = mod.handleSidebarMessage; + }); + + it("handles open-chat without throwing", () => { + const openChat = vi.fn(); + const newProject = vi.fn(); + expect(() => + handleSidebarMessage({ kind: "open-chat" }, { openChat, newProject }) + ).not.toThrow(); + expect(openChat).toHaveBeenCalled(); + }); + + it("handles new-project without throwing", () => { + const openChat = vi.fn(); + const newProject = vi.fn(); + expect(() => + handleSidebarMessage({ kind: "new-project" }, { openChat, newProject }) + ).not.toThrow(); + expect(newProject).toHaveBeenCalled(); + }); + + it("handles chat-active message kind", () => { + const openChat = vi.fn(); + const newProject = vi.fn(); + expect(() => + handleSidebarMessage({ kind: "chat-active", active: true }, { openChat, newProject }) + ).not.toThrow(); + }); +}); + +// ── Build pipeline ─────────────────────────────────────────────────────────── + +describe("sidebar build pipeline", () => { + it("esbuild config declares sidebar_webview.ts as a browser entry point", () => { + const configSrc = readFileSync( + resolve(__dirname, "..", "esbuild.config.mjs"), + "utf8", + ); + expect(configSrc).toContain("sidebar_webview.ts"); + expect(configSrc).toContain("dist/sidebar_webview.js"); + }); + + it("package.json registers amicode.workspace as type webview", () => { + const pkg = JSON.parse( + readFileSync(resolve(__dirname, "..", "package.json"), "utf8"), + ); + const views = pkg.contributes?.views?.amicode ?? []; + const wsView = views.find((v: any) => v.id === "amicode.workspace"); + expect(wsView).toBeDefined(); + expect(wsView.type).toBe("webview"); + }); + + it("package.json has no viewsWelcome for amicode.workspace", () => { + const pkg = JSON.parse( + readFileSync(resolve(__dirname, "..", "package.json"), "utf8"), + ); + const welcome = pkg.contributes?.viewsWelcome ?? []; + const wsWelcome = welcome.find((w: any) => w.view === "amicode.workspace"); + expect(wsWelcome).toBeUndefined(); + }); + + it("package.json has no tree-scoped context menu contributions for amicode.workspace", () => { + const pkg = JSON.parse( + readFileSync(resolve(__dirname, "..", "package.json"), "utf8"), + ); + const menus = pkg.contributes?.menus ?? {}; + // view/item/context entries should not reference amicode.workspace + const itemContext = menus["view/item/context"] ?? []; + const wsMenus = itemContext.filter((m: any) => + m.when && m.when.includes("amicode.workspace"), + ); + expect(wsMenus).toHaveLength(0); + }); +}); diff --git a/packages/extension/test/workspace_tree.test.ts b/packages/extension/test/workspace_tree.test.ts deleted file mode 100644 index d1c1c5ea..00000000 --- a/packages/extension/test/workspace_tree.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import * as vscode from "vscode"; -import { registerWorkspaceTree, WorkspaceTreeProvider } from "../src/workspace_tree"; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function makeCtx() { - return { subscriptions: [], extensionUri: vscode.Uri.file("/ext") } as any; -} - -function fileItem(fsPath: string) { - return { uri: vscode.Uri.file(fsPath), type: (vscode as any).FileType.File }; -} - -function dirItem(fsPath: string) { - return { uri: vscode.Uri.file(fsPath), type: (vscode as any).FileType.Directory }; -} - -// ── Tree data ──────────────────────────────────────────────────────────────── - -describe("WorkspaceTreeProvider", () => { - let provider: WorkspaceTreeProvider; - - beforeEach(() => { - (vscode.workspace as any).workspaceFolders = [ - { uri: vscode.Uri.file("/project"), name: "project", index: 0 }, - ]; - provider = new WorkspaceTreeProvider(); - }); - - it("lists Chat with Amico action as the first root item", async () => { - const roots = await provider.getChildren(undefined); - expect(roots[0].action).toBe("openChat"); - }); - - it("lists workspace folders as roots after the chat item", async () => { - const roots = await provider.getChildren(undefined); - expect(roots).toHaveLength(2); - expect(roots[1].uri.fsPath).toBe("/project"); - expect(roots[1].type).toBe((vscode as any).FileType.Directory); - }); - - it("chat action item opens chat and uses yellow icon", () => { - provider.setExtensionUri(vscode.Uri.file("/ext")); - const chatItem = { uri: vscode.Uri.file("__chat__"), type: (vscode as any).FileType.File, action: "openChat" }; - const item = provider.getTreeItem(chatItem as any); - expect(item.command).toMatchObject({ command: "amicode.openChat" }); - expect((item as any).iconPath.fsPath).toContain("chat-yellow.svg"); - expect((item as any).contextValue).toBe("chatAction"); - }); - - it("chat action item is muted when chat is active", () => { - provider.setExtensionUri(vscode.Uri.file("/ext")); - provider.setChatActive(true); - const chatItem = { uri: vscode.Uri.file("__chat__"), type: (vscode as any).FileType.File, action: "openChat" }; - const item = provider.getTreeItem(chatItem as any); - expect((item as any).iconPath.fsPath).toContain("chat-muted.svg"); - expect(item.description).toBe("(open)"); - }); - - it("expands directory children sorted dirs-first then alphabetically", async () => { - vi.spyOn(vscode.workspace.fs, "readDirectory").mockResolvedValueOnce([ - ["beta.ts", (vscode as any).FileType.File], - ["src", (vscode as any).FileType.Directory], - ["alpha.ts", (vscode as any).FileType.File], - [".git", (vscode as any).FileType.Directory], - ["lib", (vscode as any).FileType.Directory], - ] as any); - - const children = await provider.getChildren(dirItem("/project")); - const names = children.map((c: any) => c.uri.fsPath.split("/").pop()); - - // .git is excluded, dirs come first sorted, then files sorted - expect(names).toEqual(["lib", "src", "alpha.ts", "beta.ts"]); - }); - - it("returns collapsible tree items for directories with resourceUri", () => { - const item = provider.getTreeItem(dirItem("/project/src")); - expect(item.collapsibleState).toBe(vscode.TreeItemCollapsibleState.Collapsed); - expect(item.label).toBe("src"); - expect((item as any).resourceUri.fsPath).toBe("/project/src"); - expect((item as any).contextValue).toBe("workspaceFolder"); - }); - - it("returns workspaceRoot contextValue for root workspace folders", () => { - const rootItem = { - uri: vscode.Uri.file("/project"), - type: (vscode as any).FileType.Directory, - workspaceFolder: { uri: vscode.Uri.file("/project"), name: "project", index: 0 }, - }; - const item = provider.getTreeItem(rootItem as any); - expect((item as any).contextValue).toBe("workspaceRoot"); - }); - - it("returns non-collapsible tree items for files with open command", () => { - const item = provider.getTreeItem(fileItem("/project/main.jl")); - expect(item.collapsibleState).toBe(vscode.TreeItemCollapsibleState.None); - expect(item.label).toBe("main.jl"); - expect((item as any).contextValue).toBe("workspaceFile"); - expect(item.command).toMatchObject({ - command: "vscode.open", - arguments: [{ fsPath: "/project/main.jl" }], - }); - }); -}); - -// ── Context menu commands ──────────────────────────────────────────────────── - -describe("Workspace context-menu commands", () => { - let ctx: any; - - beforeEach(() => { - (vscode.workspace as any).workspaceFolders = [ - { uri: vscode.Uri.file("/project"), name: "project", index: 0 }, - ]; - (vscode.commands as any).executed = []; - (vscode.env as any).clipboard.text = ""; - ctx = makeCtx(); - registerWorkspaceTree(ctx); - }); - - it("newFile creates an empty file and opens it", async () => { - vi.spyOn(vscode.window, "showInputBox").mockResolvedValueOnce("hello.jl" as any); - const writeSpy = vi.spyOn(vscode.workspace.fs, "writeFile").mockResolvedValueOnce(undefined); - - await vscode.commands.executeCommand("amicode.workspace.newFile", dirItem("/project/src")); - - expect(writeSpy).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: "/project/src/hello.jl" }), - expect.any(Uint8Array), - ); - expect((vscode.commands as any).executed).toContain("vscode.open"); - }); - - it("newFile on a file item creates in the parent directory", async () => { - vi.spyOn(vscode.window, "showInputBox").mockResolvedValueOnce("sibling.ts" as any); - const writeSpy = vi.spyOn(vscode.workspace.fs, "writeFile").mockResolvedValueOnce(undefined); - - await vscode.commands.executeCommand("amicode.workspace.newFile", fileItem("/project/src/main.jl")); - - expect(writeSpy).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: "/project/src/sibling.ts" }), - expect.any(Uint8Array), - ); - }); - - it("newFile does nothing when input is cancelled", async () => { - vi.spyOn(vscode.window, "showInputBox").mockResolvedValueOnce(undefined as any); - const writeSpy = vi.spyOn(vscode.workspace.fs, "writeFile"); - - await vscode.commands.executeCommand("amicode.workspace.newFile", dirItem("/project")); - - expect(writeSpy).not.toHaveBeenCalled(); - }); - - it("newFolder creates a directory", async () => { - vi.spyOn(vscode.window, "showInputBox").mockResolvedValueOnce("utils" as any); - const mkdirSpy = vi.spyOn(vscode.workspace.fs, "createDirectory").mockResolvedValueOnce(undefined); - - await vscode.commands.executeCommand("amicode.workspace.newFolder", dirItem("/project")); - - expect(mkdirSpy).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: "/project/utils" }), - ); - }); - - it("rename renames via workspace.fs", async () => { - vi.spyOn(vscode.window, "showInputBox").mockResolvedValueOnce("renamed.jl" as any); - const renameSpy = vi.spyOn(vscode.workspace.fs, "rename").mockResolvedValueOnce(undefined); - - await vscode.commands.executeCommand("amicode.workspace.rename", fileItem("/project/old.jl")); - - expect(renameSpy).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: "/project/old.jl" }), - expect.objectContaining({ fsPath: "/project/renamed.jl" }), - ); - }); - - it("rename does nothing when user cancels or enters same name", async () => { - vi.spyOn(vscode.window, "showInputBox").mockResolvedValueOnce("old.jl" as any); - const renameSpy = vi.spyOn(vscode.workspace.fs, "rename"); - - await vscode.commands.executeCommand("amicode.workspace.rename", fileItem("/project/old.jl")); - - expect(renameSpy).not.toHaveBeenCalled(); - }); - - it("delete moves to trash when confirmed", async () => { - vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValueOnce("Move to Trash" as any); - const deleteSpy = vi.spyOn(vscode.workspace.fs, "delete").mockResolvedValueOnce(undefined); - - await vscode.commands.executeCommand("amicode.workspace.delete", fileItem("/project/dead.ts")); - - expect(deleteSpy).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: "/project/dead.ts" }), - { useTrash: true, recursive: true }, - ); - }); - - it("delete permanently when confirmed", async () => { - vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValueOnce("Delete Permanently" as any); - const deleteSpy = vi.spyOn(vscode.workspace.fs, "delete").mockResolvedValueOnce(undefined); - - await vscode.commands.executeCommand("amicode.workspace.delete", fileItem("/project/dead.ts")); - - expect(deleteSpy).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: "/project/dead.ts" }), - { recursive: true }, - ); - }); - - it("delete does nothing when dismissed", async () => { - vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValueOnce(undefined as any); - const deleteSpy = vi.spyOn(vscode.workspace.fs, "delete"); - - await vscode.commands.executeCommand("amicode.workspace.delete", fileItem("/project/keep.ts")); - - expect(deleteSpy).not.toHaveBeenCalled(); - }); - - it("copyPath writes absolute path to clipboard", async () => { - await vscode.commands.executeCommand("amicode.workspace.copyPath", fileItem("/project/src/main.jl")); - - expect(vscode.env.clipboard.text).toBe("/project/src/main.jl"); - }); - - it("copyRelativePath writes workspace-relative path to clipboard", async () => { - await vscode.commands.executeCommand("amicode.workspace.copyRelativePath", fileItem("/project/src/main.jl")); - - expect(vscode.env.clipboard.text).toBe("src/main.jl"); - }); - - it("revealInOS delegates to the built-in command", async () => { - await vscode.commands.executeCommand("amicode.workspace.revealInOS", fileItem("/project/file.jl")); - - expect((vscode.commands as any).executed).toContain("revealFileInOS"); - }); - - it("openInTerminal creates a terminal at the directory", async () => { - const termSpy = vi.spyOn(vscode.window, "createTerminal"); - - await vscode.commands.executeCommand("amicode.workspace.openInTerminal", dirItem("/project/src")); - - expect(termSpy).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/project/src" })); - }); - - it("openToSide opens file in beside column", async () => { - await vscode.commands.executeCommand("amicode.workspace.openToSide", fileItem("/project/file.jl")); - - expect((vscode.commands as any).executed).toContain("vscode.open"); - }); - - it("removeFromWorkspace removes the folder at the correct index", async () => { - const updateSpy = vi.spyOn(vscode.workspace as any, "updateWorkspaceFolders"); - const folder = (vscode.workspace as any).workspaceFolders[0]; - const rootItem = { uri: folder.uri, type: (vscode as any).FileType.Directory, workspaceFolder: folder }; - - await vscode.commands.executeCommand("amicode.workspace.removeFromWorkspace", rootItem); - - expect(updateSpy).toHaveBeenCalledWith(0, 1); - }); - - it("removeFromWorkspace does nothing for non-root items", async () => { - const updateSpy = vi.spyOn(vscode.workspace as any, "updateWorkspaceFolders"); - - await vscode.commands.executeCommand("amicode.workspace.removeFromWorkspace", dirItem("/project/src")); - - expect(updateSpy).not.toHaveBeenCalled(); - }); -}); From 24b1cde900a00ac14f0f68e62f99c7c30f7ea7ec Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 17:17:25 -0400 Subject: [PATCH 02/34] feat(sidebar): project tree with lazy file browsing (#675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the sidebar webview with a full project tree: - SidebarTreeService: scans workspace folders, classifies via detectProjectType(), reads research-project.toml for name/phase, returns structured roots (research first) - Bridge: get-roots, get-children (lazy), open-file, fs-changed message kinds - Webview: vanilla DOM tree rendering with expand/collapse, section labels, lifecycle phase pills, file icons, dirs-first alphabetical sort - .git hidden, files.exclude patterns respected - FileSystemWatcher posts fs-changed → webview invalidates cache + re-requests - Tree state (expanded nodes) persisted via webview setState/getState - Fleet placeholder section ('coming soon') - Workspace folder add/remove triggers re-scan Closes #675 --- packages/extension/src/sidebar_bridge.ts | 59 ++++- .../extension/src/sidebar_tree_service.ts | 110 ++++++++ packages/extension/src/sidebar_view.ts | 157 ++++++++++- packages/extension/src/sidebar_webview.ts | 244 +++++++++++++++++- packages/extension/test/sidebar_view.test.ts | 161 ++++++++++++ 5 files changed, 721 insertions(+), 10 deletions(-) create mode 100644 packages/extension/src/sidebar_tree_service.ts diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts index 3b96fa05..614ffde1 100644 --- a/packages/extension/src/sidebar_bridge.ts +++ b/packages/extension/src/sidebar_bridge.ts @@ -4,18 +4,48 @@ // host → webview: SidebarDownMessage (state pushes) // webview → host: SidebarUpMessage (user actions) +// ── Data types ─────────────────────────────────────────────────────────────── + +export interface TreeRoot { + path: string; + name: string; + projectType: "research" | "dev"; + metadata?: { phase?: string; lastActive?: string }; +} + +export interface TreeEntry { + name: string; + type: "file" | "directory"; + path: string; +} + // ── Host → Webview (down) ──────────────────────────────────────────────────── export type ChatActiveMessage = { kind: "chat-active"; active: boolean }; +export type RootsMessage = { kind: "roots"; roots: TreeRoot[] }; +export type ChildrenMessage = { kind: "children"; path: string; entries: TreeEntry[] }; +export type FsChangedMessage = { kind: "fs-changed"; folder: string }; -export type SidebarDownMessage = ChatActiveMessage; +export type SidebarDownMessage = + | ChatActiveMessage + | RootsMessage + | ChildrenMessage + | FsChangedMessage; // ── Webview → Host (up) ────────────────────────────────────────────────────── export type OpenChatMessage = { kind: "open-chat" }; export type NewProjectMessage = { kind: "new-project" }; +export type GetRootsMessage = { kind: "get-roots" }; +export type GetChildrenMessage = { kind: "get-children"; path: string }; +export type OpenFileMessage = { kind: "open-file"; path: string }; -export type SidebarUpMessage = OpenChatMessage | NewProjectMessage; +export type SidebarUpMessage = + | OpenChatMessage + | NewProjectMessage + | GetRootsMessage + | GetChildrenMessage + | OpenFileMessage; // ── Combined union (for the bridge type) ───────────────────────────────────── @@ -26,6 +56,10 @@ export type SidebarMessage = SidebarUpMessage | SidebarDownMessage; export interface SidebarMessageHandlers { openChat: () => void; newProject: () => void; + getRoots: () => TreeRoot[]; + getChildren: (path: string) => Promise; + openFile: (path: string) => void; + postMessage: (msg: SidebarDownMessage) => void; } /** @@ -36,7 +70,7 @@ export interface SidebarMessageHandlers { export function handleSidebarMessage( msg: SidebarMessage, handlers: SidebarMessageHandlers, -): void { +): void | Promise { switch (msg.kind) { case "open-chat": handlers.openChat(); @@ -44,9 +78,24 @@ export function handleSidebarMessage( case "new-project": handlers.newProject(); break; + case "get-roots": { + const roots = handlers.getRoots(); + handlers.postMessage({ kind: "roots", roots }); + break; + } + case "get-children": + return handlers.getChildren(msg.path).then((entries) => { + handlers.postMessage({ kind: "children", path: msg.path, entries }); + }); + case "open-file": + handlers.openFile(msg.path); + break; case "chat-active": - // Down-direction message — no host-side handler needed (the webview - // consumes it). If received on the host side, ignore. + case "roots": + case "children": + case "fs-changed": + // Down-direction messages — no host-side handler needed (the webview + // consumes them). If received on the host side, ignore. break; } } diff --git a/packages/extension/src/sidebar_tree_service.ts b/packages/extension/src/sidebar_tree_service.ts new file mode 100644 index 00000000..731e45bd --- /dev/null +++ b/packages/extension/src/sidebar_tree_service.ts @@ -0,0 +1,110 @@ +// sidebar_tree_service.ts — Extension-side tree scanning for the sidebar (#675). +// +// Owns the logic that the SidebarViewProvider's bridge handlers delegate to: +// scanning workspace folders, classifying projects, reading directory entries +// with filtering and sorting. All filesystem access runs on the extension host +// (Node runtime) and the results are posted to the webview via bridge messages. + +import type { TreeRoot, TreeEntry } from "./sidebar_bridge"; + +// ── Dependencies (injected for testability) ────────────────────────────────── + +export interface RawDirEntry { + name: string; + type: "file" | "directory"; +} + +export interface TreeServiceDeps { + /** Classify a directory as research or dev. */ + detectProjectType: (dir: string) => "research" | "dev"; + /** Read research-project.toml fields (name, status). Returns {} on failure. */ + readToml: (dir: string) => { name?: string; status?: string }; + /** Read immediate children of a directory. */ + readDirectory?: (dir: string) => Promise; + /** Get exclude pattern strings from files.exclude. */ + getExcludePatterns?: () => string[]; + /** Get current workspace folders. */ + getWorkspaceFolders?: () => Array<{ uri: { fsPath: string }; name: string }>; +} + +// ── Service ────────────────────────────────────────────────────────────────── + +/** + * Stateless tree-scanning service. Each method is a pure query — no caching, + * no watchers, no VS Code API calls. The provider wires these to the bridge. + */ +export class SidebarTreeService { + private deps: TreeServiceDeps; + + constructor(deps: TreeServiceDeps) { + this.deps = deps; + } + + /** + * Scan workspace folders and return structured roots. + * Research Projects are grouped before Dev Projects. + */ + getRoots(): TreeRoot[] { + const workspaceFolders = this.deps.getWorkspaceFolders?.() ?? []; + + const research: TreeRoot[] = []; + const dev: TreeRoot[] = []; + + for (const folder of workspaceFolders) { + const dir = folder.uri.fsPath; + const projectType = this.deps.detectProjectType(dir); + + if (projectType === "research") { + const toml = this.deps.readToml(dir); + research.push({ + path: dir, + name: toml.name ?? folder.name, + projectType: "research", + metadata: toml.status ? { phase: toml.status } : undefined, + }); + } else { + dev.push({ + path: dir, + name: folder.name, + projectType: "dev", + }); + } + } + + // Research first, then dev + return [...research, ...dev]; + } + + /** + * Lazy-load immediate children of a directory. + * Filters .git, applies files.exclude, sorts dirs-first then alphabetical. + */ + async getChildren(dirPath: string): Promise { + if (!this.deps.readDirectory) return []; + + const raw = await this.deps.readDirectory(dirPath); + const excludePatterns = this.deps.getExcludePatterns?.() ?? []; + + const filtered = raw.filter((entry) => { + // Always hide .git + if (entry.name === ".git") return false; + // Apply exclude patterns (simple name match) + for (const pat of excludePatterns) { + if (pat && entry.name === pat) return false; + } + return true; + }); + + // Sort: directories first, then files, alphabetically within each group + filtered.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return filtered.map((entry) => ({ + name: entry.name, + type: entry.type, + path: `${dirPath}/${entry.name}`, + })); + } +} diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index ba1fd37e..009735ed 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -1,4 +1,4 @@ -// sidebar_view.ts — WebviewViewProvider for the Amicode sidebar (#673, slice 1). +// sidebar_view.ts — WebviewViewProvider for the Amicode sidebar (#673). // // Replaces the native TreeDataProvider (workspace_tree.ts) with a webview that // can render custom UI: styled buttons, project metadata, lifecycle pills, and @@ -8,7 +8,11 @@ // Pattern: WebviewViewProvider (sidebar view), CSP nonce, typed bridge. import * as vscode from "vscode"; -import { handleSidebarMessage, type SidebarMessageHandlers } from "./sidebar_bridge"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage } from "./sidebar_bridge"; +import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service"; +import { detectProjectType } from "./project/detect"; /** * Provides the sidebar webview for the Amicode workspace panel. @@ -18,9 +22,19 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { private extensionUri: vscode.Uri; private view?: vscode.WebviewView; private chatActive = false; + private watcher?: vscode.FileSystemWatcher; + private workspaceSub?: vscode.Disposable; + private treeService: SidebarTreeService; constructor(extensionUri: vscode.Uri) { this.extensionUri = extensionUri; + this.treeService = new SidebarTreeService({ + detectProjectType, + readToml: (dir) => readResearchToml(dir), + readDirectory: (dir) => readDirectoryEntries(dir), + getExcludePatterns: () => getExcludePatterns(), + getWorkspaceFolders: () => (vscode.workspace.workspaceFolders ?? []) as Array<{ uri: { fsPath: string }; name: string }>, + }); } resolveWebviewView( @@ -53,8 +67,31 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { const handlers: SidebarMessageHandlers = { openChat: () => vscode.commands.executeCommand("amicode.openChat"), newProject: () => vscode.commands.executeCommand("amicode.newProject"), + getRoots: () => this.treeService.getRoots(), + getChildren: (p) => this.treeService.getChildren(p), + openFile: (p) => { + const uri = vscode.Uri.file(p); + void vscode.window.showTextDocument(uri); + }, + postMessage: (m) => { + void webviewView.webview.postMessage(m); + }, }; - handleSidebarMessage(msg, handlers); + void handleSidebarMessage(msg, handlers); + }); + + // FileSystemWatcher — refresh subtrees on changes. + this.setupWatcher(webviewView); + + // Refresh when workspace folders change. + this.workspaceSub = vscode.workspace.onDidChangeWorkspaceFolders(() => { + this.postDown({ kind: "roots", roots: this.treeService.getRoots() }); + }); + + webviewView.onDidDispose(() => { + this.watcher?.dispose(); + this.workspaceSub?.dispose(); + this.view = undefined; }); } @@ -62,10 +99,30 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { setChatActive(active: boolean): void { if (this.chatActive !== active) { this.chatActive = active; - this.view?.webview.postMessage({ kind: "chat-active", active }); + this.postDown({ kind: "chat-active", active }); } } + private postDown(msg: SidebarDownMessage): void { + this.view?.webview.postMessage(msg); + } + + private setupWatcher(webviewView: vscode.WebviewView): void { + this.watcher = vscode.workspace.createFileSystemWatcher("**/*"); + const onFsEvent = (uri: vscode.Uri) => { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (folder) { + void webviewView.webview.postMessage({ + kind: "fs-changed", + folder: folder.uri.fsPath, + }); + } + }; + this.watcher.onDidCreate(onFsEvent); + this.watcher.onDidChange(onFsEvent); + this.watcher.onDidDelete(onFsEvent); + } + private buildHtml( webview: vscode.Webview, nonce: string, @@ -126,6 +183,58 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { #tree-root { padding: 4px 0; } + .tree-node { + display: flex; + align-items: center; + padding: 2px 8px 2px 0; + cursor: pointer; + user-select: none; + } + .tree-node:hover { + background: var(--vscode-list-hoverBackground); + } + .tree-node .indent { + flex-shrink: 0; + } + .tree-node .icon { + width: 16px; + height: 16px; + margin-right: 4px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + } + .tree-node .label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tree-node .pill { + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + margin-left: 6px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + flex-shrink: 0; + } + .tree-section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 8px 12px 4px; + color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-descriptionForeground)); + font-weight: 600; + } + .fleet-placeholder { + padding: 8px 12px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + font-style: italic; + } @@ -134,12 +243,52 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
+
Fleet — coming soon
`; } } +// ── Helpers (extension host, Node runtime) ─────────────────────────────────── + +/** Read research-project.toml fields. Returns {} on any failure. */ +function readResearchToml(dir: string): { name?: string; status?: string } { + try { + const tomlPath = path.join(dir, "research-project.toml"); + const content = fs.readFileSync(tomlPath, "utf8"); + // Minimal TOML key extraction (no full parser dependency — only name/status). + const name = content.match(/^\s*name\s*=\s*"([^"]*)"/m)?.[1]; + const status = content.match(/^\s*status\s*=\s*"([^"]*)"/m)?.[1]; + return { name, status }; + } catch { + return {}; + } +} + +/** Read directory entries via Node fs. */ +async function readDirectoryEntries(dir: string): Promise { + try { + const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(dir)); + return entries.map(([name, type]) => ({ + name, + type: type === vscode.FileType.Directory ? "directory" as const : "file" as const, + })); + } catch { + return []; + } +} + +/** Get exclude patterns from files.exclude config. */ +function getExcludePatterns(): string[] { + const exclude = vscode.workspace + .getConfiguration("files") + .get>("exclude", {}); + return Object.entries(exclude) + .filter(([, v]) => v) + .map(([k]) => k.replace(/\*\*/g, "").replace(/\*/g, "").replace(/\//g, "")); +} + /** Cryptographically random nonce for CSP script-src. */ function getNonce(): string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index ca6ed014..42df9dcd 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -1,6 +1,6 @@ // sidebar_webview.ts — Browser entry point for the sidebar webview (#673). // Runs inside the webview iframe (platform: browser, format: iife). -// Acquires the VS Code API and wires button clicks to bridge messages. +// Acquires the VS Code API and wires button clicks + tree rendering to bridge. declare function acquireVsCodeApi(): { postMessage(msg: unknown): void; @@ -8,13 +8,35 @@ declare function acquireVsCodeApi(): { setState(state: unknown): void; }; +interface TreeRoot { + path: string; + name: string; + projectType: "research" | "dev"; + metadata?: { phase?: string; lastActive?: string }; +} + +interface TreeEntry { + name: string; + type: "file" | "directory"; + path: string; +} + (function () { const vscode = acquireVsCodeApi(); + // Restore expanded state from webview state (survives hide/show). + const savedState = vscode.getState() as { expanded?: Record } | undefined; + const expanded: Record = savedState?.expanded ?? {}; + + function saveExpandedState(): void { + vscode.setState({ expanded }); + } + // ── Button wiring ────────────────────────────────────────────────────────── const chatBtn = document.getElementById("btn-chat"); const newProjectBtn = document.getElementById("btn-new-project"); + const treeRoot = document.getElementById("tree-root"); chatBtn?.addEventListener("click", () => { vscode.postMessage({ kind: "open-chat" }); @@ -24,6 +46,188 @@ declare function acquireVsCodeApi(): { vscode.postMessage({ kind: "new-project" }); }); + // ── Tree rendering ───────────────────────────────────────────────────────── + + let currentRoots: TreeRoot[] = []; + // Cache children per directory path + const childrenCache: Record = {}; + + function renderRoots(roots: TreeRoot[]): void { + if (!treeRoot) return; + currentRoots = roots; + treeRoot.innerHTML = ""; + + // Group: research first, then dev + const research = roots.filter((r) => r.projectType === "research"); + const dev = roots.filter((r) => r.projectType === "dev"); + + if (research.length > 0) { + const label = document.createElement("div"); + label.className = "tree-section-label"; + label.textContent = "Research Projects"; + treeRoot.appendChild(label); + for (const root of research) { + treeRoot.appendChild(renderRootNode(root, 0)); + } + } + + if (dev.length > 0) { + const label = document.createElement("div"); + label.className = "tree-section-label"; + label.textContent = "Development"; + treeRoot.appendChild(label); + for (const root of dev) { + treeRoot.appendChild(renderRootNode(root, 0)); + } + } + } + + function renderRootNode(root: TreeRoot, depth: number): HTMLElement { + const container = document.createElement("div"); + container.dataset.path = root.path; + container.dataset.type = "directory"; + + const row = document.createElement("div"); + row.className = "tree-node"; + row.style.paddingLeft = `${8 + depth * 16}px`; + + // Chevron + const icon = document.createElement("span"); + icon.className = "icon"; + icon.textContent = expanded[root.path] ? "\u25BE" : "\u25B8"; // ▾ / ▸ + + const label = document.createElement("span"); + label.className = "label"; + label.textContent = root.name; + + row.appendChild(icon); + row.appendChild(label); + + // Research metadata pill + if (root.projectType === "research" && root.metadata?.phase) { + const pill = document.createElement("span"); + pill.className = "pill"; + pill.textContent = root.metadata.phase; + row.appendChild(pill); + } + + container.appendChild(row); + + // Children container + const childrenEl = document.createElement("div"); + childrenEl.className = "children"; + childrenEl.style.display = expanded[root.path] ? "block" : "none"; + container.appendChild(childrenEl); + + // If already expanded and cached, render children + if (expanded[root.path] && childrenCache[root.path]) { + renderChildren(childrenEl, childrenCache[root.path], depth + 1); + } + + row.addEventListener("click", () => { + expanded[root.path] = !expanded[root.path]; + saveExpandedState(); + icon.textContent = expanded[root.path] ? "\u25BE" : "\u25B8"; + childrenEl.style.display = expanded[root.path] ? "block" : "none"; + + if (expanded[root.path] && !childrenCache[root.path]) { + vscode.postMessage({ kind: "get-children", path: root.path }); + } + }); + + // Request children on initial render if expanded (for restore) + if (expanded[root.path] && !childrenCache[root.path]) { + vscode.postMessage({ kind: "get-children", path: root.path }); + } + + return container; + } + + function renderChildren(container: HTMLElement, entries: TreeEntry[], depth: number): void { + container.innerHTML = ""; + + for (const entry of entries) { + if (entry.type === "directory") { + container.appendChild(renderDirectoryNode(entry, depth)); + } else { + container.appendChild(renderFileNode(entry, depth)); + } + } + } + + function renderDirectoryNode(entry: TreeEntry, depth: number): HTMLElement { + const container = document.createElement("div"); + container.dataset.path = entry.path; + container.dataset.type = "directory"; + + const row = document.createElement("div"); + row.className = "tree-node"; + row.style.paddingLeft = `${8 + depth * 16}px`; + + const icon = document.createElement("span"); + icon.className = "icon"; + icon.textContent = expanded[entry.path] ? "\u25BE" : "\u25B8"; + + const label = document.createElement("span"); + label.className = "label"; + label.textContent = entry.name; + + row.appendChild(icon); + row.appendChild(label); + container.appendChild(row); + + const childrenEl = document.createElement("div"); + childrenEl.className = "children"; + childrenEl.style.display = expanded[entry.path] ? "block" : "none"; + container.appendChild(childrenEl); + + if (expanded[entry.path] && childrenCache[entry.path]) { + renderChildren(childrenEl, childrenCache[entry.path], depth + 1); + } + + row.addEventListener("click", () => { + expanded[entry.path] = !expanded[entry.path]; + saveExpandedState(); + icon.textContent = expanded[entry.path] ? "\u25BE" : "\u25B8"; + childrenEl.style.display = expanded[entry.path] ? "block" : "none"; + + if (expanded[entry.path] && !childrenCache[entry.path]) { + vscode.postMessage({ kind: "get-children", path: entry.path }); + } + }); + + if (expanded[entry.path] && !childrenCache[entry.path]) { + vscode.postMessage({ kind: "get-children", path: entry.path }); + } + + return container; + } + + function renderFileNode(entry: TreeEntry, depth: number): HTMLElement { + const row = document.createElement("div"); + row.className = "tree-node"; + row.dataset.path = entry.path; + row.dataset.type = "file"; + row.style.paddingLeft = `${8 + depth * 16}px`; + + const icon = document.createElement("span"); + icon.className = "icon"; + icon.textContent = "\u{1F4C4}"; // 📄 + + const label = document.createElement("span"); + label.className = "label"; + label.textContent = entry.name; + + row.appendChild(icon); + row.appendChild(label); + + row.addEventListener("click", () => { + vscode.postMessage({ kind: "open-file", path: entry.path }); + }); + + return row; + } + // ── Host → Webview messages ──────────────────────────────────────────────── window.addEventListener("message", (event) => { @@ -36,6 +240,44 @@ declare function acquireVsCodeApi(): { chatBtn.classList.toggle("muted", !!msg.active); } break; + + case "roots": + renderRoots(msg.roots ?? []); + break; + + case "children": { + childrenCache[msg.path] = msg.entries ?? []; + // Find the container for this path and render children + const container = treeRoot?.querySelector(`[data-path="${CSS.escape(msg.path)}"] > .children`); + if (container) { + const depth = Math.round((parseInt((container.parentElement as HTMLElement)?.querySelector('.tree-node')?.style.paddingLeft ?? '8') - 8) / 16) + 1; + renderChildren(container as HTMLElement, msg.entries ?? [], depth); + } + break; + } + + case "fs-changed": { + // Invalidate cache for the changed folder and re-request if expanded + const folder = msg.folder; + for (const key of Object.keys(childrenCache)) { + if (key === folder || key.startsWith(folder + "/")) { + delete childrenCache[key]; + } + } + // Re-request roots (workspace may have changed project type) + vscode.postMessage({ kind: "get-roots" }); + // Re-request children for expanded nodes under this folder + for (const key of Object.keys(expanded)) { + if (expanded[key] && (key === folder || key.startsWith(folder + "/"))) { + vscode.postMessage({ kind: "get-children", path: key }); + } + } + break; + } } }); + + // ── Initial load ─────────────────────────────────────────────────────────── + + vscode.postMessage({ kind: "get-roots" }); })(); diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 6862b07d..d745ad55 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -7,6 +7,7 @@ import * as vscode from "vscode"; function makeWebviewView() { const messageCbs: Array<(msg: unknown) => void> = []; + const disposeCbs: Array<() => void> = []; let html = ""; return { webview: { @@ -22,7 +23,12 @@ function makeWebviewView() { postMessage: vi.fn().mockResolvedValue(true), _simulateMessage(msg: unknown) { for (const cb of messageCbs) cb(msg); }, }, + onDidDispose: (cb: () => void) => { + disposeCbs.push(cb); + return { dispose() {} }; + }, _messageCbs: messageCbs, + _disposeCbs: disposeCbs, }; } @@ -141,3 +147,158 @@ describe("sidebar build pipeline", () => { expect(wsMenus).toHaveLength(0); }); }); + +// ── Project tree scanning (#675) ───────────────────────────────────────────── + +describe("sidebar bridge — project tree scanning", () => { + let handleSidebarMessage: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../src/sidebar_bridge"); + handleSidebarMessage = mod.handleSidebarMessage; + }); + + it("get-roots returns workspace folders classified by project type, research first", () => { + const openChat = vi.fn(); + const newProject = vi.fn(); + const postMessage = vi.fn(); + + // Mock workspace folders: one research, one dev + const getRoots = vi.fn().mockReturnValue([ + { path: "/projects/quantum-sim", name: "quantum-sim", projectType: "research", metadata: { phase: "running" } }, + { path: "/projects/my-app", name: "my-app", projectType: "dev" }, + ]); + + handleSidebarMessage( + { kind: "get-roots" }, + { openChat, newProject, getRoots, getChildren: vi.fn(), openFile: vi.fn(), postMessage }, + ); + + expect(getRoots).toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "roots", + roots: expect.arrayContaining([ + expect.objectContaining({ path: "/projects/quantum-sim", projectType: "research" }), + expect.objectContaining({ path: "/projects/my-app", projectType: "dev" }), + ]), + }), + ); + }); + + it("get-children returns directory entries sorted dirs-first, .git filtered", async () => { + const openChat = vi.fn(); + const newProject = vi.fn(); + const postMessage = vi.fn(); + + const getChildren = vi.fn().mockResolvedValue([ + { name: "src", type: "directory" }, + { name: "alpha.ts", type: "file" }, + { name: "beta.ts", type: "file" }, + ]); + + await handleSidebarMessage( + { kind: "get-children", path: "/projects/quantum-sim" }, + { openChat, newProject, getRoots: vi.fn(), getChildren, openFile: vi.fn(), postMessage }, + ); + + expect(getChildren).toHaveBeenCalledWith("/projects/quantum-sim"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "children", + path: "/projects/quantum-sim", + entries: [ + expect.objectContaining({ name: "src", type: "directory" }), + expect.objectContaining({ name: "alpha.ts", type: "file" }), + expect.objectContaining({ name: "beta.ts", type: "file" }), + ], + }), + ); + }); + + it("open-file triggers the openFile handler", () => { + const openFile = vi.fn(); + handleSidebarMessage( + { kind: "open-file", path: "/projects/quantum-sim/solve.jl" }, + { openChat: vi.fn(), newProject: vi.fn(), getRoots: vi.fn(), getChildren: vi.fn(), openFile, postMessage: vi.fn() }, + ); + + expect(openFile).toHaveBeenCalledWith("/projects/quantum-sim/solve.jl"); + }); +}); + +// ── Tree service (#675) ────────────────────────────────────────────────────── + +describe("SidebarTreeService", () => { + let SidebarTreeService: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../src/sidebar_tree_service"); + SidebarTreeService = mod.SidebarTreeService; + }); + + it("getRoots classifies workspace folders, research first", () => { + const folders = [ + { uri: vscode.Uri.file("/dev-app"), name: "dev-app", index: 0 }, + { uri: vscode.Uri.file("/quantum-sim"), name: "quantum-sim", index: 1 }, + ]; + + const service = new SidebarTreeService({ + detectProjectType: (dir: string) => + dir === "/quantum-sim" ? "research" : "dev", + readToml: () => ({ name: "Quantum Sim", status: "running" }), + getWorkspaceFolders: () => folders, + }); + + const roots = service.getRoots(); + // Research projects come first + expect(roots[0]).toMatchObject({ + path: "/quantum-sim", + projectType: "research", + name: "Quantum Sim", + }); + expect(roots[1]).toMatchObject({ + path: "/dev-app", + projectType: "dev", + name: "dev-app", + }); + }); + + it("getChildren returns entries with .git filtered and dirs-first sort", async () => { + const service = new SidebarTreeService({ + detectProjectType: () => "dev", + readToml: () => ({}), + readDirectory: async () => [ + { name: "beta.ts", type: "file" }, + { name: ".git", type: "directory" }, + { name: "src", type: "directory" }, + { name: "alpha.ts", type: "file" }, + ], + getExcludePatterns: () => [], + }); + + const entries = await service.getChildren("/project"); + const names = entries.map((e: any) => e.name); + // .git excluded, dirs first, then alphabetical + expect(names).toEqual(["src", "alpha.ts", "beta.ts"]); + }); + + it("getChildren respects files.exclude patterns", async () => { + const service = new SidebarTreeService({ + detectProjectType: () => "dev", + readToml: () => ({}), + readDirectory: async () => [ + { name: "src", type: "directory" }, + { name: "node_modules", type: "directory" }, + { name: "main.ts", type: "file" }, + ], + getExcludePatterns: () => ["node_modules"], + }); + + const entries = await service.getChildren("/project"); + const names = entries.map((e: any) => e.name); + expect(names).toEqual(["src", "main.ts"]); + }); +}); From 2e250e33b31be0aa97f43c6ba35fc2aacb67ed42 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 17:19:07 -0400 Subject: [PATCH 03/34] feat(sidebar): context menus and file operations (#676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add file operation support to the sidebar webview bridge: - file-op bridge message: rename, delete (trash-only), new-file, new-folder, copy-path, copy-relative-path, reveal-in-os, open-in-terminal, open-to-side, remove-from-workspace, new-session - All operations dispatch through typed bridge → extension executes vscode APIs - file-op-error response posted back on failure (e.g. rename collision) - Delete invariant: always useTrash: true — no permanent delete path exists - Rename validates target doesn't collide before calling fs.rename() - active-project message type added (groundwork for #677) Closes #676 --- packages/extension/src/sidebar_bridge.ts | 43 ++++++++- packages/extension/src/sidebar_view.ts | 91 +++++++++++++++++- packages/extension/test/sidebar_view.test.ts | 98 ++++++++++++++++++++ 3 files changed, 227 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts index 614ffde1..133624f1 100644 --- a/packages/extension/src/sidebar_bridge.ts +++ b/packages/extension/src/sidebar_bridge.ts @@ -19,18 +19,36 @@ export interface TreeEntry { path: string; } +// ── File operation types ───────────────────────────────────────────────────── + +export interface FileOpRequest { + op: "rename" | "delete" | "new-file" | "new-folder" | "copy-path" | "copy-relative-path" | "reveal-in-os" | "open-in-terminal" | "open-to-side" | "remove-from-workspace" | "new-session"; + path: string; + newName?: string; + name?: string; +} + +export interface FileOpResult { + ok: boolean; + message?: string; +} + // ── Host → Webview (down) ──────────────────────────────────────────────────── export type ChatActiveMessage = { kind: "chat-active"; active: boolean }; export type RootsMessage = { kind: "roots"; roots: TreeRoot[] }; export type ChildrenMessage = { kind: "children"; path: string; entries: TreeEntry[] }; export type FsChangedMessage = { kind: "fs-changed"; folder: string }; +export type FileOpErrorMessage = { kind: "file-op-error"; op: string; path: string; message: string }; +export type ActiveProjectMessage = { kind: "active-project"; path: string | null }; export type SidebarDownMessage = | ChatActiveMessage | RootsMessage | ChildrenMessage - | FsChangedMessage; + | FsChangedMessage + | FileOpErrorMessage + | ActiveProjectMessage; // ── Webview → Host (up) ────────────────────────────────────────────────────── @@ -39,13 +57,15 @@ export type NewProjectMessage = { kind: "new-project" }; export type GetRootsMessage = { kind: "get-roots" }; export type GetChildrenMessage = { kind: "get-children"; path: string }; export type OpenFileMessage = { kind: "open-file"; path: string }; +export type FileOpMessage = { kind: "file-op" } & FileOpRequest; export type SidebarUpMessage = | OpenChatMessage | NewProjectMessage | GetRootsMessage | GetChildrenMessage - | OpenFileMessage; + | OpenFileMessage + | FileOpMessage; // ── Combined union (for the bridge type) ───────────────────────────────────── @@ -59,6 +79,7 @@ export interface SidebarMessageHandlers { getRoots: () => TreeRoot[]; getChildren: (path: string) => Promise; openFile: (path: string) => void; + fileOp: (req: FileOpRequest) => Promise; postMessage: (msg: SidebarDownMessage) => void; } @@ -90,12 +111,26 @@ export function handleSidebarMessage( case "open-file": handlers.openFile(msg.path); break; + case "file-op": { + const { kind: _k, ...req } = msg; + return handlers.fileOp(req as FileOpRequest).then((result) => { + if (!result.ok) { + handlers.postMessage({ + kind: "file-op-error", + op: req.op, + path: req.path, + message: result.message ?? "Operation failed", + }); + } + }); + } case "chat-active": case "roots": case "children": case "fs-changed": - // Down-direction messages — no host-side handler needed (the webview - // consumes them). If received on the host side, ignore. + case "file-op-error": + case "active-project": + // Down-direction messages — no host-side handler needed. break; } } diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 009735ed..7b290ba1 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -10,7 +10,7 @@ import * as vscode from "vscode"; import * as path from "node:path"; import * as fs from "node:fs"; -import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage } from "./sidebar_bridge"; +import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage, type FileOpRequest, type FileOpResult } from "./sidebar_bridge"; import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service"; import { detectProjectType } from "./project/detect"; @@ -73,6 +73,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { const uri = vscode.Uri.file(p); void vscode.window.showTextDocument(uri); }, + fileOp: (req) => executeFileOp(req), postMessage: (m) => { void webviewView.webview.postMessage(m); }, @@ -298,3 +299,91 @@ function getNonce(): string { } return result; } + +// ── File operations (extension host, #676) ─────────────────────────────────── + +/** + * Execute a file operation dispatched from the webview context menu. + * All operations go through vscode.workspace.fs — the webview never touches + * the filesystem directly. Delete always uses useTrash: true. + */ +async function executeFileOp(req: FileOpRequest): Promise { + try { + const uri = vscode.Uri.file(req.path); + + switch (req.op) { + case "new-file": { + if (!req.name) return { ok: false, message: "No filename provided" }; + const newUri = vscode.Uri.joinPath(uri, req.name); + await vscode.workspace.fs.writeFile(newUri, new Uint8Array()); + void vscode.window.showTextDocument(newUri); + return { ok: true }; + } + case "new-folder": { + if (!req.name) return { ok: false, message: "No folder name provided" }; + const newUri = vscode.Uri.joinPath(uri, req.name); + await vscode.workspace.fs.createDirectory(newUri); + return { ok: true }; + } + case "rename": { + if (!req.newName) return { ok: false, message: "No new name provided" }; + const dir = vscode.Uri.file(path.dirname(req.path)); + const newUri = vscode.Uri.joinPath(dir, req.newName); + // Check for collision + try { + await vscode.workspace.fs.stat(newUri); + return { ok: false, message: `"${req.newName}" already exists` }; + } catch { + // Target doesn't exist — safe to rename + } + await vscode.workspace.fs.rename(uri, newUri); + return { ok: true }; + } + case "delete": { + // Always trash — the permanent delete path does not exist (#673 invariant) + await vscode.workspace.fs.delete(uri, { useTrash: true, recursive: true }); + return { ok: true }; + } + case "copy-path": { + await vscode.env.clipboard.writeText(uri.fsPath); + return { ok: true }; + } + case "copy-relative-path": { + const folder = vscode.workspace.getWorkspaceFolder(uri); + const rel = folder ? path.relative(folder.uri.fsPath, uri.fsPath) : uri.fsPath; + await vscode.env.clipboard.writeText(rel); + return { ok: true }; + } + case "reveal-in-os": { + await vscode.commands.executeCommand("revealFileInOS", uri); + return { ok: true }; + } + case "open-in-terminal": { + const terminal = vscode.window.createTerminal({ cwd: uri.fsPath }); + terminal.show(); + return { ok: true }; + } + case "open-to-side": { + await vscode.commands.executeCommand("vscode.open", uri, vscode.ViewColumn.Beside); + return { ok: true }; + } + case "remove-from-workspace": { + const folders = vscode.workspace.workspaceFolders ?? []; + const idx = folders.findIndex((f) => f.uri.fsPath === req.path); + if (idx >= 0) { + vscode.workspace.updateWorkspaceFolders(idx, 1); + } + return { ok: true }; + } + case "new-session": { + // Posts to the session creation flow — the project path is carried + void vscode.commands.executeCommand("amicode.newChat"); + return { ok: true }; + } + default: + return { ok: false, message: `Unknown operation: ${req.op}` }; + } + } catch (e) { + return { ok: false, message: (e as Error).message }; + } +} diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index d745ad55..6432422d 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -302,3 +302,101 @@ describe("SidebarTreeService", () => { expect(names).toEqual(["src", "main.ts"]); }); }); + +// ── File operations (#676) ─────────────────────────────────────────────────── + +describe("sidebar bridge — file operations", () => { + let handleSidebarMessage: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../src/sidebar_bridge"); + handleSidebarMessage = mod.handleSidebarMessage; + }); + + function makeHandlers(overrides: Record = {}) { + return { + openChat: vi.fn(), + newProject: vi.fn(), + getRoots: vi.fn(), + getChildren: vi.fn().mockResolvedValue([]), + openFile: vi.fn(), + postMessage: vi.fn(), + fileOp: vi.fn().mockResolvedValue({ ok: true }), + ...overrides, + }; + } + + it("file-op rename dispatches to fileOp handler", async () => { + const handlers = makeHandlers(); + await handleSidebarMessage( + { kind: "file-op", op: "rename", path: "/project/old.ts", newName: "new.ts" }, + handlers, + ); + expect(handlers.fileOp).toHaveBeenCalledWith( + expect.objectContaining({ op: "rename", path: "/project/old.ts", newName: "new.ts" }), + ); + }); + + it("file-op delete dispatches to fileOp handler", async () => { + const handlers = makeHandlers(); + await handleSidebarMessage( + { kind: "file-op", op: "delete", path: "/project/dead.ts" }, + handlers, + ); + expect(handlers.fileOp).toHaveBeenCalledWith( + expect.objectContaining({ op: "delete", path: "/project/dead.ts" }), + ); + }); + + it("file-op new-file dispatches to fileOp handler", async () => { + const handlers = makeHandlers(); + await handleSidebarMessage( + { kind: "file-op", op: "new-file", path: "/project/src", name: "hello.jl" }, + handlers, + ); + expect(handlers.fileOp).toHaveBeenCalledWith( + expect.objectContaining({ op: "new-file", path: "/project/src", name: "hello.jl" }), + ); + }); + + it("file-op new-folder dispatches to fileOp handler", async () => { + const handlers = makeHandlers(); + await handleSidebarMessage( + { kind: "file-op", op: "new-folder", path: "/project", name: "utils" }, + handlers, + ); + expect(handlers.fileOp).toHaveBeenCalledWith( + expect.objectContaining({ op: "new-folder", path: "/project", name: "utils" }), + ); + }); + + it("file-op copy-path dispatches to fileOp handler", async () => { + const handlers = makeHandlers(); + await handleSidebarMessage( + { kind: "file-op", op: "copy-path", path: "/project/src/main.ts" }, + handlers, + ); + expect(handlers.fileOp).toHaveBeenCalledWith( + expect.objectContaining({ op: "copy-path", path: "/project/src/main.ts" }), + ); + }); + + it("file-op error result posts file-op-error back", async () => { + const handlers = makeHandlers({ + fileOp: vi.fn().mockResolvedValue({ ok: false, message: "name collision" }), + }); + await handleSidebarMessage( + { kind: "file-op", op: "rename", path: "/project/old.ts", newName: "new.ts" }, + handlers, + ); + expect(handlers.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "file-op-error", + op: "rename", + path: "/project/old.ts", + message: "name collision", + }), + ); + }); +}); From 9cd432c0e0e6caa88783efbf7ddca6440443460a Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 17:20:33 -0400 Subject: [PATCH 04/34] feat(sidebar): session-aware project highlighting (#677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add session awareness to the sidebar project tree: - SidebarViewProvider.setActiveProject(path | null): pushes active-project message to the webview, deduplicates same path (initial state undefined, not null, so explicit null is always sent) - Webview handles active-project: highlights the matching root node with accent border + selection background, auto-expands it on highlight - Session switch moves highlight from old project to new - path: null → neutral state (no highlight, no error) - The sidebar follows sessions; it never drives session switching - The integration point for session→project binding is marked with a TODO in extension.ts (the session change listener wires here) Closes #677 --- packages/extension/src/sidebar_view.ts | 12 ++++ packages/extension/src/sidebar_webview.ts | 35 ++++++++++ packages/extension/test/sidebar_view.test.ts | 70 ++++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 7b290ba1..38a5af64 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -22,6 +22,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { private extensionUri: vscode.Uri; private view?: vscode.WebviewView; private chatActive = false; + private activeProjectPath: string | null | undefined = undefined; private watcher?: vscode.FileSystemWatcher; private workspaceSub?: vscode.Disposable; private treeService: SidebarTreeService; @@ -104,6 +105,17 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { } } + /** + * Set the active project path (from the active session's binding). + * Posts active-project to the webview for highlight + auto-expand. + * Pass null to clear (no session or no project binding). + */ + setActiveProject(projectPath: string | null): void { + if (this.activeProjectPath === projectPath) return; // deduplicate + this.activeProjectPath = projectPath; + this.postDown({ kind: "active-project", path: projectPath }); + } + private postDown(msg: SidebarDownMessage): void { this.view?.webview.postMessage(msg); } diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index 42df9dcd..c5c285ea 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -274,6 +274,41 @@ interface TreeEntry { } break; } + + case "active-project": { + // Update highlight: find all root nodes, toggle the "active" class + const activePath: string | null = msg.path; + const allRootNodes = treeRoot?.querySelectorAll("[data-path][data-type='directory']") ?? []; + for (const node of allRootNodes) { + const el = node as HTMLElement; + const nodePath = el.dataset.path; + const row = el.querySelector(".tree-node") as HTMLElement | null; + if (!row) continue; + + if (nodePath === activePath) { + row.style.borderLeft = "2px solid var(--vscode-focusBorder)"; + row.style.background = "var(--vscode-list-activeSelectionBackground)"; + // Auto-expand the active project root (not deeper) + if (!expanded[nodePath!]) { + expanded[nodePath!] = true; + saveExpandedState(); + const icon = row.querySelector(".icon") as HTMLElement | null; + if (icon) icon.textContent = "\u25BE"; + const childrenEl = el.querySelector(".children") as HTMLElement | null; + if (childrenEl) { + childrenEl.style.display = "block"; + if (!childrenCache[nodePath!]) { + vscode.postMessage({ kind: "get-children", path: nodePath }); + } + } + } + } else { + row.style.borderLeft = ""; + row.style.background = ""; + } + } + break; + } } }); diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 6432422d..35dc2bec 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -400,3 +400,73 @@ describe("sidebar bridge — file operations", () => { ); }); }); + +// ── Session-aware highlighting (#677) ──────────────────────────────────────── + +describe("SidebarViewProvider — session awareness", () => { + let SidebarViewProvider: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../src/sidebar_view"); + SidebarViewProvider = mod.SidebarViewProvider; + }); + + it("setActiveProject posts active-project message to the webview", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + provider.setActiveProject("/projects/quantum-sim"); + + expect(view.webview.postMessage).toHaveBeenCalledWith({ + kind: "active-project", + path: "/projects/quantum-sim", + }); + }); + + it("setActiveProject with null clears the highlight", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + provider.setActiveProject(null); + + expect(view.webview.postMessage).toHaveBeenCalledWith({ + kind: "active-project", + path: null, + }); + }); + + it("setActiveProject switches highlight from one project to another", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + provider.setActiveProject("/projects/quantum-sim"); + provider.setActiveProject("/projects/other"); + + const calls = (view.webview.postMessage as any).mock.calls; + const activeProjectCalls = calls.filter((c: any) => c[0]?.kind === "active-project"); + expect(activeProjectCalls).toHaveLength(2); + expect(activeProjectCalls[0][0].path).toBe("/projects/quantum-sim"); + expect(activeProjectCalls[1][0].path).toBe("/projects/other"); + }); + + it("setActiveProject deduplicates same path", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + provider.setActiveProject("/projects/quantum-sim"); + provider.setActiveProject("/projects/quantum-sim"); + + const calls = (view.webview.postMessage as any).mock.calls; + const activeProjectCalls = calls.filter((c: any) => c[0]?.kind === "active-project"); + expect(activeProjectCalls).toHaveLength(1); + }); +}); From 84f0f22ad389f552d4262c6ad91678a5b5f6a5f5 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 17:39:51 -0400 Subject: [PATCH 05/34] fix(test): update golden fixture for project type field The projects list response now includes a `type` field per project entry (from detectProjectType() in listProjectDirs, added on the research-projects base branch). Update the golden fixture to include `"type":"dev"` on both test projects so the contract test passes. --- packages/extension/test/fixtures/amicode/golden.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/test/fixtures/amicode/golden.json b/packages/extension/test/fixtures/amicode/golden.json index edd164b1..a089377f 100644 --- a/packages/extension/test/fixtures/amicode/golden.json +++ b/packages/extension/test/fixtures/amicode/golden.json @@ -885,7 +885,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"parentDir\":\"/tmp/amicode-fixture-f5CKEm/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/tmp/amicode-fixture-f5CKEm/AmicodeProjects/my-new-project\"},{\"slug\":\"prior-project\",\"path\":\"/tmp/amicode-fixture-f5CKEm/AmicodeProjects/prior-project\"}]}" + "body": "{\"ok\":true,\"parentDir\":\"/tmp/amicode-fixture-f5CKEm/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/tmp/amicode-fixture-f5CKEm/AmicodeProjects/my-new-project\",\"type\":\"dev\"},{\"slug\":\"prior-project\",\"path\":\"/tmp/amicode-fixture-f5CKEm/AmicodeProjects/prior-project\",\"type\":\"dev\"}]}" } ] } From 4b3b7c207e4897ac428a799c4a188e1cf90141d9 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 19:22:06 -0400 Subject: [PATCH 06/34] feat(sidebar): file icons, context menu fix, drag-drop, git colors, accordion sections - File icons: inline SVG colored by extension (60+ mappings), separate chevron/icon/label spans replacing the universal emoji - Context menu: fix path resolution for files (data-path on tree-node itself, not parent), replace window.prompt() with host-side showInputBox (prompt returns null in webview iframes) - Drag and drop: HTML5 drag/drop between folders, move-file bridge op, host-side fs.rename with collision detection - Git status: query vscode.git extension API, annotate TreeEntry with status, apply VS Code gitDecoration theme variables to labels - Accordion layout: flex-column sections with justify-content:flex-end so collapsed headers stack at the bottom, display:contents on tree-root so all sections are direct flex participants - Sash resize: draggable border between expanded sections, 1px yellow line on hover, global mousemove handler, reset on section toggle - Section headers: border-top separators (tight when collapsed), lighter + buttons --- packages/extension/src/sidebar_bridge.ts | 10 +- packages/extension/src/sidebar_view.ts | 400 ++++++++++++++- packages/extension/src/sidebar_webview.ts | 504 ++++++++++++++++++- packages/extension/test/sidebar_view.test.ts | 504 ++++++++++++++++++- 4 files changed, 1362 insertions(+), 56 deletions(-) diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts index 133624f1..5853ac1c 100644 --- a/packages/extension/src/sidebar_bridge.ts +++ b/packages/extension/src/sidebar_bridge.ts @@ -17,15 +17,17 @@ export interface TreeEntry { name: string; type: "file" | "directory"; path: string; + gitStatus?: "modified" | "added" | "deleted" | "untracked" | "ignored" | "conflict"; } // ── File operation types ───────────────────────────────────────────────────── export interface FileOpRequest { - op: "rename" | "delete" | "new-file" | "new-folder" | "copy-path" | "copy-relative-path" | "reveal-in-os" | "open-in-terminal" | "open-to-side" | "remove-from-workspace" | "new-session"; + op: "rename" | "delete" | "new-file" | "new-folder" | "copy-path" | "copy-relative-path" | "reveal-in-os" | "open-in-terminal" | "open-to-side" | "remove-from-workspace" | "new-session" | "move"; path: string; newName?: string; name?: string; + targetDir?: string; } export interface FileOpResult { @@ -54,6 +56,7 @@ export type SidebarDownMessage = export type OpenChatMessage = { kind: "open-chat" }; export type NewProjectMessage = { kind: "new-project" }; +export type AddExistingMessage = { kind: "add-existing" }; export type GetRootsMessage = { kind: "get-roots" }; export type GetChildrenMessage = { kind: "get-children"; path: string }; export type OpenFileMessage = { kind: "open-file"; path: string }; @@ -62,6 +65,7 @@ export type FileOpMessage = { kind: "file-op" } & FileOpRequest; export type SidebarUpMessage = | OpenChatMessage | NewProjectMessage + | AddExistingMessage | GetRootsMessage | GetChildrenMessage | OpenFileMessage @@ -76,6 +80,7 @@ export type SidebarMessage = SidebarUpMessage | SidebarDownMessage; export interface SidebarMessageHandlers { openChat: () => void; newProject: () => void; + addExisting: () => void; getRoots: () => TreeRoot[]; getChildren: (path: string) => Promise; openFile: (path: string) => void; @@ -99,6 +104,9 @@ export function handleSidebarMessage( case "new-project": handlers.newProject(); break; + case "add-existing": + handlers.addExisting(); + break; case "get-roots": { const roots = handlers.getRoots(); handlers.postMessage({ kind: "roots", roots }); diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 38a5af64..2c3aa93a 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -10,7 +10,7 @@ import * as vscode from "vscode"; import * as path from "node:path"; import * as fs from "node:fs"; -import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage, type FileOpRequest, type FileOpResult } from "./sidebar_bridge"; +import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage, type FileOpRequest, type FileOpResult, type TreeEntry } from "./sidebar_bridge"; import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service"; import { detectProjectType } from "./project/detect"; @@ -42,9 +42,13 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, - ): void { + ): void { this.view = webviewView; + // Clear the view-level title so VS Code shows only the container title ("AMICODE") + // rather than "AMICODE: AMICODE". + webviewView.title = ""; + webviewView.webview.options = { enableScripts: true, localResourceRoots: [ @@ -68,8 +72,12 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { const handlers: SidebarMessageHandlers = { openChat: () => vscode.commands.executeCommand("amicode.openChat"), newProject: () => vscode.commands.executeCommand("amicode.newProject"), + addExisting: () => addExistingProject(), getRoots: () => this.treeService.getRoots(), - getChildren: (p) => this.treeService.getChildren(p), + getChildren: async (p) => { + const entries = await this.treeService.getChildren(p); + return annotateGitStatus(entries); + }, openFile: (p) => { const uri = vscode.Uri.file(p); void vscode.window.showTextDocument(uri); @@ -153,6 +161,10 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { body { margin: 0; padding: 0; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-sideBarTitle-foreground, var(--vscode-foreground)); @@ -160,14 +172,16 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { } .sidebar-header { display: flex; + flex-direction: column; gap: 6px; padding: 8px 12px; + flex-shrink: 0; border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); } .sidebar-header button { - flex: 1; + width: 100%; padding: 5px 8px; - border: 1px solid var(--vscode-button-border, transparent); + border: none; border-radius: 4px; font-family: inherit; font-size: 12px; @@ -177,24 +191,81 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { text-overflow: ellipsis; } .btn-chat { - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); + background: #fff676; + color: #666; + border: none; + font-weight: 400; } .btn-chat:hover { - background: var(--vscode-button-hoverBackground); + background: #ffe94a; + } + .btn-chat:focus { + outline: none; + } + .btn-chat .btn-icon { + width: 14px; + height: 14px; + vertical-align: -2px; + margin-right: 4px; + } + .btn-chat .btn-icon rect, + .btn-chat .btn-icon polygon { + fill: #666; + } + .btn-chat .btn-icon path { + stroke: #aaa; } .btn-chat.muted { - opacity: 0.5; + background: transparent; + color: var(--vscode-foreground); + border: 1px solid #fff676; + opacity: 0.7; + } + .btn-chat.muted .btn-icon rect, + .btn-chat.muted .btn-icon polygon { + fill: #fff676; + } + .btn-chat.muted .btn-icon path { + stroke: #999; } .btn-new-project { - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); + background: transparent; + color: var(--vscode-foreground); + border: 1px solid #2B382B; + font-weight: 400; } .btn-new-project:hover { - background: var(--vscode-button-secondaryHoverBackground); + background: rgba(43, 56, 43, 0.15); + } + .btn-new-project:focus { + outline: none; } #tree-root { - padding: 4px 0; + display: contents; + } + /* ── Accordion sections ────────────────────────────────────── */ + .section { + display: flex; + flex-direction: column; + flex-shrink: 0; + min-height: 0; + } + .section.expanded { + flex: 1; + overflow: hidden; + } + .section-body { + flex: 1; + overflow-y: auto; + min-height: 0; + } + .sidebar-sections { + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + overflow: hidden; + min-height: 0; } .tree-node { display: flex; @@ -209,6 +280,15 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { .tree-node .indent { flex-shrink: 0; } + .tree-node .chevron { + width: 16px; + height: 16px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + } .tree-node .icon { width: 16px; height: 16px; @@ -217,7 +297,10 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { display: flex; align-items: center; justify-content: center; - font-size: 14px; + } + .tree-node .icon svg { + width: 16px; + height: 16px; } .tree-node .label { flex: 1; @@ -234,29 +317,183 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { color: var(--vscode-badge-foreground); flex-shrink: 0; } + /* ── Git status colors ─────────────────────────────────────── */ + .git-modified { color: var(--vscode-gitDecoration-modifiedResourceForeground, #e2c08d); } + .git-added { color: var(--vscode-gitDecoration-addedResourceForeground, #81b88b); } + .git-deleted { color: var(--vscode-gitDecoration-deletedResourceForeground, #c74e39); text-decoration: line-through; } + .git-untracked { color: var(--vscode-gitDecoration-untrackedResourceForeground, #73c991); } + .git-ignored { color: var(--vscode-gitDecoration-ignoredResourceForeground, #8c8c8c); opacity: 0.6; } + .git-conflict { color: var(--vscode-gitDecoration-conflictingResourceForeground, #e4676b); } + /* ── Drag and drop ─────────────────────────────────────────── */ + .tree-node.drop-target { + background: var(--vscode-list-dropBackground, rgba(83, 89, 93, 0.5)); + outline: 1px dashed var(--vscode-focusBorder); + } + .tree-node.dragging { + opacity: 0.4; + } + /* ── Sash (resize handle between sections) ─────────────────── */ + .sash { + height: 0; + position: relative; + flex-shrink: 0; + z-index: 1; + } + .sash::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: -3px; + height: 6px; + cursor: ns-resize; + } + .sash.inactive::after { + cursor: default; + pointer-events: none; + } + .sash:not(.inactive):hover::after, + .sash.active::after { + top: 0; + height: 1px; + background: #fff676; + } + body.sash-dragging { + cursor: ns-resize !important; + } + body.sash-dragging * { + user-select: none !important; + } .tree-section-label { + display: flex; + align-items: center; + cursor: pointer; + user-select: none; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; - padding: 8px 12px 4px; + padding: 6px 8px 4px 2px; + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-descriptionForeground)); font-weight: 600; + flex-shrink: 0; } - .fleet-placeholder { - padding: 8px 12px; + .tree-section-label:hover { + background: var(--vscode-list-hoverBackground); + } + .tree-section-label .section-chevron { + width: 16px; + height: 16px; + margin-right: 4px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + } + .tree-section-label .section-title { + flex: 1; + } + .tree-section-label .section-add-btn { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 14px; + font-weight: 300; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s; + } + .tree-section-label:hover .section-add-btn { + opacity: 1; + } + .section-add-btn:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(255,255,255,0.1)); + } + .fleet-section-label { + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 6px 8px 4px 2px; + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); + color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-descriptionForeground)); + font-weight: 600; + flex-shrink: 0; + } + .fleet-section-label:hover { + background: var(--vscode-list-hoverBackground); + } + .fleet-section-label .fleet-chevron { + width: 16px; + height: 16px; + margin-right: 4px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + } + .fleet-body { + padding: 8px 12px 8px 32px; font-size: 12px; color: var(--vscode-descriptionForeground); font-style: italic; } + .context-menu { + position: fixed; + z-index: 1000; + min-width: 180px; + background: var(--vscode-menu-background, var(--vscode-sideBar-background)); + border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border)); + border-radius: 4px; + padding: 4px 0; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + font-size: 12px; + } + .context-menu-item { + padding: 4px 12px; + cursor: pointer; + color: var(--vscode-menu-foreground, var(--vscode-foreground)); + white-space: nowrap; + } + .context-menu-item:hover { + background: var(--vscode-menu-selectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-menu-selectionForeground, var(--vscode-foreground)); + } + .context-menu-separator { + height: 1px; + margin: 4px 0; + background: var(--vscode-menu-separatorBackground, var(--vscode-panel-border)); + } -
-
Fleet — coming soon
+ `; @@ -312,12 +549,91 @@ function getNonce(): string { return result; } +/** Open a folder picker and add selected folder(s) to the workspace. */ +async function addExistingProject(): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: true, + openLabel: "Add to Workspace", + title: "Select project folder(s) to add", + }); + if (!uris || uris.length === 0) return; + + const existing = vscode.workspace.workspaceFolders ?? []; + const start = existing.length; + vscode.workspace.updateWorkspaceFolders( + start, + 0, + ...uris.map((uri) => ({ uri })), + ); +} + +// ── Git status annotation ──────────────────────────────────────────────────── + +/** Classify a numeric Git extension status into a simple category. */ +function classifyGitStatus(status: number): TreeEntry["gitStatus"] { + // Git extension Status enum: 0=INDEX_MODIFIED, 1=INDEX_ADDED, 2=INDEX_DELETED, + // 3=INDEX_RENAMED, 4=INDEX_COPIED, 5=MODIFIED, 6=DELETED, 7=UNTRACKED, + // 8=IGNORED, 9=INTENT_TO_ADD, 10..16=merge conflict variants + switch (status) { + case 0: case 3: case 4: case 5: return "modified"; + case 1: case 9: return "added"; + case 2: case 6: return "deleted"; + case 7: return "untracked"; + case 8: return "ignored"; + default: return "conflict"; + } +} + +/** + * Annotate tree entries with git status from the Git extension. + * Falls back gracefully if the git extension is unavailable. + */ +function annotateGitStatus(entries: TreeEntry[]): TreeEntry[] { + try { + const gitExt = vscode.extensions.getExtension("vscode.git"); + if (!gitExt?.isActive) return entries; + const api = gitExt.exports?.getAPI?.(1); + if (!api) return entries; + + // Build a path → status map from all repositories + const statusMap = new Map(); + for (const repo of api.repositories ?? []) { + const state = repo?.state; + if (!state) continue; + for (const change of state.workingTreeChanges ?? []) { + statusMap.set(change.uri.fsPath, classifyGitStatus(change.status)); + } + // Index changes (staged) — working tree takes precedence + for (const change of state.indexChanges ?? []) { + if (!statusMap.has(change.uri.fsPath)) { + statusMap.set(change.uri.fsPath, classifyGitStatus(change.status)); + } + } + } + + if (statusMap.size === 0) return entries; + + return entries.map((entry) => { + const gitStatus = statusMap.get(entry.path); + return gitStatus ? { ...entry, gitStatus } : entry; + }); + } catch { + return entries; + } +} + // ── File operations (extension host, #676) ─────────────────────────────────── /** * Execute a file operation dispatched from the webview context menu. * All operations go through vscode.workspace.fs — the webview never touches * the filesystem directly. Delete always uses useTrash: true. + * + * Operations that need user input (new-file, new-folder, rename) collect it + * via vscode.window.showInputBox on the host side — window.prompt() does not + * work in VS Code webview iframes. */ async function executeFileOp(req: FileOpRequest): Promise { try { @@ -325,32 +641,62 @@ async function executeFileOp(req: FileOpRequest): Promise { switch (req.op) { case "new-file": { - if (!req.name) return { ok: false, message: "No filename provided" }; - const newUri = vscode.Uri.joinPath(uri, req.name); + const name = req.name ?? await vscode.window.showInputBox({ + prompt: "File name", + placeHolder: "filename.ext", + }); + if (!name) return { ok: true }; // User cancelled + const newUri = vscode.Uri.joinPath(uri, name); await vscode.workspace.fs.writeFile(newUri, new Uint8Array()); void vscode.window.showTextDocument(newUri); return { ok: true }; } case "new-folder": { - if (!req.name) return { ok: false, message: "No folder name provided" }; - const newUri = vscode.Uri.joinPath(uri, req.name); + const name = req.name ?? await vscode.window.showInputBox({ + prompt: "Folder name", + placeHolder: "folder-name", + }); + if (!name) return { ok: true }; // User cancelled + const newUri = vscode.Uri.joinPath(uri, name); await vscode.workspace.fs.createDirectory(newUri); return { ok: true }; } case "rename": { - if (!req.newName) return { ok: false, message: "No new name provided" }; + const currentName = path.basename(req.path); + const dotIdx = currentName.lastIndexOf("."); + const selEnd = dotIdx > 0 ? dotIdx : currentName.length; + const newName = req.newName ?? await vscode.window.showInputBox({ + prompt: "Rename to:", + value: currentName, + valueSelection: [0, selEnd], + }); + if (!newName || newName === currentName) return { ok: true }; const dir = vscode.Uri.file(path.dirname(req.path)); - const newUri = vscode.Uri.joinPath(dir, req.newName); + const newUri = vscode.Uri.joinPath(dir, newName); // Check for collision try { await vscode.workspace.fs.stat(newUri); - return { ok: false, message: `"${req.newName}" already exists` }; + return { ok: false, message: `"${newName}" already exists` }; } catch { // Target doesn't exist — safe to rename } await vscode.workspace.fs.rename(uri, newUri); return { ok: true }; } + case "move": { + if (!req.targetDir) return { ok: false, message: "No target directory" }; + const sourceName = path.basename(req.path); + const targetUri = vscode.Uri.joinPath(vscode.Uri.file(req.targetDir), sourceName); + // Check for collision + try { + await vscode.workspace.fs.stat(targetUri); + return { ok: false, message: `"${sourceName}" already exists in target directory` }; + } catch { + // Target doesn't exist — safe to move + } + await vscode.workspace.fs.rename(uri, targetUri); + return { ok: true }; + } case "delete": { // Always trash — the permanent delete path does not exist (#673 invariant) await vscode.workspace.fs.delete(uri, { useTrash: true, recursive: true }); diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index c5c285ea..bdf2929e 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -19,8 +19,98 @@ interface TreeEntry { name: string; type: "file" | "directory"; path: string; + gitStatus?: string; } +// ── File icon color mapping ────────────────────────────────────────────────── + +const FILE_ICON_COLORS: Record = { + ts: "#3178c6", tsx: "#3178c6", mts: "#3178c6", cts: "#3178c6", + js: "#f1e05a", jsx: "#f1e05a", mjs: "#f1e05a", cjs: "#f1e05a", + jl: "#9558b2", + py: "#3572A5", pyi: "#3572A5", + json: "#e8d44d", jsonc: "#e8d44d", json5: "#e8d44d", + toml: "#9c4221", + yaml: "#cb171e", yml: "#cb171e", + md: "#519aba", mdx: "#519aba", + txt: "#8b8b8b", log: "#8b8b8b", csv: "#8b8b8b", + sh: "#89e051", bash: "#89e051", zsh: "#89e051", fish: "#89e051", + html: "#e34c26", htm: "#e34c26", + css: "#563d7c", scss: "#c6538c", less: "#1d365d", sass: "#c6538c", + rs: "#dea584", + go: "#00ADD8", + c: "#555555", h: "#555555", cpp: "#f34b7d", hpp: "#f34b7d", cc: "#f34b7d", + java: "#b07219", + rb: "#701516", + svg: "#ffb13b", + png: "#a074c4", jpg: "#a074c4", jpeg: "#a074c4", gif: "#a074c4", ico: "#a074c4", webp: "#a074c4", + pdf: "#db1818", + zip: "#afb42b", gz: "#afb42b", tar: "#afb42b", + lock: "#6d8086", + xml: "#e44d26", + sql: "#e38c00", + graphql: "#e10098", gql: "#e10098", + vue: "#41b883", + svelte: "#ff3e00", + r: "#198ce7", R: "#198ce7", + lua: "#000080", + zig: "#f69a1b", + nim: "#ffe953", + swift: "#f05138", + kt: "#A97BFF", kts: "#A97BFF", + dart: "#00B4AB", + ex: "#6e4a7e", exs: "#6e4a7e", + tf: "#5c4ee5", hcl: "#5c4ee5", + dockerfile: "#2496ed", +}; + +const EXACT_FILE_COLORS: Record = { + ".gitignore": "#f54d27", ".gitmodules": "#f54d27", ".gitattributes": "#f54d27", + ".env": "#ecd53f", ".env.local": "#ecd53f", + "Dockerfile": "#2496ed", "docker-compose.yml": "#2496ed", "docker-compose.yaml": "#2496ed", + "Makefile": "#6d8086", "CMakeLists.txt": "#6d8086", + "Cargo.toml": "#dea584", "Cargo.lock": "#dea584", + "package.json": "#e8d44d", "package-lock.json": "#6d8086", + "tsconfig.json": "#3178c6", "tsconfig.node.json": "#3178c6", + "Manifest.toml": "#9558b2", "Project.toml": "#9558b2", + "LICENSE": "#6d8086", "LICENSE.md": "#6d8086", + "README.md": "#519aba", "CHANGELOG.md": "#519aba", +}; + +function getFileIconColor(name: string): string { + if (EXACT_FILE_COLORS[name]) return EXACT_FILE_COLORS[name]; + const dot = name.lastIndexOf("."); + if (dot >= 0) { + const ext = name.slice(dot + 1).toLowerCase(); + if (FILE_ICON_COLORS[ext]) return FILE_ICON_COLORS[ext]; + } + return "#8b8b8b"; // default gray +} + +// ── SVG icon templates ─────────────────────────────────────────────────────── + +function fileIconSvg(color: string): string { + return `` + + `` + + `` + + ``; +} + +function folderClosedSvg(): string { + return `` + + `` + + ``; +} + +function folderOpenSvg(): string { + return `` + + `` + + `` + + ``; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + (function () { const vscode = acquireVsCodeApi(); @@ -46,12 +136,286 @@ interface TreeEntry { vscode.postMessage({ kind: "new-project" }); }); + // ── Fleet section toggle ────────────────────────────────────────────────── + + const fleetToggle = document.getElementById("fleet-toggle"); + const fleetChevron = document.getElementById("fleet-chevron"); + const fleetBody = document.getElementById("fleet-body"); + const fleetSection = document.getElementById("fleet-section"); + let fleetExpanded = false; + + fleetToggle?.addEventListener("click", () => { + fleetExpanded = !fleetExpanded; + if (fleetChevron) fleetChevron.textContent = fleetExpanded ? "\u25BE" : "\u25B8"; // ▾ / ▸ + if (fleetBody) fleetBody.style.display = fleetExpanded ? "block" : "none"; + fleetSection?.classList.toggle("expanded", fleetExpanded); + resetSectionSizes(); + }); + + // ── Context menu ────────────────────────────────────────────────────────── + + let activeMenu: HTMLElement | null = null; + + function dismissMenu(): void { + if (activeMenu) { + activeMenu.remove(); + activeMenu = null; + } + } + + document.addEventListener("click", dismissMenu); + document.addEventListener("contextmenu", (e) => { + // Only handle right-clicks on tree nodes (not buttons, fleet, etc.) + const target = e.target as HTMLElement; + const treeNode = target.closest(".tree-node") as HTMLElement | null; + if (!treeNode) return; + + e.preventDefault(); + dismissMenu(); + + // Resolve the data element — for files data-path is on the tree-node itself; + // for directories it's on the parent container wrapping .tree-node + .children. + const dataEl = treeNode.dataset.path ? treeNode : treeNode.parentElement; + const nodePath = dataEl?.dataset.path; + const nodeType = dataEl?.dataset.type; // "file" or "directory" + if (!nodePath) return; + + // Determine if this is a workspace root + const isRoot = currentRoots.some((r) => r.path === nodePath); + + // Build menu items + interface MenuItem { label: string; op?: string; separator?: boolean } + const items: MenuItem[] = []; + + if (nodeType === "directory") { + items.push({ label: "New File", op: "new-file" }); + items.push({ label: "New Folder", op: "new-folder" }); + items.push({ separator: true }); + } + + items.push({ label: "Rename", op: "rename" }); + items.push({ label: "Delete", op: "delete" }); + items.push({ separator: true }); + items.push({ label: "Copy Path", op: "copy-path" }); + items.push({ label: "Copy Relative Path", op: "copy-relative-path" }); + items.push({ separator: true }); + items.push({ label: "Reveal in Finder", op: "reveal-in-os" }); + items.push({ label: "Open in Terminal", op: "open-in-terminal" }); + + if (nodeType === "file") { + items.push({ label: "Open to the Side", op: "open-to-side" }); + } + + if (isRoot) { + items.push({ separator: true }); + items.push({ label: "Remove from Workspace", op: "remove-from-workspace" }); + } + + // Render menu + const menu = document.createElement("div"); + menu.className = "context-menu"; + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + + for (const item of items) { + if (item.separator) { + const sep = document.createElement("div"); + sep.className = "context-menu-separator"; + menu.appendChild(sep); + continue; + } + const el = document.createElement("div"); + el.className = "context-menu-item"; + el.textContent = item.label; + el.addEventListener("click", () => { + dismissMenu(); + // ALL operations delegate to the host — no window.prompt() in webviews + vscode.postMessage({ kind: "file-op", op: item.op, path: nodePath }); + }); + menu.appendChild(el); + } + + document.body.appendChild(menu); + activeMenu = menu; + + // Clamp to viewport bounds + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${window.innerWidth - rect.width - 4}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${window.innerHeight - rect.height - 4}px`; + } + }); + + // ── Drag and drop ───────────────────────────────────────────────────────── + + let dragSourcePath: string | null = null; + let currentDropTarget: HTMLElement | null = null; + + function clearDropTarget(): void { + if (currentDropTarget) { + currentDropTarget.classList.remove("drop-target"); + currentDropTarget = null; + } + } + + // ── Sash resize between sections ──────────────────────────────────────────── + + const MIN_SECTION_HEIGHT = 28; // ~section header height + const sidebarSections = document.querySelector(".sidebar-sections"); + let activeSash: { + sash: HTMLElement; + above: HTMLElement; + below: HTMLElement; + startY: number; + startAboveH: number; + startBelowH: number; + } | null = null; + + /** Collect all .section elements in visual order (tree-root has display:contents). */ + function getAllSections(): HTMLElement[] { + const sections: HTMLElement[] = []; + if (treeRoot) { + for (const child of Array.from(treeRoot.children)) { + if ((child as HTMLElement).classList?.contains("section")) { + sections.push(child as HTMLElement); + } + } + } + if (fleetSection) sections.push(fleetSection); + return sections; + } + + /** Remove old sashes and insert fresh ones between adjacent sections. */ + function updateSashes(): void { + document.querySelectorAll(".sash").forEach((s) => s.remove()); + const sections = getAllSections(); + for (let i = 0; i < sections.length - 1; i++) { + const above = sections[i]; + const below = sections[i + 1]; + const sash = document.createElement("div"); + sash.className = "sash"; + + // Active only when both neighbours are expanded + const setActive = () => { + const bothExpanded = above.classList.contains("expanded") && below.classList.contains("expanded"); + sash.classList.toggle("inactive", !bothExpanded); + }; + setActive(); + (sash as any)._setActive = setActive; + + sash.addEventListener("mousedown", (e) => { + if (sash.classList.contains("inactive")) return; + e.preventDefault(); + activeSash = { + sash, above, below, + startY: e.clientY, + startAboveH: above.getBoundingClientRect().height, + startBelowH: below.getBoundingClientRect().height, + }; + sash.classList.add("active"); + document.body.classList.add("sash-dragging"); + }); + + // Insert after the 'above' section in the DOM + above.parentElement!.insertBefore(sash, above.nextSibling); + } + } + + /** Clear explicit sizes so CSS flex:1 re-distributes equally; refresh sash states. */ + function resetSectionSizes(): void { + for (const el of getAllSections()) { + el.style.flex = ""; + } + document.querySelectorAll(".sash").forEach((s) => { + (s as any)._setActive?.(); + }); + } + + // Global mousemove / mouseup for sash dragging + document.addEventListener("mousemove", (e) => { + if (!activeSash) return; + const { above, below, startY, startAboveH, startBelowH } = activeSash; + const delta = e.clientY - startY; + const total = startAboveH + startBelowH; + const newAbove = Math.max(MIN_SECTION_HEIGHT, Math.min(startAboveH + delta, total - MIN_SECTION_HEIGHT)); + const newBelow = total - newAbove; + above.style.flex = `0 0 ${newAbove}px`; + below.style.flex = `0 0 ${newBelow}px`; + }); + + document.addEventListener("mouseup", () => { + if (!activeSash) return; + activeSash.sash.classList.remove("active"); + document.body.classList.remove("sash-dragging"); + activeSash = null; + }); + // ── Tree rendering ───────────────────────────────────────────────────────── let currentRoots: TreeRoot[] = []; // Cache children per directory path const childrenCache: Record = {}; + // Restore section expanded state (separate from tree node expanded state) + const sectionExpanded: Record = savedState?.expanded + ? { research: savedState.expanded["__section_research"] !== false, dev: savedState.expanded["__section_dev"] !== false } + : { research: true, dev: true }; + + function saveSectionState(): void { + expanded["__section_research"] = sectionExpanded.research; + expanded["__section_dev"] = sectionExpanded.dev; + saveExpandedState(); + } + + function renderSectionHeader(title: string, sectionKey: string): { section: HTMLElement; body: HTMLElement } { + const section = document.createElement("div"); + section.className = sectionExpanded[sectionKey] ? "section expanded" : "section"; + + const header = document.createElement("div"); + header.className = "tree-section-label"; + + const chevron = document.createElement("span"); + chevron.className = "section-chevron"; + chevron.textContent = sectionExpanded[sectionKey] ? "\u25BE" : "\u25B8"; // ▾ / ▸ + + const titleEl = document.createElement("span"); + titleEl.className = "section-title"; + titleEl.textContent = title; + + const addBtn = document.createElement("button"); + addBtn.className = "section-add-btn"; + addBtn.textContent = "+"; + addBtn.title = "Add existing project"; + addBtn.addEventListener("click", (e) => { + e.stopPropagation(); + vscode.postMessage({ kind: "add-existing" }); + }); + + header.appendChild(chevron); + header.appendChild(titleEl); + header.appendChild(addBtn); + + const body = document.createElement("div"); + body.className = "section-body"; + body.style.display = sectionExpanded[sectionKey] ? "block" : "none"; + + section.appendChild(header); + section.appendChild(body); + + header.addEventListener("click", () => { + sectionExpanded[sectionKey] = !sectionExpanded[sectionKey]; + saveSectionState(); + chevron.textContent = sectionExpanded[sectionKey] ? "\u25BE" : "\u25B8"; + body.style.display = sectionExpanded[sectionKey] ? "block" : "none"; + section.classList.toggle("expanded", sectionExpanded[sectionKey]); + resetSectionSizes(); + }); + + return { section, body }; + } + function renderRoots(roots: TreeRoot[]): void { if (!treeRoot) return; currentRoots = roots; @@ -62,24 +426,22 @@ interface TreeEntry { const dev = roots.filter((r) => r.projectType === "dev"); if (research.length > 0) { - const label = document.createElement("div"); - label.className = "tree-section-label"; - label.textContent = "Research Projects"; - treeRoot.appendChild(label); + const { section, body } = renderSectionHeader("Research Projects", "research"); for (const root of research) { - treeRoot.appendChild(renderRootNode(root, 0)); + body.appendChild(renderRootNode(root, 0)); } + treeRoot.appendChild(section); } if (dev.length > 0) { - const label = document.createElement("div"); - label.className = "tree-section-label"; - label.textContent = "Development"; - treeRoot.appendChild(label); + const { section, body } = renderSectionHeader("Development Projects", "dev"); for (const root of dev) { - treeRoot.appendChild(renderRootNode(root, 0)); + body.appendChild(renderRootNode(root, 0)); } + treeRoot.appendChild(section); } + + updateSashes(); } function renderRootNode(root: TreeRoot, depth: number): HTMLElement { @@ -91,16 +453,22 @@ interface TreeEntry { row.className = "tree-node"; row.style.paddingLeft = `${8 + depth * 16}px`; - // Chevron - const icon = document.createElement("span"); - icon.className = "icon"; - icon.textContent = expanded[root.path] ? "\u25BE" : "\u25B8"; // ▾ / ▸ + // Chevron (expand/collapse indicator) + const chevronEl = document.createElement("span"); + chevronEl.className = "chevron"; + chevronEl.textContent = expanded[root.path] ? "\u25BE" : "\u25B8"; // ▾ / ▸ + + // Folder icon + const iconEl = document.createElement("span"); + iconEl.className = "icon"; + iconEl.innerHTML = expanded[root.path] ? folderOpenSvg() : folderClosedSvg(); const label = document.createElement("span"); label.className = "label"; label.textContent = root.name; - row.appendChild(icon); + row.appendChild(chevronEl); + row.appendChild(iconEl); row.appendChild(label); // Research metadata pill @@ -113,6 +481,9 @@ interface TreeEntry { container.appendChild(row); + // Drag-and-drop: roots are drop targets + setupDirectoryDropTarget(row, root.path); + // Children container const childrenEl = document.createElement("div"); childrenEl.className = "children"; @@ -127,7 +498,8 @@ interface TreeEntry { row.addEventListener("click", () => { expanded[root.path] = !expanded[root.path]; saveExpandedState(); - icon.textContent = expanded[root.path] ? "\u25BE" : "\u25B8"; + chevronEl.textContent = expanded[root.path] ? "\u25BE" : "\u25B8"; + iconEl.innerHTML = expanded[root.path] ? folderOpenSvg() : folderClosedSvg(); childrenEl.style.display = expanded[root.path] ? "block" : "none"; if (expanded[root.path] && !childrenCache[root.path]) { @@ -163,19 +535,34 @@ interface TreeEntry { const row = document.createElement("div"); row.className = "tree-node"; row.style.paddingLeft = `${8 + depth * 16}px`; + row.draggable = true; + + // Chevron + const chevronEl = document.createElement("span"); + chevronEl.className = "chevron"; + chevronEl.textContent = expanded[entry.path] ? "\u25BE" : "\u25B8"; - const icon = document.createElement("span"); - icon.className = "icon"; - icon.textContent = expanded[entry.path] ? "\u25BE" : "\u25B8"; + // Folder icon + const iconEl = document.createElement("span"); + iconEl.className = "icon"; + iconEl.innerHTML = expanded[entry.path] ? folderOpenSvg() : folderClosedSvg(); const label = document.createElement("span"); label.className = "label"; label.textContent = entry.name; + if (entry.gitStatus) { + label.classList.add(`git-${entry.gitStatus}`); + } - row.appendChild(icon); + row.appendChild(chevronEl); + row.appendChild(iconEl); row.appendChild(label); container.appendChild(row); + // Drag-and-drop: directories are both draggable sources and drop targets + setupDragSource(row, entry.path); + setupDirectoryDropTarget(row, entry.path); + const childrenEl = document.createElement("div"); childrenEl.className = "children"; childrenEl.style.display = expanded[entry.path] ? "block" : "none"; @@ -188,7 +575,8 @@ interface TreeEntry { row.addEventListener("click", () => { expanded[entry.path] = !expanded[entry.path]; saveExpandedState(); - icon.textContent = expanded[entry.path] ? "\u25BE" : "\u25B8"; + chevronEl.textContent = expanded[entry.path] ? "\u25BE" : "\u25B8"; + iconEl.innerHTML = expanded[entry.path] ? folderOpenSvg() : folderClosedSvg(); childrenEl.style.display = expanded[entry.path] ? "block" : "none"; if (expanded[entry.path] && !childrenCache[entry.path]) { @@ -209,18 +597,31 @@ interface TreeEntry { row.dataset.path = entry.path; row.dataset.type = "file"; row.style.paddingLeft = `${8 + depth * 16}px`; + row.draggable = true; - const icon = document.createElement("span"); - icon.className = "icon"; - icon.textContent = "\u{1F4C4}"; // 📄 + // Spacer (same width as chevron, for alignment with directories) + const spacer = document.createElement("span"); + spacer.className = "chevron"; + + // File icon (colored by extension) + const iconEl = document.createElement("span"); + iconEl.className = "icon"; + iconEl.innerHTML = fileIconSvg(getFileIconColor(entry.name)); const label = document.createElement("span"); label.className = "label"; label.textContent = entry.name; + if (entry.gitStatus) { + label.classList.add(`git-${entry.gitStatus}`); + } - row.appendChild(icon); + row.appendChild(spacer); + row.appendChild(iconEl); row.appendChild(label); + // Drag source + setupDragSource(row, entry.path); + row.addEventListener("click", () => { vscode.postMessage({ kind: "open-file", path: entry.path }); }); @@ -228,6 +629,53 @@ interface TreeEntry { return row; } + // ── Drag-and-drop helpers ───────────────────────────────────────────────── + + function setupDragSource(el: HTMLElement, sourcePath: string): void { + el.addEventListener("dragstart", (e) => { + dragSourcePath = sourcePath; + el.classList.add("dragging"); + e.dataTransfer!.effectAllowed = "move"; + e.dataTransfer!.setData("text/plain", sourcePath); + }); + el.addEventListener("dragend", () => { + el.classList.remove("dragging"); + dragSourcePath = null; + clearDropTarget(); + }); + } + + function setupDirectoryDropTarget(el: HTMLElement, targetDir: string): void { + el.addEventListener("dragover", (e) => { + if (!dragSourcePath) return; + // Don't allow dropping on self or on a parent of the source + if (dragSourcePath === targetDir) return; + if (dragSourcePath.startsWith(targetDir + "/")) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = "move"; + if (currentDropTarget !== el) { + clearDropTarget(); + currentDropTarget = el; + el.classList.add("drop-target"); + } + }); + el.addEventListener("dragleave", (e) => { + // Only clear if we're really leaving (not entering a child) + const related = e.relatedTarget as HTMLElement | null; + if (related && el.contains(related)) return; + if (currentDropTarget === el) { + clearDropTarget(); + } + }); + el.addEventListener("drop", (e) => { + e.preventDefault(); + clearDropTarget(); + const sourcePath = e.dataTransfer?.getData("text/plain"); + if (!sourcePath || sourcePath === targetDir) return; + vscode.postMessage({ kind: "file-op", op: "move", path: sourcePath, targetDir }); + }); + } + // ── Host → Webview messages ──────────────────────────────────────────────── window.addEventListener("message", (event) => { @@ -292,8 +740,10 @@ interface TreeEntry { if (!expanded[nodePath!]) { expanded[nodePath!] = true; saveExpandedState(); - const icon = row.querySelector(".icon") as HTMLElement | null; - if (icon) icon.textContent = "\u25BE"; + const chevronSpan = row.querySelector(".chevron") as HTMLElement | null; + if (chevronSpan) chevronSpan.textContent = "\u25BE"; + const iconSpan = row.querySelector(".icon") as HTMLElement | null; + if (iconSpan) iconSpan.innerHTML = folderOpenSvg(); const childrenEl = el.querySelector(".children") as HTMLElement | null; if (childrenEl) { childrenEl.style.display = "block"; diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 35dc2bec..6ca59d54 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -9,7 +9,10 @@ function makeWebviewView() { const messageCbs: Array<(msg: unknown) => void> = []; const disposeCbs: Array<() => void> = []; let html = ""; + let title: string | undefined = undefined; return { + get title() { return title; }, + set title(v: string | undefined) { title = v; }, webview: { get html() { return html; }, set html(v: string) { html = v; }, @@ -63,6 +66,48 @@ describe("SidebarViewProvider", () => { expect(html).toContain("Chat with Amico"); expect(html).toContain("New Project"); }); + + it("clears the view-level title so VS Code shows just the container title", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + expect(view.title).toBe(""); + }); + + it("chat button: gray icon+text on solid yellow, yellow icon on muted, not bold; new-project: forest green outline, not bold", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + const html = view.webview.html; + + // ── Chat button (solid state) ── + // Solid yellow background, gray text (not #111), not bold + expect(html).toMatch(/\.btn-chat\s*\{[^}]*background:\s*#fff676/); + expect(html).toMatch(/\.btn-chat\s*\{[^}]*color:\s*#666/); + expect(html).toMatch(/\.btn-chat\s*\{[^}]*font-weight:\s*400/); + expect(html).toMatch(/\.btn-chat:focus\s*\{[^}]*outline:\s*none/); + // Icon shapes are overridden to gray on solid state + expect(html).toContain(".btn-chat .btn-icon rect"); + expect(html).toContain("fill: #666"); + // Chat-yellow SVG shape is present + expect(html).toContain(' { expect(configSrc).toContain("dist/sidebar_webview.js"); }); - it("package.json registers amicode.workspace as type webview", () => { + it("package.json registers amicode.workspace as type webview, container titled AMICODE", () => { const pkg = JSON.parse( readFileSync(resolve(__dirname, "..", "package.json"), "utf8"), ); @@ -123,6 +168,11 @@ describe("sidebar build pipeline", () => { const wsView = views.find((v: any) => v.id === "amicode.workspace"); expect(wsView).toBeDefined(); expect(wsView.type).toBe("webview"); + // Both container and view carry "AMICODE" so VS Code collapses to one title + const containers = pkg.contributes?.viewsContainers?.activitybar ?? []; + const container = containers.find((c: any) => c.id === "amicode"); + expect(container?.title).toBe("AMICODE"); + expect(wsView.name).toBe("AMICODE"); }); it("package.json has no viewsWelcome for amicode.workspace", () => { @@ -470,3 +520,455 @@ describe("SidebarViewProvider — session awareness", () => { expect(activeProjectCalls).toHaveLength(1); }); }); + +// ── Section labels and text (#673 polish) ──────────────────────────────────── + +describe("sidebar webview — section labels", () => { + let SidebarTreeService: any; + let SidebarViewProvider: any; + + beforeEach(async () => { + vi.resetModules(); + const treeMod = await import("../src/sidebar_tree_service"); + SidebarTreeService = treeMod.SidebarTreeService; + const viewMod = await import("../src/sidebar_view"); + SidebarViewProvider = viewMod.SidebarViewProvider; + }); + + it("dev section label reads 'Development Projects' not 'Development'", () => { + // The label is rendered in sidebar_webview.ts — verify tree service getRoots + // classifies dev roots, then the webview renders the correct label text. + // Since sidebar_webview is browser-only (no DOM in test), we verify the + // source contains the correct string literal. + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + expect(src).toContain('"Development Projects"'); + }); + + it("sections are collapsible with chevrons and a + button for add-existing", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Section headers have chevrons + expect(src).toContain("section-chevron"); + // Section headers have a + button that posts add-existing + expect(src).toContain("section-add-btn"); + expect(src).toContain("add-existing"); + // Sections track expanded/collapsed state + expect(src).toContain("sectionExpanded"); + }); + + it("section header CSS has collapsible styling with + button that appears on hover", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + const html = view.webview.html; + // Section label is a flex row with cursor: pointer + expect(html).toMatch(/\.tree-section-label\s*\{[^}]*cursor:\s*pointer/); + // + button is hidden by default, shown on hover + expect(html).toMatch(/\.section-add-btn[^{]*\{[^}]*opacity:\s*0/); + expect(html).toMatch(/\.tree-section-label:hover\s+\.section-add-btn[^{]*\{[^}]*opacity:\s*1/); + }); + + it("sections use accordion flex layout with border-top separators", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + const html = view.webview.html; + // Section labels use border-top (not margin-top) so collapsed headers are tight + expect(html).toMatch(/\.tree-section-label\s*\{[^}]*border-top:/); + expect(html).toMatch(/\.fleet-section-label\s*\{[^}]*border-top:/); + expect(html).not.toMatch(/\.tree-section-label\s*\{[^}]*margin-top:/); + // Accordion layout: body is full-height flex, sections flex-grow when expanded + expect(html).toContain("sidebar-sections"); + expect(html).toMatch(/\.section\.expanded\s*\{[^}]*flex:\s*1/); + expect(html).toMatch(/\.section-body\s*\{[^}]*overflow-y:\s*auto/); + }); + + it("fleet section is a collapsible section with 'Coming soon' body, not a static div", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + const html = view.webview.html; + // Has a fleet section label with the collapsible class + expect(html).toContain("fleet-section-label"); + // Contains Fleet text as a section header + expect(html).toMatch(/Fleet/); + // Contains "Coming soon" body text + expect(html).toContain("Coming soon"); + // Has a chevron for expand/collapse + expect(html).toContain("fleet-chevron"); + // No longer uses the old static fleet-placeholder + expect(html).not.toContain("fleet-placeholder"); + }); +}); + +// ── Context menu (#673 polish) ─────────────────────────────────────────────── + +describe("sidebar webview — context menu", () => { + let SidebarViewProvider: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../src/sidebar_view"); + SidebarViewProvider = mod.SidebarViewProvider; + }); + + it("sidebar_webview.ts intercepts contextmenu event and renders explorer-like items", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Prevents the default browser context menu on tree nodes + expect(src).toContain("contextmenu"); + expect(src).toContain("preventDefault"); + // Has explorer-like menu items + expect(src).toContain("New File"); + expect(src).toContain("New Folder"); + expect(src).toContain("Rename"); + expect(src).toContain("Delete"); + expect(src).toContain("Copy Path"); + expect(src).toContain("Copy Relative Path"); + expect(src).toContain("Reveal in Finder"); + expect(src).toContain("Open in Terminal"); + }); + + it("sidebar_view.ts CSS includes context-menu styling", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + const html = view.webview.html; + expect(html).toContain("context-menu"); + expect(html).toContain("context-menu-item"); + }); +}); + +// ── Add existing project (#673 polish) ─────────────────────────────────────── + +describe("sidebar — add existing project", () => { + let SidebarViewProvider: any; + let handleSidebarMessage: any; + + beforeEach(async () => { + vi.resetModules(); + const viewMod = await import("../src/sidebar_view"); + SidebarViewProvider = viewMod.SidebarViewProvider; + const bridgeMod = await import("../src/sidebar_bridge"); + handleSidebarMessage = bridgeMod.handleSidebarMessage; + }); + + it("section + button posts add-existing message to host", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // The + button in each section header posts add-existing + expect(src).toContain('kind: "add-existing"'); + expect(src).toContain("section-add-btn"); + }); + + it("header does NOT contain an add-existing button (moved to section headers)", () => { + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + const html = view.webview.html; + expect(html).not.toContain("btn-add-existing"); + }); + + it("bridge handles add-existing message", () => { + const addExisting = vi.fn(); + expect(() => + handleSidebarMessage({ kind: "add-existing" }, { + openChat: vi.fn(), + newProject: vi.fn(), + addExisting, + getRoots: vi.fn().mockReturnValue([]), + getChildren: vi.fn().mockResolvedValue([]), + openFile: vi.fn(), + fileOp: vi.fn().mockResolvedValue({ ok: true }), + postMessage: vi.fn(), + }) + ).not.toThrow(); + expect(addExisting).toHaveBeenCalled(); + }); +}); + +// ── File icons (#673 — colored SVG per extension) ──────────────────────────── + +describe("sidebar webview — file icons", () => { + it("webview uses inline SVG file icons colored by extension, not emoji", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // No more 📄 emoji + expect(src).not.toContain("\\u{1F4C4}"); + // Uses SVG-based file icons + expect(src).toContain("fileIconSvg"); + expect(src).toContain("folderClosedSvg"); + expect(src).toContain("folderOpenSvg"); + // Has a color mapping for known extensions + expect(src).toContain("FILE_ICON_COLORS"); + expect(src).toContain("getFileIconColor"); + }); + + it("icon color map covers common language extensions", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // TypeScript blue + expect(src).toMatch(/ts:\s*"#3178c6"/); + // JavaScript yellow + expect(src).toMatch(/js:\s*"#f1e05a"/); + // Julia purple + expect(src).toMatch(/jl:\s*"#9558b2"/); + // Python blue + expect(src).toMatch(/py:\s*"#3572A5"/); + // JSON yellow + expect(src).toMatch(/json:\s*"#e8d44d"/); + // TOML brown + expect(src).toMatch(/toml:\s*"#9c4221"/); + }); + + it("tree nodes have separate chevron and icon spans (not combined)", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Directory nodes: chevron + icon (folder SVG) + label + expect(src).toContain('chevronEl.className = "chevron"'); + expect(src).toContain('iconEl.className = "icon"'); + // File nodes: spacer + icon (file SVG) + label + expect(src).toContain('spacer.className = "chevron"'); + }); + + it("CSS has separate chevron and icon styles, icon holds SVG", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + expect(html).toMatch(/\.tree-node\s+\.chevron\s*\{/); + expect(html).toMatch(/\.tree-node\s+\.icon\s+svg\s*\{/); + }); +}); + +// ── Context menu fix (#673 — host-side showInputBox) ───────────────────────── + +describe("sidebar webview — context menu operations (end-to-end)", () => { + it("webview does NOT use window.prompt() — all input via host showInputBox", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // No prompt() assignment calls — old code had `const name = prompt(...)` which + // silently returns null in webview iframes. All input now collected host-side. + expect(src).not.toMatch(/=\s*prompt\s*\(/); + // Instead, all operations just post file-op directly to host + expect(src).toContain('kind: "file-op"'); + }); + + it("context menu resolves data-path from tree-node (files) or parent (dirs)", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // The fix: check treeNode.dataset.path first, then fall back to parentElement + expect(src).toContain("treeNode.dataset.path ? treeNode : treeNode.parentElement"); + }); + + it("bridge dispatches file-op move with targetDir to fileOp handler", async () => { + const { handleSidebarMessage } = await import("../src/sidebar_bridge"); + const fileOp = vi.fn().mockResolvedValue({ ok: true }); + const handlers = { + openChat: vi.fn(), newProject: vi.fn(), addExisting: vi.fn(), + getRoots: vi.fn().mockReturnValue([]), + getChildren: vi.fn().mockResolvedValue([]), + openFile: vi.fn(), postMessage: vi.fn(), fileOp, + }; + await handleSidebarMessage( + { kind: "file-op", op: "move", path: "/project/src/old.ts", targetDir: "/project/lib" }, + handlers, + ); + expect(fileOp).toHaveBeenCalledWith( + expect.objectContaining({ op: "move", path: "/project/src/old.ts", targetDir: "/project/lib" }), + ); + }); + + it("file-op new-file/rename without name/newName still dispatches (host prompts)", async () => { + const { handleSidebarMessage } = await import("../src/sidebar_bridge"); + const fileOp = vi.fn().mockResolvedValue({ ok: true }); + const handlers = { + openChat: vi.fn(), newProject: vi.fn(), addExisting: vi.fn(), + getRoots: vi.fn().mockReturnValue([]), + getChildren: vi.fn().mockResolvedValue([]), + openFile: vi.fn(), postMessage: vi.fn(), fileOp, + }; + // new-file without name — host will showInputBox + await handleSidebarMessage( + { kind: "file-op", op: "new-file", path: "/project/src" }, + handlers, + ); + expect(fileOp).toHaveBeenCalledWith( + expect.objectContaining({ op: "new-file", path: "/project/src" }), + ); + // rename without newName — host will showInputBox + await handleSidebarMessage( + { kind: "file-op", op: "rename", path: "/project/src/old.ts" }, + handlers, + ); + expect(fileOp).toHaveBeenCalledWith( + expect.objectContaining({ op: "rename", path: "/project/src/old.ts" }), + ); + }); +}); + +// ── Drag and drop (#673) ───────────────────────────────────────────────────── + +describe("sidebar webview — drag and drop", () => { + it("tree nodes are draggable and have drop-target styling", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // File and directory nodes are draggable + expect(src).toContain("row.draggable = true"); + // Drag/drop events are wired + expect(src).toContain("setupDragSource"); + expect(src).toContain("setupDirectoryDropTarget"); + // Drop posts a move file-op + expect(src).toContain('op: "move"'); + expect(src).toContain("targetDir"); + }); + + it("CSS includes drag-and-drop visual feedback styles", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + expect(html).toContain("drop-target"); + expect(html).toContain("dragging"); + expect(html).toMatch(/\.tree-node\.drop-target\s*\{/); + expect(html).toMatch(/\.tree-node\.dragging\s*\{/); + }); + + it("FileOpRequest type includes move op with targetDir", async () => { + const bridge = await import("../src/sidebar_bridge"); + // Type check: constructing a move request should be valid + const req: typeof bridge.FileOpRequest extends never ? never : any = { + op: "move" as const, + path: "/src/a.ts", + targetDir: "/lib", + }; + expect(req.op).toBe("move"); + expect(req.targetDir).toBe("/lib"); + }); +}); + +// ── Git status colors (#673) ───────────────────────────────────────────────── + +describe("sidebar — git status colors", () => { + it("TreeEntry type supports gitStatus field", async () => { + const bridge = await import("../src/sidebar_bridge"); + // Type check: constructing entry with gitStatus + const entry: typeof bridge.TreeEntry extends never ? never : any = { + name: "main.ts", + type: "file" as const, + path: "/p/main.ts", + gitStatus: "modified", + }; + expect(entry.gitStatus).toBe("modified"); + }); + + it("CSS includes git status color classes using VS Code theme variables", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + expect(html).toContain(".git-modified"); + expect(html).toContain(".git-added"); + expect(html).toContain(".git-deleted"); + expect(html).toContain(".git-untracked"); + expect(html).toContain(".git-ignored"); + expect(html).toContain(".git-conflict"); + // Uses VS Code theme variables, not hardcoded colors + expect(html).toContain("--vscode-gitDecoration-modifiedResourceForeground"); + expect(html).toContain("--vscode-gitDecoration-untrackedResourceForeground"); + }); + + it("webview applies git status CSS classes to file labels", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Label gets git-* class from entry.gitStatus + expect(src).toContain("entry.gitStatus"); + expect(src).toMatch(/label\.classList\.add.*git-/); + }); + + it("sidebar_view.ts annotates children with git status from the Git extension", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_view.ts"), + "utf8", + ); + // Accesses the Git extension API + expect(src).toContain('getExtension("vscode.git")'); + // Annotates entries with git status + expect(src).toContain("annotateGitStatus"); + expect(src).toContain("classifyGitStatus"); + // Walks workingTreeChanges and indexChanges + expect(src).toContain("workingTreeChanges"); + expect(src).toContain("indexChanges"); + }); +}); + +// ── Sash resize between sections (#673) ────────────────────────────────────── + +describe("sidebar — sash resize between sections", () => { + it("CSS has sash styles with VS Code sash hover color and ns-resize cursor", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + expect(html).toMatch(/\.sash/); + expect(html).toContain("ns-resize"); + expect(html).toContain("#fff676"); + expect(html).toContain("sash-dragging"); + }); + + it("webview creates sashes between sections and handles resize via mousemove", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Sashes are inserted between sections after rendering + expect(src).toContain("updateSashes"); + // Resize handler redistributes heights + expect(src).toContain("activeSash"); + expect(src).toContain("mousemove"); + expect(src).toContain("mouseup"); + // Section toggle resets sizes so flex:1 takes over + expect(src).toContain("resetSectionSizes"); + }); +}); From 65bbfb2e7783424650e91f5e162227446761b3ae Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 19:22:13 -0400 Subject: [PATCH 07/34] fix(creds): downgrade model/provider mismatch to soft warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveLlmCreds() no longer hard-fails when the model name doesn't match the provider — it logs a warning and proceeds. Fixes the stale opencode.json credential-gate bug. --- packages/extension/package.json | 4 ++-- packages/extension/src/extension.ts | 9 ++++++++- packages/extension/src/llm_creds.mjs | 15 ++++++++++----- packages/extension/test/llm_creds.test.ts | 20 +++++++++++++------- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index cb72a413..9d3bb4a5 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -50,7 +50,7 @@ "activitybar": [ { "id": "amicode", - "title": "Amicode", + "title": "AMICODE", "icon": "media/amico_reduced.svg" } ] @@ -59,7 +59,7 @@ "amicode": [ { "id": "amicode.workspace", - "name": "Workspace", + "name": "AMICODE", "type": "webview" } ] diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 909e78fe..f8bb5d86 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -900,6 +900,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ? `[boot] LLM provider: configured (${sig.provider}${sig.source ? ` via ${sig.source}` : ""})` : `[boot] LLM provider: ${sig.reason} → ${sig.fix}`, ); + if (sig.ok && sig.warning) { + opencodeChannel.appendLine(`[boot] LLM provider warning: ${sig.warning}`); + } }); }); @@ -1616,12 +1619,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // providers, so a missing credential would otherwise sail past the ready // check and silently hang at the chat box (Q129). Ask opencode's own live // resolution (/config/providers, same signal the healthcheck uses) so the - // cause is named, not hidden. Key-free. + // cause is named, not hidden. Key-free. A model/provider mismatch is a + // soft warning (ok:true + warning) — the chat still opens. const creds = await fetchProviderSignal(readyUrl.toString(), { headers: serverAuthHeaders }); if (!creds.ok) { vscode.window.showWarningMessage(`Amicode: ${creds.reason} → ${creds.fix}`); return; } + if (creds.warning) { + opencodeChannel.appendLine(`[openChat] ${creds.warning}`); + } ChatPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); }), // Side-by-side sessions: ALWAYS a fresh editor tab (ViewColumn.Beside, so diff --git a/packages/extension/src/llm_creds.mjs b/packages/extension/src/llm_creds.mjs index 2403345d..39549f1d 100644 --- a/packages/extension/src/llm_creds.mjs +++ b/packages/extension/src/llm_creds.mjs @@ -36,16 +36,21 @@ export function resolveLlmCreds({ providers, model }) { }; } // A configured model selects which provider chat uses; if set, that provider - // must be among the resolved ones (else chat picks an unresolved provider and - // fails at the box — the mismatch case). + // must be among the resolved ones. When it isn't, this is a SOFT mismatch — + // other providers ARE live, so the chat can still work (opencode falls back or + // the in-app picker overrides). Blocking the panel on a stale model field + // (e.g. onboarding wrote "anthropic/..." but the user later connected + // amazon-bedrock) is worse than letting them in with a heads-up. if (typeof model === "string" && model.includes("/")) { const want = model.split("/")[0]; const hit = list.find((p) => p.id === want); if (!hit) { + const first = list[0]; return { - ok: false, - reason: `opencode model provider "${want}" has no resolved credentials (resolved: ${list.map((p) => p.id).join(", ")})`, - fix: "set creds for that provider, or point the opencode model at a resolved one (RUNBOOK §4)", + ok: true, + provider: first.id, + source: first.source, + warning: `opencode model provider "${want}" has no resolved credentials (resolved: ${list.map((p) => p.id).join(", ")}) — falling back to ${first.id}`, }; } return { ok: true, provider: hit.id, source: hit.source }; diff --git a/packages/extension/test/llm_creds.test.ts b/packages/extension/test/llm_creds.test.ts index 355df6c9..d614076e 100644 --- a/packages/extension/test/llm_creds.test.ts +++ b/packages/extension/test/llm_creds.test.ts @@ -29,15 +29,17 @@ describe("resolveLlmCreds — pure signal from opencode-resolved providers", () }); expect(r).toMatchObject({ ok: true, provider: "anthropic", source: "env" }); }); - it("mismatch → explicit fail when the model points at an unresolved provider", () => { + it("mismatch → ok with warning when the model points at an unresolved provider (falls back to first resolved)", () => { const r = resolveLlmCreds({ providers: [{ id: "anthropic", source: "env" }], model: "amazon-bedrock/us.anthropic.claude-sonnet-4-6", }); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.reason).toMatch(/amazon-bedrock/); - expect(r.reason).toMatch(/no resolved credentials|resolved:/i); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.provider).toBe("anthropic"); + expect(r.source).toBe("env"); + expect(r.warning).toMatch(/amazon-bedrock/); + expect(r.warning).toMatch(/falling back/i); } }); it('ignores a model with no provider prefix (falls back to "any resolved")', () => { @@ -94,13 +96,17 @@ describe("fetchProviderSignal — async, against a stubbed opencode server", () expect(sig.ok).toBe(false); if (!sig.ok) expect(sig.reason).toMatch(/not configured/i); }); - it("mismatch surfaces through the async path too", async () => { + it("mismatch is a soft warning through the async path too (chat still opens)", async () => { const fetchImpl = stub({ "/config/providers": { providers: [{ id: "anthropic", source: "env" }] }, "/config": { model: "amazon-bedrock/x" }, }); const sig = await fetchProviderSignal("http://127.0.0.1:9", { fetchImpl }); - expect(sig.ok).toBe(false); + expect(sig.ok).toBe(true); + if (sig.ok) { + expect(sig.provider).toBe("anthropic"); + expect(sig.warning).toMatch(/amazon-bedrock/); + } }); it("not-ok (not a throw) when /config/providers is unreachable", async () => { const fetchImpl = (async () => { From 8b1cc277b02276baf3e178b561e37486fe29884f Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 21:00:04 -0400 Subject: [PATCH 08/34] fix(sidebar): use VS Code icon theme, fix file/folder indent alignment Replace custom inline SVG icons with VS Code's active file icon theme (Seti font-based + Material SVG-based). Resolves languageIds, light variants, case-insensitive fileNames. Propagate git status to dirs. Remove 16px spacer from file rows so the icon sits where the chevron would, aligning file and folder labels at the same depth. --- packages/extension/src/sidebar_view.ts | 326 ++++++++++++++++++- packages/extension/src/sidebar_webview.ts | 163 ++++------ packages/extension/test/__mocks__/vscode.ts | 4 + packages/extension/test/sidebar_view.test.ts | 303 ++++++++++++++--- 4 files changed, 644 insertions(+), 152 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 2c3aa93a..66956fa6 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -14,6 +14,265 @@ import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMess import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service"; import { detectProjectType } from "./project/detect"; +// ── Icon theme resolution ──────────────────────────────────────────────────── + +/** Data structure passed to the webview for icon rendering. */ +export interface IconThemeData { + mode: "font" | "svg" | "none"; + /** CSS to inject (@font-face + icon classes for font themes, empty otherwise). */ + css: string; + /** File extension → icon identifier (CSS class for font, webview URI for svg). */ + fileExtensions: Record; + /** Exact file name → icon identifier. */ + fileNames: Record; + /** Default folder icon identifier. */ + folder: string; + /** Expanded folder icon identifier. */ + folderExpanded: string; + /** Default file icon identifier. */ + defaultFile: string; +} + +/** + * Pure function: parse a VS Code icon theme JSON and produce an IconThemeData. + * + * @param themeJson Parsed icon theme contribution JSON (or null). + * @param basePath Directory containing the theme JSON (for resolving relative paths). + * @param resolveUri Converts an absolute file path to a webview-loadable URI string. + * @param langExtMap Optional mapping from file extension (no dot) to VS Code language ID, + * built from vscode.extensions.all[*].contributes.languages. + * Enables the languageIds icon resolution path that Seti relies on. + * @param colorThemeKind "light" | "dark" (default "dark"). When "light", overlays the + * theme's `light` section onto the base mappings. + */ +export function buildIconMap( + themeJson: any, + basePath: string, + resolveUri: (absolutePath: string) => string, + langExtMap?: Record, + colorThemeKind?: "light" | "dark", +): IconThemeData { + const empty: IconThemeData = { + mode: "none", css: "", + fileExtensions: {}, fileNames: {}, + folder: "", folderExpanded: "", defaultFile: "", + }; + if (!themeJson || typeof themeJson !== "object") return empty; + + // Merge the light variant overrides when on a light theme + let effective = themeJson; + if (colorThemeKind === "light" && themeJson.light) { + const lt = themeJson.light; + effective = { + ...themeJson, + file: lt.file ?? themeJson.file, + folder: lt.folder ?? themeJson.folder, + folderExpanded: lt.folderExpanded ?? themeJson.folderExpanded, + fileExtensions: { ...themeJson.fileExtensions, ...lt.fileExtensions }, + fileNames: { ...themeJson.fileNames, ...lt.fileNames }, + languageIds: { ...themeJson.languageIds, ...lt.languageIds }, + }; + } + + const defs: Record = themeJson.iconDefinitions ?? {}; + const isFontTheme = Array.isArray(themeJson.fonts) && themeJson.fonts.length > 0; + + if (isFontTheme) { + return buildFontIconMap(effective, basePath, resolveUri, defs, langExtMap); + } + // SVG-based theme + return buildSvgIconMap(effective, basePath, resolveUri, defs, langExtMap); +} + +function buildFontIconMap( + themeJson: any, basePath: string, + resolveUri: (p: string) => string, + defs: Record, + langExtMap?: Record, +): IconThemeData { + const font = themeJson.fonts[0]; + const fontSrc = font.src?.[0]; + if (!fontSrc) return { mode: "none", css: "", fileExtensions: {}, fileNames: {}, folder: "", folderExpanded: "", defaultFile: "" }; + + const fontUri = resolveUri(path.resolve(basePath, fontSrc.path)); + const fontSize = font.size ?? "150%"; + const fontId = font.id ?? "icon-theme-font"; + + // Build @font-face + base class + let css = `@font-face { font-family: "${fontId}"; src: url("${fontUri}") format("${fontSrc.format ?? "woff"}"); font-weight: normal; font-style: normal; }\n`; + css += `.theme-icon { font-family: "${fontId}"; font-size: ${fontSize}; -webkit-font-smoothing: antialiased; }\n`; + + // Build per-definition CSS classes + const classMap: Record = {}; // defName -> CSS class + for (const [defName, def] of Object.entries(defs)) { + if (!def?.fontCharacter) continue; + const cls = `thi-${defName.replace(/^_/, "")}`; + classMap[defName] = cls; + css += `.${cls}::before { content: "${def.fontCharacter}"; color: ${def.fontColor ?? "inherit"}; }\n`; + } + + // Map file extensions + names to CSS classes + const fileExtensions: Record = {}; + for (const [ext, defName] of Object.entries(themeJson.fileExtensions ?? {})) { + if (classMap[defName as string]) fileExtensions[ext] = classMap[defName as string]; + } + // languageIds resolution: ext → langId → defName → CSS class (lower priority) + const langIcons: Record = themeJson.languageIds ?? {}; + if (langExtMap) { + for (const [ext, langId] of Object.entries(langExtMap)) { + if (fileExtensions[ext]) continue; // direct mapping wins + const defName = langIcons[langId]; + if (defName && classMap[defName]) fileExtensions[ext] = classMap[defName]; + } + } + const fileNames: Record = {}; + for (const [name, defName] of Object.entries(themeJson.fileNames ?? {})) { + if (classMap[defName as string]) { + const cls = classMap[defName as string]; + fileNames[name] = cls; + // Case-insensitive variants (Seti uses lowercase; real files vary) + const lower = name.toLowerCase(); + const upper = name.toUpperCase(); + // "readme.md" → "README.MD" won't match, but common patterns: + // "readme.md" → "README.md" (uppercase base, keep extension case) + const dot = name.lastIndexOf("."); + const ext = dot >= 0 ? name.slice(dot) : ""; + const base = dot >= 0 ? name.slice(0, dot) : name; + const upperBase = base.toUpperCase() + ext; + if (!fileNames[lower]) fileNames[lower] = cls; + if (!fileNames[upper]) fileNames[upper] = cls; + if (!fileNames[upperBase]) fileNames[upperBase] = cls; + } + } + + return { + mode: "font", + css, + fileExtensions, + fileNames, + folder: classMap[themeJson.folder] ?? "", + folderExpanded: classMap[themeJson.folderExpanded] ?? "", + defaultFile: classMap[themeJson.file] ?? "", + }; +} + +function buildSvgIconMap( + themeJson: any, basePath: string, + resolveUri: (p: string) => string, + defs: Record, + langExtMap?: Record, +): IconThemeData { + // Resolve definition name → webview URI + const uriMap: Record = {}; + for (const [defName, def] of Object.entries(defs)) { + if (!(def as any)?.iconPath) continue; + uriMap[defName] = resolveUri(path.resolve(basePath, (def as any).iconPath)); + } + + const fileExtensions: Record = {}; + for (const [ext, defName] of Object.entries(themeJson.fileExtensions ?? {})) { + if (uriMap[defName as string]) fileExtensions[ext] = uriMap[defName as string]; + } + // languageIds resolution (lower priority) + const langIcons: Record = themeJson.languageIds ?? {}; + if (langExtMap) { + for (const [ext, langId] of Object.entries(langExtMap)) { + if (fileExtensions[ext]) continue; + const defName = langIcons[langId]; + if (defName && uriMap[defName]) fileExtensions[ext] = uriMap[defName]; + } + } + const fileNames: Record = {}; + for (const [name, defName] of Object.entries(themeJson.fileNames ?? {})) { + if (uriMap[defName as string]) { + const uri = uriMap[defName as string]; + fileNames[name] = uri; + const lower = name.toLowerCase(); + const upper = name.toUpperCase(); + const dot = name.lastIndexOf("."); + const ext = dot >= 0 ? name.slice(dot) : ""; + const base = dot >= 0 ? name.slice(0, dot) : name; + const upperBase = base.toUpperCase() + ext; + if (!fileNames[lower]) fileNames[lower] = uri; + if (!fileNames[upper]) fileNames[upper] = uri; + if (!fileNames[upperBase]) fileNames[upperBase] = uri; + } + } + + return { + mode: "svg", + css: "", + fileExtensions, + fileNames, + folder: uriMap[themeJson.folder] ?? "", + folderExpanded: uriMap[themeJson.folderExpanded] ?? "", + defaultFile: uriMap[themeJson.file] ?? "", + }; +} + +/** + * Build a file-extension → language-ID map from VS Code's installed extensions. + * Used to resolve the icon theme's `languageIds` section (e.g. .jl → julia → _julia). + */ +export function buildLangExtMap(extensionList: any[]): Record { + const map: Record = {}; + for (const ext of extensionList ?? []) { + const languages: any[] = ext.packageJSON?.contributes?.languages ?? []; + for (const lang of languages) { + const langId = lang.id; + if (!langId) continue; + for (const fileExt of lang.extensions ?? []) { + const clean = fileExt.replace(/^\./, ""); + if (clean && !map[clean]) map[clean] = langId; + } + } + } + return map; +} + +/** + * Read the active VS Code file icon theme and produce an IconThemeData. + * Returns mode "none" if the theme can't be read (graceful fallback). + */ +function resolveIconTheme(webview: vscode.Webview): { data: IconThemeData; rootUri?: vscode.Uri } { + const none = { data: buildIconMap(null, "", (p) => p) }; + try { + const themeId = vscode.workspace.getConfiguration("workbench").get("iconTheme"); + if (!themeId) return none; + + // Build the language-extension map once for all icon resolution + const langExtMap = buildLangExtMap(vscode.extensions.all as any[]); + + for (const ext of vscode.extensions.all ?? []) { + const themes: any[] | undefined = ext.packageJSON?.contributes?.iconThemes; + if (!themes) continue; + const theme = themes.find((t: any) => t.id === themeId); + if (!theme?.path) continue; + + const themeJsonPath = path.resolve(ext.extensionPath, theme.path); + const themeJson = JSON.parse(fs.readFileSync(themeJsonPath, "utf8")); + const basePath = path.dirname(themeJsonPath); + const rootUri = vscode.Uri.file(basePath); + + // Detect light vs dark color theme + const themeKind = vscode.window.activeColorTheme?.kind; + // ColorThemeKind: 1=Light, 2=Dark, 3=HighContrast, 4=HighContrastLight + const colorThemeKind: "light" | "dark" = (themeKind === 1 || themeKind === 4) ? "light" : "dark"; + + const data = buildIconMap( + themeJson, basePath, + (p) => webview.asWebviewUri(vscode.Uri.file(p)).toString(), + langExtMap, + colorThemeKind, + ); + return { data, rootUri }; + } + } catch { + // Graceful fallback — sidebar works without icons + } + return none; +} + /** * Provides the sidebar webview for the Amicode workspace panel. * Registered as `amicode.workspace` (type: "webview" in package.json). @@ -49,12 +308,17 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { // rather than "AMICODE: AMICODE". webviewView.title = ""; + // Resolve the active file icon theme for file/folder icons in the tree. + const iconTheme = resolveIconTheme(webviewView.webview); + const localRoots = [ + vscode.Uri.joinPath(this.extensionUri, "dist"), + vscode.Uri.joinPath(this.extensionUri, "media"), + ]; + if (iconTheme.rootUri) localRoots.push(iconTheme.rootUri); + webviewView.webview.options = { enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, "dist"), - vscode.Uri.joinPath(this.extensionUri, "media"), - ], + localResourceRoots: localRoots, }; // CSP nonce — regenerated per resolve (not cached). @@ -65,7 +329,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { vscode.Uri.joinPath(this.extensionUri, "dist", "sidebar_webview.js"), ); - webviewView.webview.html = this.buildHtml(webviewView.webview, nonce, scriptUri); + webviewView.webview.html = this.buildHtml(webviewView.webview, nonce, scriptUri, iconTheme.data); // Wire up the bridge: webview → host messages. webviewView.webview.onDidReceiveMessage((msg) => { @@ -148,16 +412,19 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { webview: vscode.Webview, nonce: string, scriptUri: string | { toString(): string }, + iconTheme: IconThemeData, ): string { const cspSource = webview.cspSource; + const iconThemeJson = JSON.stringify(iconTheme); return /* html */ ` + content="default-src 'none'; script-src 'nonce-${nonce}'; style-src ${cspSource} 'unsafe-inline'; img-src ${cspSource}; font-src ${cspSource};"> diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index 38c54d04..cc0a4992 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -129,6 +129,177 @@ function createIconEl(icon: string): HTMLElement { resetSectionSizes(); }); + // ── Inline editing (VS Code explorer-style) ──────────────────────────────── + + let activeInlineEdit: { + input: HTMLInputElement; + mode: "rename" | "new-file" | "new-folder"; + path: string; + originalLabel?: string; // for rename: the label text before editing + labelEl?: HTMLElement; // for rename: the original .label span + tempRow?: HTMLElement; // for new-file/new-folder: the temporary row + committed?: boolean; // set to true when Enter fires, prevents blur from double-cancelling + } | null = null; + + function cancelInlineEdit(): void { + if (!activeInlineEdit) return; + const edit = activeInlineEdit; + activeInlineEdit = null; + + if (edit.mode === "rename" && edit.labelEl && edit.originalLabel != null) { + // Restore the original label + edit.labelEl.textContent = edit.originalLabel; + edit.labelEl.style.display = ""; + edit.input.remove(); + } else if (edit.tempRow) { + // Remove the temporary row + edit.tempRow.remove(); + } + } + + function commitInlineEdit(): void { + if (!activeInlineEdit) return; + const edit = activeInlineEdit; + const value = edit.input.value.trim(); + + // Validation: reject empty names and path separators + if (value.length === 0 || value.includes("/") || value.includes("\\")) { + edit.input.classList.add("inline-error"); + edit.input.focus(); + return; + } + + edit.committed = true; + + if (edit.mode === "rename") { + vscode.postMessage({ kind: "file-op", op: "rename", path: edit.path, newName: value }); + } else if (edit.mode === "new-file") { + vscode.postMessage({ kind: "file-op", op: "new-file", path: edit.path, name: value }); + } else if (edit.mode === "new-folder") { + vscode.postMessage({ kind: "file-op", op: "new-folder", path: edit.path, name: value }); + } + } + + function startInlineEdit( + mode: "rename" | "new-file" | "new-folder", + nodePath: string, + dataEl: HTMLElement, + ): void { + // Cancel any existing inline edit first + cancelInlineEdit(); + + const input = document.createElement("input"); + input.type = "text"; + input.className = "inline-edit-input"; + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + commitInlineEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + cancelInlineEdit(); + } + }); + + input.addEventListener("blur", () => { + // If not committed, treat blur as cancel + if (activeInlineEdit && !activeInlineEdit.committed) { + cancelInlineEdit(); + } + }); + + if (mode === "rename") { + const row = dataEl.querySelector(".tree-node") ?? dataEl; + const labelEl = row.querySelector(".label") as HTMLElement | null; + if (!labelEl) return; + + const currentName = labelEl.textContent ?? ""; + input.value = currentName; + + // Select the stem (not the extension), like VS Code + const dotIdx = currentName.lastIndexOf("."); + const selEnd = dotIdx > 0 ? dotIdx : currentName.length; + + activeInlineEdit = { + input, + mode, + path: nodePath, + originalLabel: currentName, + labelEl, + }; + + // Hide the label and insert the input in its place + labelEl.style.display = "none"; + labelEl.parentElement!.appendChild(input); + input.focus(); + input.setSelectionRange(0, selEnd); + + } else { + // new-file or new-folder — create a temporary row at the top of the directory's children + // First, ensure the directory is expanded + if (!expanded[nodePath]) { + expanded[nodePath] = true; + saveExpandedState(); + const chevronSpan = dataEl.querySelector(".chevron") as HTMLElement | null; + if (chevronSpan) chevronSpan.textContent = "\u25BE"; + const iconSpan = dataEl.querySelector(".icon") as HTMLElement | null; + if (iconSpan) { + const newIcon = createFolderIconEl(true); + if (newIcon) iconSpan.replaceWith(newIcon); + } + const childrenEl = dataEl.querySelector(".children") as HTMLElement | null; + if (childrenEl) { + childrenEl.style.display = "block"; + if (!childrenCache[nodePath]) { + vscode.postMessage({ kind: "get-children", path: nodePath }); + } + } + } + + const childrenEl = dataEl.querySelector(".children") as HTMLElement | null; + if (!childrenEl) return; + + // Compute depth from the parent row's padding + const parentRow = dataEl.querySelector(".tree-node") as HTMLElement | null; + const parentPad = parseInt(parentRow?.style.paddingLeft ?? "8"); + const depth = Math.round((parentPad - 8) / 16) + 1; + + const tempRow = document.createElement("div"); + tempRow.className = "tree-node inline-edit-row"; + tempRow.style.paddingLeft = `${8 + depth * 16}px`; + + // Add the appropriate icon + if (mode === "new-folder") { + const folderIcon = createFolderIconEl(false); + if (folderIcon) tempRow.appendChild(folderIcon); + } else { + const fileIcon = createFileIconEl("untitled"); + tempRow.appendChild(fileIcon); + } + + tempRow.appendChild(input); + + activeInlineEdit = { + input, + mode, + path: nodePath, + tempRow, + }; + + // Insert at the top of the children list + if (childrenEl.firstChild) { + childrenEl.insertBefore(tempRow, childrenEl.firstChild); + } else { + childrenEl.appendChild(tempRow); + } + + input.focus(); + } + } + // ── Context menu ────────────────────────────────────────────────────────── let activeMenu: HTMLElement | null = null; @@ -142,6 +313,9 @@ function createIconEl(icon: string): HTMLElement { document.addEventListener("click", dismissMenu); document.addEventListener("contextmenu", (e) => { + // Suppress context menu while inline edit is active + if (activeInlineEdit) return; + // Only handle right-clicks on tree nodes (not buttons, fleet, etc.) const target = e.target as HTMLElement; const treeNode = target.closest(".tree-node") as HTMLElement | null; @@ -161,16 +335,16 @@ function createIconEl(icon: string): HTMLElement { const isRoot = currentRoots.some((r) => r.path === nodePath); // Build menu items - interface MenuItem { label: string; op?: string; separator?: boolean } + interface MenuItem { label: string; op?: string; separator?: boolean; inline?: boolean } const items: MenuItem[] = []; if (nodeType === "directory") { - items.push({ label: "New File", op: "new-file" }); - items.push({ label: "New Folder", op: "new-folder" }); + items.push({ label: "New File", op: "new-file", inline: true }); + items.push({ label: "New Folder", op: "new-folder", inline: true }); items.push({ separator: true }); } - items.push({ label: "Rename", op: "rename" }); + items.push({ label: "Rename", op: "rename", inline: true }); items.push({ label: "Delete", op: "delete" }); items.push({ separator: true }); items.push({ label: "Copy Path", op: "copy-path" }); @@ -206,8 +380,12 @@ function createIconEl(icon: string): HTMLElement { el.textContent = item.label; el.addEventListener("click", () => { dismissMenu(); - // ALL operations delegate to the host — no window.prompt() in webviews - vscode.postMessage({ kind: "file-op", op: item.op, path: nodePath }); + if (item.inline && dataEl) { + // Inline edit: rename, new-file, new-folder + startInlineEdit(item.op as "rename" | "new-file" | "new-folder", nodePath, dataEl); + } else { + vscode.postMessage({ kind: "file-op", op: item.op, path: nodePath }); + } }); menu.appendChild(el); } @@ -784,6 +962,29 @@ function createIconEl(icon: string): HTMLElement { } break; } + + case "file-op-ok": { + // Inline edit succeeded — clean up the inline editor + if (activeInlineEdit) { + cancelInlineEdit(); + } + break; + } + + case "file-op-error": { + // Inline edit failed — show error state on the input + if (activeInlineEdit) { + activeInlineEdit.input.classList.add("inline-error"); + activeInlineEdit.input.focus(); + // Clear error styling when user starts typing again + const clearError = () => { + activeInlineEdit?.input.classList.remove("inline-error"); + activeInlineEdit?.input.removeEventListener("input", clearError); + }; + activeInlineEdit.input.addEventListener("input", clearError); + } + break; + } } }); diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 655a25be..efd299ba 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -503,6 +503,23 @@ describe("sidebar bridge — file operations", () => { }), ); }); + + it("file-op success result posts file-op-ok back", async () => { + const handlers = makeHandlers({ + fileOp: vi.fn().mockResolvedValue({ ok: true }), + }); + await handleSidebarMessage( + { kind: "file-op", op: "new-file", path: "/project/src", name: "hello.jl" }, + handlers, + ); + expect(handlers.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "file-op-ok", + op: "new-file", + path: "/project/src", + }), + ); + }); }); // ── Session-aware highlighting (#677) ──────────────────────────────────────── @@ -1373,3 +1390,111 @@ describe("sidebar — sash resize between sections", () => { expect(src).toContain("resetSectionSizes"); }); }); + +// ── Inline editing (#673) ──────────────────────────────────────────────────── + +describe("sidebar — inline editing", () => { + let src: string; + beforeEach(() => { + src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + }); + + // Slice 2: rename inline editing infrastructure + it("has startInlineEdit function that creates an input element", () => { + expect(src).toContain("startInlineEdit"); + // Must create an element for inline text entry + expect(src).toContain('createElement("input")'); + }); + + it("rename mode swaps label content with a pre-filled input", () => { + // Input should be pre-filled with the current name for rename + expect(src).toMatch(/input\.value\s*=/); + // Selection should be on the stem (not extension) like VS Code + expect(src).toContain("setSelectionRange"); + expect(src).toContain("lastIndexOf"); + }); + + // Slice 3: new-file and new-folder insert a temporary row + it("new-file and new-folder modes create a temporary tree row", () => { + // Should insert a temporary node at the top of the directory's children + expect(src).toMatch(/insertBefore|prepend/); + // The temporary row needs a file/folder icon + expect(src).toContain("createFileIconEl"); + expect(src).toContain("createFolderIconEl"); + }); + + it("new-file/new-folder auto-expands the target directory", () => { + // When creating a new file in a collapsed directory, it should expand first + expect(src).toMatch(/expanded\[.*\]\s*=\s*true/); + }); + + // Slice 4: Enter commits, Escape cancels + it("Enter key commits the inline edit by posting file-op with the name", () => { + expect(src).toContain('"Enter"'); + // Should post a file-op message with the entered name + expect(src).toContain("postMessage"); + expect(src).toMatch(/kind:\s*"file-op"/); + }); + + it("Escape key cancels the inline edit and restores original state", () => { + expect(src).toContain('"Escape"'); + // Should have a cancel/cleanup function + expect(src).toMatch(/cancelInlineEdit|cleanupInlineEdit|cleanup/); + }); + + it("blur on the input cancels the edit (unless committed)", () => { + // The input should listen for blur events + expect(src).toContain('"blur"'); + }); + + // Slice 5: file-op-ok and file-op-error handling + it("handles file-op-ok message to dismiss the inline editor", () => { + expect(src).toContain('"file-op-ok"'); + }); + + it("handles file-op-error message to show error state on the input", () => { + expect(src).toContain('"file-op-error"'); + // Should apply an error visual cue + expect(src).toMatch(/inline-error|error/); + }); + + // Slice 6: context menu wires inline edit for rename/new-file/new-folder + it("context menu calls startInlineEdit for rename, new-file, and new-folder", () => { + // The context menu handler should call startInlineEdit instead of posting directly + expect(src).toMatch(/startInlineEdit.*rename|rename.*startInlineEdit/); + expect(src).toMatch(/startInlineEdit.*new-file|new-file.*startInlineEdit/); + expect(src).toMatch(/startInlineEdit.*new-folder|new-folder.*startInlineEdit/); + }); + + it("suppresses context menu and tree clicks while inline edit is active", () => { + // Should track whether an inline edit is active + expect(src).toMatch(/activeInlineEdit|inlineEditActive|isEditing/); + // Context menu should check and bail + expect(src).toMatch(/activeInlineEdit|inlineEditActive|isEditing/); + }); + + // Slice: CSS for inline edit input + it("CSS includes inline-edit input styles matching tree row font", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + // inline-edit input should have styling + expect(html).toMatch(/\.inline-edit-input|inline-edit/); + // Should use focusBorder for the input outline + expect(html).toContain("focusBorder"); + }); + + // Slice: validation prevents empty names and path separators + it("validates input — rejects empty names and path separators", () => { + // Should check for empty strings + expect(src).toMatch(/trim\(\).*===\s*""|\.length\s*===\s*0/); + // Should reject path separators + expect(src).toMatch(/includes.*[/\\]|[/\\]/); + }); +}); From ca2489b3abf872b218e647e4c8d1a74967c79106 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 21:31:32 -0400 Subject: [PATCH 11/34] fix(sidebar): re-insert inline edit temp row after children arrive When new-file/new-folder is triggered on a collapsed folder, the temp input row gets inserted into the .children container before get-children completes. renderChildren then wipes innerHTML, destroying the input. Fix: the children message handler checks for an active inline edit targeting the same directory and re-inserts the temp row at the top after rendering, then re-focuses the input. --- packages/extension/src/sidebar_webview.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index cc0a4992..369ab073 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -850,6 +850,11 @@ function createIconEl(icon: string): HTMLElement { if (container) { const depth = Math.round((parseInt((container.parentElement as HTMLElement)?.querySelector('.tree-node')?.style.paddingLeft ?? '8') - 8) / 16) + 1; renderChildren(container as HTMLElement, msg.entries ?? [], depth); + // Re-insert the inline edit temp row if one is active for this directory + if (activeInlineEdit?.tempRow && activeInlineEdit.path === msg.path) { + container.insertBefore(activeInlineEdit.tempRow, container.firstChild); + activeInlineEdit.input.focus(); + } } break; } From 32b8552b44e9fb24387b1a3e4bb8251361515975 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 21:35:46 -0400 Subject: [PATCH 12/34] =?UTF-8?q?fix(sidebar):=20inline=20edit=20on=20neve?= =?UTF-8?q?r-opened=20folder=20=E2=80=94=20suppress=20blur=20during=20chil?= =?UTF-8?q?dren=20re-render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When new-file/new-folder targets a folder that has never been expanded, get-children fires asynchronously. The temp input row is inserted into the empty .children container and focused. When the children response arrives, renderChildren does innerHTML="" which detaches the focused input — the browser fires blur synchronously, cancelInlineEdit nulls the state, and the re-insert check sees null. Net effect: the folder expands but no input appears. Fix: set inlineEditRerendering before renderChildren and check it in the blur handler. The flag is cleared immediately after render, and the temp row is re-inserted with focus restored. --- packages/extension/src/sidebar_webview.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index 369ab073..0d5a6a3d 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -131,6 +131,12 @@ function createIconEl(icon: string): HTMLElement { // ── Inline editing (VS Code explorer-style) ──────────────────────────────── + // Suppresses the blur→cancel path while the children handler is re-rendering + // a directory that holds the active inline edit's temp row. Without this, + // innerHTML="" detaches the focused input → browser fires blur synchronously + // → cancelInlineEdit nulls the state → the re-insert check sees null. + let inlineEditRerendering = false; + let activeInlineEdit: { input: HTMLInputElement; mode: "rename" | "new-file" | "new-folder"; @@ -205,7 +211,9 @@ function createIconEl(icon: string): HTMLElement { }); input.addEventListener("blur", () => { - // If not committed, treat blur as cancel + // If not committed, treat blur as cancel — unless we're in the middle + // of a children re-render that will re-insert the temp row. + if (inlineEditRerendering) return; if (activeInlineEdit && !activeInlineEdit.committed) { cancelInlineEdit(); } @@ -849,9 +857,14 @@ function createIconEl(icon: string): HTMLElement { const container = treeRoot?.querySelector(`[data-path="${CSS.escape(msg.path)}"] > .children`); if (container) { const depth = Math.round((parseInt((container.parentElement as HTMLElement)?.querySelector('.tree-node')?.style.paddingLeft ?? '8') - 8) / 16) + 1; + // Guard the inline edit temp row: innerHTML="" will detach the + // focused input, firing blur synchronously — suppress cancel. + const hasInlineEdit = activeInlineEdit?.tempRow && activeInlineEdit.path === msg.path; + if (hasInlineEdit) inlineEditRerendering = true; renderChildren(container as HTMLElement, msg.entries ?? [], depth); - // Re-insert the inline edit temp row if one is active for this directory - if (activeInlineEdit?.tempRow && activeInlineEdit.path === msg.path) { + inlineEditRerendering = false; + // Re-insert the inline edit temp row after children are rendered + if (hasInlineEdit && activeInlineEdit?.tempRow) { container.insertBefore(activeInlineEdit.tempRow, container.firstChild); activeInlineEdit.input.focus(); } From 2f84d21e544d2c69b96ef8d40740895c11e04ac4 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 21:48:24 -0400 Subject: [PATCH 13/34] feat(sidebar): drag-drop onto file rows and children gap resolves to parent folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX fixes to match VS Code explorer drag-and-drop behavior: 1. File rows are now drop targets that resolve to their parent directory. setupFileDropTarget() walks up via closest('[data-type="directory"]') and highlights the parent's .tree-node row — so dragging a file onto any sibling file in src/ targets src/, not nothing. 2. The directory outer container (which wraps both the .tree-node row and the .children div) is now also a drop target. Dragging onto the gap between child rows, or empty space after the last child, now correctly targets the enclosing folder. setupDirectoryDropTarget gets an optional highlightEl param so the container listens but the row gets highlighted. 3. Drop-target CSS: removed the dashed outline, kept only the subtle background fill (var(--vscode-list-dropBackground)) to match VS Code's native explorer highlight. --- packages/extension/src/sidebar_view.ts | 1 - packages/extension/src/sidebar_webview.ts | 58 ++++++++++++++++--- packages/extension/test/sidebar_view.test.ts | 59 ++++++++++++++++++++ 3 files changed, 110 insertions(+), 8 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 1fb54240..71f2b8a7 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -658,7 +658,6 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { /* ── Drag and drop ─────────────────────────────────────────── */ .tree-node.drop-target { background: var(--vscode-list-dropBackground, rgba(83, 89, 93, 0.5)); - outline: 1px dashed var(--vscode-focusBorder); } .tree-node.dragging { opacity: 0.4; diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index 0d5a6a3d..6f9d7eac 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -721,9 +721,11 @@ function createIconEl(icon: string): HTMLElement { row.appendChild(label); container.appendChild(row); - // Drag-and-drop: directories are both draggable sources and drop targets + // Drag-and-drop: directories are both draggable sources and drop targets. + // Wire the row (header) and the outer container (covers the .children gap). setupDragSource(row, entry.path); setupDirectoryDropTarget(row, entry.path); + setupDirectoryDropTarget(container, entry.path, row); const childrenEl = document.createElement("div"); childrenEl.className = "children"; @@ -777,8 +779,9 @@ function createIconEl(icon: string): HTMLElement { row.appendChild(iconEl); row.appendChild(label); - // Drag source + // Drag source + drop resolves to parent directory setupDragSource(row, entry.path); + setupFileDropTarget(row); row.addEventListener("click", () => { vscode.postMessage({ kind: "open-file", path: entry.path }); @@ -803,7 +806,8 @@ function createIconEl(icon: string): HTMLElement { }); } - function setupDirectoryDropTarget(el: HTMLElement, targetDir: string): void { + function setupDirectoryDropTarget(el: HTMLElement, targetDir: string, highlightEl?: HTMLElement): void { + const highlight = highlightEl ?? el; el.addEventListener("dragover", (e) => { if (!dragSourcePath) return; // Don't allow dropping on self or on a parent of the source @@ -811,17 +815,17 @@ function createIconEl(icon: string): HTMLElement { if (dragSourcePath.startsWith(targetDir + "/")) return; e.preventDefault(); e.dataTransfer!.dropEffect = "move"; - if (currentDropTarget !== el) { + if (currentDropTarget !== highlight) { clearDropTarget(); - currentDropTarget = el; - el.classList.add("drop-target"); + currentDropTarget = highlight; + highlight.classList.add("drop-target"); } }); el.addEventListener("dragleave", (e) => { // Only clear if we're really leaving (not entering a child) const related = e.relatedTarget as HTMLElement | null; if (related && el.contains(related)) return; - if (currentDropTarget === el) { + if (currentDropTarget === highlight) { clearDropTarget(); } }); @@ -834,6 +838,46 @@ function createIconEl(icon: string): HTMLElement { }); } + /** Resolve the nearest directory ancestor from a file row and wire it as a drop target. */ + function setupFileDropTarget(el: HTMLElement): void { + el.addEventListener("dragover", (e) => { + if (!dragSourcePath) return; + const dirContainer = el.closest("[data-type=\"directory\"]") as HTMLElement | null; + if (!dirContainer) return; + const dirPath = dirContainer.dataset.path!; + if (dragSourcePath === dirPath) return; + if (dragSourcePath.startsWith(dirPath + "/")) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = "move"; + const dirRow = dirContainer.querySelector(":scope > .tree-node") as HTMLElement | null; + if (!dirRow) return; + if (currentDropTarget !== dirRow) { + clearDropTarget(); + currentDropTarget = dirRow; + dirRow.classList.add("drop-target"); + } + }); + el.addEventListener("dragleave", (e) => { + const related = e.relatedTarget as HTMLElement | null; + if (related && el.contains(related)) return; + // Only clear if nothing else has claimed the target + const dirContainer = el.closest("[data-type=\"directory\"]") as HTMLElement | null; + const dirRow = dirContainer?.querySelector(":scope > .tree-node") as HTMLElement | null; + if (currentDropTarget === dirRow) { + clearDropTarget(); + } + }); + el.addEventListener("drop", (e) => { + e.preventDefault(); + clearDropTarget(); + const sourcePath = e.dataTransfer?.getData("text/plain"); + const dirContainer = el.closest("[data-type=\"directory\"]") as HTMLElement | null; + const targetDir = dirContainer?.dataset.path; + if (!sourcePath || !targetDir || sourcePath === targetDir) return; + vscode.postMessage({ kind: "file-op", op: "move", path: sourcePath, targetDir }); + }); + } + // ── Host → Webview messages ──────────────────────────────────────────────── window.addEventListener("message", (event) => { diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index efd299ba..b334ed6a 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1498,3 +1498,62 @@ describe("sidebar — inline editing", () => { expect(src).toMatch(/includes.*[/\\]|[/\\]/); }); }); + +// ── Drag-and-drop: file-row and gap drop targeting ─────────────────────────── + +describe("sidebar — drag drop target resolution", () => { + it("file rows resolve drop target to parent directory via setupFileDropTarget", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // File nodes must wire a drop-target helper (resolving to parent dir) + expect(src).toContain("setupFileDropTarget"); + // The helper must walk up to the nearest directory ancestor + expect(src).toMatch(/closest.*data-type.*directory|parentElement/); + }); + + it("setupFileDropTarget highlights the parent directory row, not the file row", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // The drop-target class must be applied to the resolved directory row + // (not the file row itself) — look for the dir row getting the class + expect(src).toMatch(/dirRow.*classList.*add.*drop-target|\.drop-target/); + }); + + it("file rows are wired with setupFileDropTarget in renderFileNode", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // renderFileNode must call setupFileDropTarget + const start = src.indexOf("function renderFileNode"); + const fileNodeSection = src.slice(start, start + 1000); + expect(fileNodeSection).toContain("setupFileDropTarget"); + }); + + it("children container is a drop target for its parent directory", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // The outer directory container (which wraps .children) must be wired + // as a drop target, with the row as the highlight element + expect(src).toMatch(/setupDirectoryDropTarget\(container,.*entry\.path.*row\)/); + }); + + it("drop-target CSS uses only background fill — no dashed outline", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + // Must use VS Code's list drop background + expect(html).toContain("--vscode-list-dropBackground"); + // Must NOT have a dashed outline on drop-target + expect(html).not.toMatch(/\.tree-node\.drop-target[^}]*outline.*dashed/s); + }); +}); From 628550fb00bcf3c57cff656b26baacc88fdfe04b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 21:53:21 -0400 Subject: [PATCH 14/34] feat(sidebar): VS Code-style drag image pill replaces default browser ghost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On dragstart, setupDragSource now creates a compact floating pill (.drag-image) containing the cloned icon and label from the dragged row. The pill uses VS Code theme tokens (list-activeSelectionBackground / Foreground) so it looks native in any theme, and setDragImage() makes it the cursor companion instead of the browser's default faded row screenshot. The pill is removed on dragend. Styled as an inline-flex badge: 4px radius, 2px/8px padding, gap for icon+label — compact enough not to obscure the drop target highlight. --- packages/extension/src/sidebar_view.ts | 21 ++++++++++ packages/extension/src/sidebar_webview.ts | 33 ++++++++++++++++ packages/extension/test/sidebar_view.test.ts | 41 ++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 71f2b8a7..5856da5b 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -662,6 +662,27 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { .tree-node.dragging { opacity: 0.4; } + .drag-image { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + background: var(--vscode-list-activeSelectionBackground, #094771); + color: var(--vscode-list-activeSelectionForeground, #fff); + font-family: var(--vscode-font-family, sans-serif); + font-size: var(--vscode-font-size, 13px); + white-space: nowrap; + pointer-events: none; + } + .drag-image .icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } + .drag-image .label { + color: inherit; + } /* ── Sash (resize handle between sections) ─────────────────── */ .sash { height: 0; diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index 6f9d7eac..aa11f7a7 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -793,16 +793,49 @@ function createIconEl(icon: string): HTMLElement { // ── Drag-and-drop helpers ───────────────────────────────────────────────── function setupDragSource(el: HTMLElement, sourcePath: string): void { + let dragImage: HTMLElement | null = null; + el.addEventListener("dragstart", (e) => { dragSourcePath = sourcePath; el.classList.add("dragging"); e.dataTransfer!.effectAllowed = "move"; e.dataTransfer!.setData("text/plain", sourcePath); + + // Create a floating pill (VS Code explorer-style drag image) + dragImage = document.createElement("div"); + dragImage.className = "drag-image"; + + // Clone the icon if present + const iconSrc = el.querySelector(".icon") as HTMLElement | null; + if (iconSrc) { + dragImage.appendChild(iconSrc.cloneNode(true)); + } + + // Clone the label + const labelSrc = el.querySelector(".label") as HTMLElement | null; + if (labelSrc) { + const labelClone = labelSrc.cloneNode(true) as HTMLElement; + // Strip git-status classes so the pill uses neutral text + labelClone.className = "label"; + dragImage.appendChild(labelClone); + } + + // Position off-screen so it's invisible in the DOM but renderable for setDragImage + dragImage.style.position = "absolute"; + dragImage.style.top = "-1000px"; + dragImage.style.left = "-1000px"; + document.body.appendChild(dragImage); + e.dataTransfer!.setDragImage(dragImage, 16, 12); }); + el.addEventListener("dragend", () => { el.classList.remove("dragging"); dragSourcePath = null; clearDropTarget(); + if (dragImage) { + dragImage.remove(); + dragImage = null; + } }); } diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index b334ed6a..10da96d1 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1557,3 +1557,44 @@ describe("sidebar — drag drop target resolution", () => { expect(html).not.toMatch(/\.tree-node\.drop-target[^}]*outline.*dashed/s); }); }); + +// ── Drag image (floating pill) ─────────────────────────────────────────────── + +describe("sidebar — drag image pill", () => { + it("setupDragSource creates a custom drag image with setDragImage", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Must call setDragImage to replace the default browser screenshot + expect(src).toContain("setDragImage"); + // Must create a drag-image element + expect(src).toContain("drag-image"); + }); + + it("drag image is removed on dragend", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // dragend handler must clean up the drag image element + const dragendSection = src.slice( + src.indexOf("dragend"), + src.indexOf("dragend") + 600, + ); + expect(dragendSection).toMatch(/drag-image|dragImage|remove/); + }); + + it("CSS includes drag-image pill styling", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + // Must have a .drag-image class with pill-like styling + expect(html).toMatch(/\.drag-image\s*\{/); + // Uses VS Code theme tokens (not hardcoded colors) + expect(html).toMatch(/\.drag-image[^}]*--vscode-/s); + }); +}); From 4f3bdbfc9d78e89e050f9326e88d86c15857fdb0 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 21:58:54 -0400 Subject: [PATCH 15/34] fix(sidebar): push git-status after every roots re-render to prevent color loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderRoots() wipes the entire tree DOM (innerHTML = ''), and TreeRoot has no gitStatus field, so root labels are recreated bare. Children recover because get-children responses carry annotated entries via annotateGitStatus(), but root-level folders stayed uncolored until the next unrelated git event fired — which could be never. Fix: add pushGitStatus() instance method that reads the current git state and posts it to the webview immediately (no debounce). Called: 1. After onDidChangeWorkspaceFolders sends a roots message 2. Via queueMicrotask after the getRoots handler returns (covers the fs-changed → get-roots → roots path from the webview) The microtask ensures the roots message posts first, then git-status follows in the same tick — no visible gap. --- packages/extension/src/sidebar_view.ts | 26 ++++++++- packages/extension/test/__mocks__/vscode.ts | 9 ++- packages/extension/test/sidebar_view.test.ts | 58 ++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 5856da5b..17c53798 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -338,7 +338,13 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { openChat: () => vscode.commands.executeCommand("amicode.openChat"), newProject: () => vscode.commands.executeCommand("amicode.newProject"), addExisting: () => addExistingProject(), - getRoots: () => this.treeService.getRoots(), + getRoots: () => { + const roots = this.treeService.getRoots(); + // Schedule a git-status push so colors survive the DOM wipe + // that renderRoots() causes in the webview. + queueMicrotask(() => this.pushGitStatus()); + return roots; + }, getChildren: async (p) => { const entries = await this.treeService.getChildren(p); return annotateGitStatus(entries); @@ -364,6 +370,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { // Refresh when workspace folders change. this.workspaceSub = vscode.workspace.onDidChangeWorkspaceFolders(() => { this.postDown({ kind: "roots", roots: this.treeService.getRoots() }); + this.pushGitStatus(); }); webviewView.onDidDispose(() => { @@ -398,6 +405,23 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { this.view?.webview.postMessage(msg); } + /** + * Push current git status to the webview immediately (no debounce). + * Called after every roots re-render so git colors survive the DOM wipe. + */ + private pushGitStatus(): void { + try { + const gitExt = vscode.extensions.getExtension("vscode.git"); + if (!gitExt?.isActive) return; + const api = gitExt.exports?.getAPI?.(1); + if (!api) return; + const statusMap = buildGitStatusMap(api); + this.postDown({ kind: "git-status", statusMap }); + } catch { + // Graceful — sidebar works without git colors + } + } + private setupWatcher(webviewView: vscode.WebviewView): void { this.watcher = vscode.workspace.createFileSystemWatcher("**/*"); const onFsEvent = (uri: vscode.Uri) => { diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index f4802bfd..d5acfe6e 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -122,7 +122,14 @@ export const workspace = { dispose() {}, }), updateWorkspaceFolders: (_start: number, _deleteCount: number | null, ..._adds: unknown[]) => true, - onDidChangeWorkspaceFolders: (_cb: unknown, _thisArg?: unknown, _subs?: unknown) => ({ dispose() {} }), + _workspaceFoldersCbs: [] as Array<() => void>, + onDidChangeWorkspaceFolders: (cb: () => void, _thisArg?: unknown, _subs?: unknown) => { + (workspace as any)._workspaceFoldersCbs.push(cb); + return { dispose() {} }; + }, + _fireWorkspaceFoldersChange() { + for (const cb of (workspace as any)._workspaceFoldersCbs) cb(); + }, fs: { writeFile: (_u: unknown, _b: unknown) => Promise.resolve(), readDirectory: (_u: unknown): Promise> => Promise.resolve([]), diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 10da96d1..0fcf8477 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1357,6 +1357,64 @@ describe("sidebar — reactive git status", () => { // Should propagate status to directories using prefix matching expect(src).toContain("startsWith"); }); + + it("host pushes git-status after every roots re-render to prevent color loss", async () => { + vi.resetModules(); + + const mockRepo = { + state: { + onDidChange: () => ({ dispose() {} }), + workingTreeChanges: [ + { uri: { fsPath: "/project/src/main.ts" }, status: 5 }, // MODIFIED + ], + indexChanges: [], + }, + }; + const mockGitExt = { + isActive: true, + exports: { + getAPI: () => ({ + repositories: [mockRepo], + onDidOpenRepository: () => ({ dispose() {} }), + onDidCloseRepository: () => ({ dispose() {} }), + }), + }, + activate: () => Promise.resolve(), + }; + + const vscodeMock = await import("vscode"); + (vscodeMock.extensions as any).getExtension = (id: string) => + id === "vscode.git" ? mockGitExt : undefined; + + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + + // Wait for initial debounced git-status push to settle + await new Promise(r => setTimeout(r, 350)); + + // Clear call history + (view.webview.postMessage as any).mockClear(); + + // Simulate workspace folder change — which sends "roots" then should push git-status + (vscodeMock.workspace as any)._fireWorkspaceFoldersChange?.(); + + // Wait for the git-status push (may be debounced up to 300ms) + await new Promise(r => setTimeout(r, 350)); + + const calls = (view.webview.postMessage as any).mock.calls; + const rootsMsg = calls.find((c: any[]) => c[0]?.kind === "roots"); + const gitStatusMsg = calls.find((c: any[]) => c[0]?.kind === "git-status"); + + // roots must fire (existing behavior) + expect(rootsMsg).toBeDefined(); + // git-status must follow (the fix) + expect(gitStatusMsg).toBeDefined(); + expect(gitStatusMsg[0].statusMap["/project/src/main.ts"]).toBe("modified"); + + (vscodeMock.extensions as any).getExtension = () => undefined; + }); }); // ── Sash resize between sections (#673) ────────────────────────────────────── From 5c22f46d8442c888faaa2404b2e2490894741ff7 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 22:06:04 -0400 Subject: [PATCH 16/34] feat(sidebar): animated section expand/collapse (RESEARCH, DEV, FLEET) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section bodies now transition smoothly instead of snapping between display:none and display:block. Uses a max-height CSS transition (0.2s ease-out): Expanding: set display:block + maxHeight:0, force reflow, animate to scrollHeight. On transitionend: clear maxHeight, add .expanded class (enables overflow-y:auto for scrolling). Collapsing: pin current offsetHeight as maxHeight, force reflow, animate to 0. On transitionend: set display:none, clear maxHeight. File tree .children toggling is unchanged — still uses the instant display swap. Only the top-level section headers (RESEARCH PROJECTS, DEVELOPMENT PROJECTS, FLEET) animate. toggleSectionBody() is the shared helper used by both renderSectionHeader click handlers and the fleet toggle. --- packages/extension/src/sidebar_view.ts | 6 ++- packages/extension/src/sidebar_webview.ts | 55 +++++++++++++++++--- packages/extension/test/sidebar_view.test.ts | 48 ++++++++++++++++- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 17c53798..739540f4 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -611,8 +611,12 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { } .section-body { flex: 1; - overflow-y: auto; + overflow: hidden; min-height: 0; + transition: max-height 0.2s ease-out; + } + .section-body.expanded { + overflow-y: auto; } .sidebar-sections { flex: 1; diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index aa11f7a7..f2944bea 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -124,9 +124,9 @@ function createIconEl(icon: string): HTMLElement { fleetToggle?.addEventListener("click", () => { fleetExpanded = !fleetExpanded; if (fleetChevron) fleetChevron.textContent = fleetExpanded ? "\u25BE" : "\u25B8"; // ▾ / ▸ - if (fleetBody) fleetBody.style.display = fleetExpanded ? "block" : "none"; - fleetSection?.classList.toggle("expanded", fleetExpanded); - resetSectionSizes(); + if (fleetBody && fleetSection) { + toggleSectionBody(fleetBody, fleetExpanded, fleetSection); + } }); // ── Inline editing (VS Code explorer-style) ──────────────────────────────── @@ -532,6 +532,49 @@ function createIconEl(icon: string): HTMLElement { saveExpandedState(); } + /** + * Animated expand/collapse for section bodies (RESEARCH PROJECTS, + * DEVELOPMENT PROJECTS, FLEET). Uses a max-height CSS transition. + * File tree .children toggling remains instant (display none/block). + */ + function toggleSectionBody(body: HTMLElement, expanding: boolean, section: HTMLElement): void { + if (expanding) { + // Show the element so we can measure it + body.style.display = "block"; + body.style.maxHeight = "0px"; + body.classList.remove("expanded"); + // Force reflow so the browser registers maxHeight: 0 + void body.offsetHeight; + // Animate to content height + body.style.maxHeight = body.scrollHeight + "px"; + const onEnd = () => { + body.removeEventListener("transitionend", onEnd); + // Remove the fixed max-height so the section can flex freely + body.style.maxHeight = ""; + body.classList.add("expanded"); + section.classList.add("expanded"); + resetSectionSizes(); + }; + body.addEventListener("transitionend", onEnd); + } else { + // Pin current height so we can transition from it + body.classList.remove("expanded"); + body.style.maxHeight = body.offsetHeight + "px"; + // Force reflow + void body.offsetHeight; + // Animate to 0 + body.style.maxHeight = "0px"; + section.classList.remove("expanded"); + resetSectionSizes(); + const onEnd = () => { + body.removeEventListener("transitionend", onEnd); + body.style.display = "none"; + body.style.maxHeight = ""; + }; + body.addEventListener("transitionend", onEnd); + } + } + function renderSectionHeader(title: string, sectionKey: string): { section: HTMLElement; body: HTMLElement } { const section = document.createElement("div"); section.className = sectionExpanded[sectionKey] ? "section expanded" : "section"; @@ -561,7 +604,7 @@ function createIconEl(icon: string): HTMLElement { header.appendChild(addBtn); const body = document.createElement("div"); - body.className = "section-body"; + body.className = sectionExpanded[sectionKey] ? "section-body expanded" : "section-body"; body.style.display = sectionExpanded[sectionKey] ? "block" : "none"; section.appendChild(header); @@ -571,9 +614,7 @@ function createIconEl(icon: string): HTMLElement { sectionExpanded[sectionKey] = !sectionExpanded[sectionKey]; saveSectionState(); chevron.textContent = sectionExpanded[sectionKey] ? "\u25BE" : "\u25B8"; - body.style.display = sectionExpanded[sectionKey] ? "block" : "none"; - section.classList.toggle("expanded", sectionExpanded[sectionKey]); - resetSectionSizes(); + toggleSectionBody(body, sectionExpanded[sectionKey], section); }); return { section, body }; diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 0fcf8477..7bc4c892 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -660,7 +660,8 @@ describe("sidebar webview — section labels", () => { // Accordion layout: body is full-height flex, sections flex-grow when expanded expect(html).toContain("sidebar-sections"); expect(html).toMatch(/\.section\.expanded\s*\{[^}]*flex:\s*1/); - expect(html).toMatch(/\.section-body\s*\{[^}]*overflow-y:\s*auto/); + // section-body gets overflow-y: auto when expanded (animated toggle adds .expanded class) + expect(html).toMatch(/\.section-body\.expanded\s*\{[^}]*overflow-y:\s*auto/); }); it("fleet section is a collapsible section with 'Coming soon' body, not a static div", () => { @@ -1656,3 +1657,48 @@ describe("sidebar — drag image pill", () => { expect(html).toMatch(/\.drag-image[^}]*--vscode-/s); }); }); + +// ── Section collapse/expand animation ──────────────────────────────────────── + +describe("sidebar — section toggle animation", () => { + it("section body CSS has transition on max-height for animated expand/collapse", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + // section-body must have a transition property for smooth expand/collapse + expect(html).toMatch(/\.section-body[^}]*transition/s); + // Must use overflow hidden during animation + expect(html).toMatch(/\.section-body[^}]*overflow/s); + }); + + it("webview uses animated toggle for section expand/collapse, not instant display swap", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Must have an animation helper (toggleSectionAnimated or similar) + expect(src).toMatch(/toggleSection|animateSection|section.*animate/i); + // Must NOT use bare display none/block for section body toggle + // (the old pattern was: body.style.display = ... ? "block" : "none") + // New pattern should use max-height or height transition + expect(src).toMatch(/maxHeight|max-height|scrollHeight/); + }); + + it("section body transition does NOT apply to tree-node .children (file tree stays instant)", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // .children container toggling must still use the instant display swap + // (not the animated toggleSectionBody helper) + // Find a ternary toggle line for childrenEl: display = expanded ? "block" : "none" + expect(src).toMatch(/childrenEl\.style\.display\s*=\s*expanded.*\?\s*"block"\s*:\s*"none"/); + // toggleSectionBody must NOT appear near childrenEl + const childrenToggleLines = src.split("\n").filter(l => l.includes("childrenEl")); + const usesAnimatedToggle = childrenToggleLines.some(l => l.includes("toggleSection")); + expect(usesAnimatedToggle).toBe(false); + }); +}); From 5c6b8648ec90eba26b56422f49ac1233bc52f096 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 22:10:22 -0400 Subject: [PATCH 17/34] feat(sidebar): outline chevrons with CSS rotation, replacing filled triangles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all filled triangle characters (U+25B8 ▸ / U+25BE ▾) with the thin outline chevron U+203A (›) and a CSS transform: rotate(90deg) for the expanded state. This matches VS Code's explorer style. All three chevron types (.chevron for tree nodes, .section-chevron for section headers, .fleet-chevron for fleet) now: - Render a single › character always - Toggle an .expanded CSS class instead of swapping textContent - Animate the rotation with transition: transform 0.15s ease Every occurrence in sidebar_webview.ts updated: renderRootNode, renderDirectoryNode, renderSectionHeader, fleet toggle, inline-edit expand, and the active-project auto-expand handler. --- packages/extension/src/sidebar_view.ts | 14 ++++++- packages/extension/src/sidebar_webview.ts | 24 ++++++------ packages/extension/test/sidebar_view.test.ts | 41 +++++++++++++++++++- 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 739540f4..b71154d8 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -647,6 +647,10 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { align-items: center; justify-content: center; font-size: 12px; + transition: transform 0.15s ease; + } + .tree-node .chevron.expanded { + transform: rotate(90deg); } .tree-node .icon { width: 16px; @@ -769,6 +773,10 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { align-items: center; justify-content: center; font-size: 14px; + transition: transform 0.15s ease; + } + .tree-section-label .section-chevron.expanded { + transform: rotate(90deg); } .tree-section-label .section-title { flex: 1; @@ -823,6 +831,10 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { align-items: center; justify-content: center; font-size: 14px; + transition: transform 0.15s ease; + } + .fleet-section-label .fleet-chevron.expanded { + transform: rotate(90deg); } .fleet-body { padding: 8px 12px 8px 32px; @@ -892,7 +904,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index f2944bea..8b87b16b 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -123,7 +123,7 @@ function createIconEl(icon: string): HTMLElement { fleetToggle?.addEventListener("click", () => { fleetExpanded = !fleetExpanded; - if (fleetChevron) fleetChevron.textContent = fleetExpanded ? "\u25BE" : "\u25B8"; // ▾ / ▸ + if (fleetChevron) fleetChevron.classList.toggle("expanded", fleetExpanded); if (fleetBody && fleetSection) { toggleSectionBody(fleetBody, fleetExpanded, fleetSection); } @@ -252,7 +252,7 @@ function createIconEl(icon: string): HTMLElement { expanded[nodePath] = true; saveExpandedState(); const chevronSpan = dataEl.querySelector(".chevron") as HTMLElement | null; - if (chevronSpan) chevronSpan.textContent = "\u25BE"; + if (chevronSpan) chevronSpan.classList.add("expanded"); const iconSpan = dataEl.querySelector(".icon") as HTMLElement | null; if (iconSpan) { const newIcon = createFolderIconEl(true); @@ -583,8 +583,8 @@ function createIconEl(icon: string): HTMLElement { header.className = "tree-section-label"; const chevron = document.createElement("span"); - chevron.className = "section-chevron"; - chevron.textContent = sectionExpanded[sectionKey] ? "\u25BE" : "\u25B8"; // ▾ / ▸ + chevron.className = sectionExpanded[sectionKey] ? "section-chevron expanded" : "section-chevron"; + chevron.textContent = "\u203A"; // › const titleEl = document.createElement("span"); titleEl.className = "section-title"; @@ -613,7 +613,7 @@ function createIconEl(icon: string): HTMLElement { header.addEventListener("click", () => { sectionExpanded[sectionKey] = !sectionExpanded[sectionKey]; saveSectionState(); - chevron.textContent = sectionExpanded[sectionKey] ? "\u25BE" : "\u25B8"; + chevron.classList.toggle("expanded", sectionExpanded[sectionKey]); toggleSectionBody(body, sectionExpanded[sectionKey], section); }); @@ -659,8 +659,8 @@ function createIconEl(icon: string): HTMLElement { // Chevron (expand/collapse indicator) const chevronEl = document.createElement("span"); - chevronEl.className = "chevron"; - chevronEl.textContent = expanded[root.path] ? "\u25BE" : "\u25B8"; // ▾ / ▸ + chevronEl.className = expanded[root.path] ? "chevron expanded" : "chevron"; + chevronEl.textContent = "\u203A"; // › // Folder icon (omitted if theme has none — label sits next to chevron) const iconEl = createFolderIconEl(!!expanded[root.path]); @@ -700,7 +700,7 @@ function createIconEl(icon: string): HTMLElement { row.addEventListener("click", () => { expanded[root.path] = !expanded[root.path]; saveExpandedState(); - chevronEl.textContent = expanded[root.path] ? "\u25BE" : "\u25B8"; + chevronEl.classList.toggle("expanded", expanded[root.path]); if (iconEl) { const newIcon = createFolderIconEl(expanded[root.path]); if (newIcon) row.replaceChild(newIcon, row.querySelector(".icon")!); @@ -744,8 +744,8 @@ function createIconEl(icon: string): HTMLElement { // Chevron const chevronEl = document.createElement("span"); - chevronEl.className = "chevron"; - chevronEl.textContent = expanded[entry.path] ? "\u25BE" : "\u25B8"; + chevronEl.className = expanded[entry.path] ? "chevron expanded" : "chevron"; + chevronEl.textContent = "\u203A"; // › // Folder icon (omitted if theme has none) const iconEl = createFolderIconEl(!!expanded[entry.path]); @@ -780,7 +780,7 @@ function createIconEl(icon: string): HTMLElement { row.addEventListener("click", () => { expanded[entry.path] = !expanded[entry.path]; saveExpandedState(); - chevronEl.textContent = expanded[entry.path] ? "\u25BE" : "\u25B8"; + chevronEl.classList.toggle("expanded", expanded[entry.path]); if (iconEl) { const newIcon = createFolderIconEl(expanded[entry.path]); if (newIcon) row.replaceChild(newIcon, row.querySelector(".icon")!); @@ -1027,7 +1027,7 @@ function createIconEl(icon: string): HTMLElement { expanded[nodePath!] = true; saveExpandedState(); const chevronSpan = row.querySelector(".chevron") as HTMLElement | null; - if (chevronSpan) chevronSpan.textContent = "\u25BE"; + if (chevronSpan) chevronSpan.classList.add("expanded"); const iconSpan = row.querySelector(".icon") as HTMLElement | null; if (iconSpan) { const newIcon = createFolderIconEl(true); diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 7bc4c892..e1036807 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -940,7 +940,8 @@ describe("sidebar — icon theme", () => { resolve(__dirname, "..", "src", "sidebar_webview.ts"), "utf8", ); - expect(src).toContain('chevronEl.className = "chevron"'); + // Directories have a chevron element (CSS-rotated, not character-swapped) + expect(src).toMatch(/chevronEl\.className\s*=.*"chevron/); // Files have icon directly (no spacer), same position as chevron expect(src).toContain("createFileIconEl"); }); @@ -1702,3 +1703,41 @@ describe("sidebar — section toggle animation", () => { expect(usesAnimatedToggle).toBe(false); }); }); + +// ── Chevron style: CSS-rotated outline chevrons, not filled triangles ──────── + +describe("sidebar — chevron style", () => { + it("CSS includes a rotate transform for expanded chevrons", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + // All chevron types must rotate when expanded rather than swapping characters + expect(html).toMatch(/\.chevron[^}]*transition[^}]*transform/s); + expect(html).toMatch(/rotate\(90deg\)/); + }); + + it("webview does not use filled triangle characters (U+25B8 / U+25BE)", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Must not contain the old filled triangle codepoints + expect(src).not.toContain("\\u25B8"); + expect(src).not.toContain("\\u25BE"); + }); + + it("fleet chevron in static HTML does not use filled triangle entity", async () => { + vi.resetModules(); + const { SidebarViewProvider } = await import("../src/sidebar_view"); + const provider = new SidebarViewProvider(makeExtensionUri()); + const view = makeWebviewView(); + provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); + const html = view.webview.html; + // Must not contain the old filled triangle HTML entity + expect(html).not.toContain("▸"); + expect(html).not.toContain("▾"); + }); +}); From 6bb2ee66dee88fe5b95bbed9beb6d5fb963e5aaf Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 22:13:44 -0400 Subject: [PATCH 18/34] =?UTF-8?q?fix(sidebar):=20smooth=20section=20animat?= =?UTF-8?q?ion=20=E2=80=94=20height=20transition=20working=20with=20flex,?= =?UTF-8?q?=20not=20against=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The max-height approach caused a visible stutter at the end of expand because: 1. max-height animated to scrollHeight (a content measurement) 2. At transitionend, we cleared max-height and added flex:1 simultaneously 3. Flex computed a DIFFERENT height than scrollHeight → visible jump Rewritten to use height transitions that cooperate with flex: Expand: add section.expanded FIRST (so flex allocates space), measure the flex-computed target via offsetHeight, pin body at height:0, then animate to the exact flex target. At transitionend, clear the explicit height — flex takes over at the identical value, so there's no jump. Collapse: pin body at its current offsetHeight, THEN remove flex:1. The pinned height holds the size stable through the class change. Animate to 0, hide on transitionend. Also: cubic-bezier(0.1, 0.9, 0.2, 1) easing (fast start, gentle decel) matches VS Code's tree animation feel. And in-flight animations are cancelled on re-click to prevent stacking. --- packages/extension/src/sidebar_view.ts | 2 +- packages/extension/src/sidebar_webview.ts | 63 ++++++++++++++------ packages/extension/test/sidebar_view.test.ts | 6 +- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index b71154d8..8ac90680 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -613,7 +613,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { flex: 1; overflow: hidden; min-height: 0; - transition: max-height 0.2s ease-out; + transition: height 0.2s cubic-bezier(0.1, 0.9, 0.2, 1); } .section-body.expanded { overflow-y: auto; diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index 8b87b16b..ec26ce4c 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -534,43 +534,72 @@ function createIconEl(icon: string): HTMLElement { /** * Animated expand/collapse for section bodies (RESEARCH PROJECTS, - * DEVELOPMENT PROJECTS, FLEET). Uses a max-height CSS transition. + * DEVELOPMENT PROJECTS, FLEET). Uses a height CSS transition that + * works WITH the flex layout instead of fighting it. + * + * Expand: add .expanded to section first (so flex allocates space), + * measure the flex-computed target height, animate body from 0 to + * that target, then release the explicit height so flex owns it. + * + * Collapse: pin the body's current height, remove .expanded (flex + * releases), animate body to 0, then hide. + * * File tree .children toggling remains instant (display none/block). */ function toggleSectionBody(body: HTMLElement, expanding: boolean, section: HTMLElement): void { + // Cancel any in-flight animation + if ((body as any)._sectionAnimEnd) { + body.removeEventListener("transitionend", (body as any)._sectionAnimEnd); + (body as any)._sectionAnimEnd = null; + } + if (expanding) { - // Show the element so we can measure it + // 1. Show the body and add .expanded to section so flex allocates space body.style.display = "block"; - body.style.maxHeight = "0px"; body.classList.remove("expanded"); - // Force reflow so the browser registers maxHeight: 0 + section.classList.add("expanded"); + resetSectionSizes(); + + // 2. Let flex compute the target height + // (body has flex:1 inside section.expanded, so it fills the space) + void body.offsetHeight; + const targetHeight = body.offsetHeight; + + // 3. Pin at 0 and animate to the flex-computed target + body.style.height = "0px"; void body.offsetHeight; - // Animate to content height - body.style.maxHeight = body.scrollHeight + "px"; + body.style.height = targetHeight + "px"; + const onEnd = () => { body.removeEventListener("transitionend", onEnd); - // Remove the fixed max-height so the section can flex freely - body.style.maxHeight = ""; + (body as any)._sectionAnimEnd = null; + // Release explicit height — flex takes over seamlessly (same value) + body.style.height = ""; body.classList.add("expanded"); - section.classList.add("expanded"); - resetSectionSizes(); }; + (body as any)._sectionAnimEnd = onEnd; body.addEventListener("transitionend", onEnd); } else { - // Pin current height so we can transition from it + // 1. Pin the body at its current flex-computed height body.classList.remove("expanded"); - body.style.maxHeight = body.offsetHeight + "px"; - // Force reflow - void body.offsetHeight; - // Animate to 0 - body.style.maxHeight = "0px"; + const currentHeight = body.offsetHeight; + body.style.height = currentHeight + "px"; + + // 2. Remove flex:1 — but the pinned height holds the size stable section.classList.remove("expanded"); resetSectionSizes(); + + // 3. Force reflow then animate to 0 + void body.offsetHeight; + body.style.height = "0px"; + const onEnd = () => { body.removeEventListener("transitionend", onEnd); + (body as any)._sectionAnimEnd = null; body.style.display = "none"; - body.style.maxHeight = ""; + body.style.height = ""; }; + (body as any)._sectionAnimEnd = onEnd; body.addEventListener("transitionend", onEnd); } } diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index e1036807..a07010d0 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1662,7 +1662,7 @@ describe("sidebar — drag image pill", () => { // ── Section collapse/expand animation ──────────────────────────────────────── describe("sidebar — section toggle animation", () => { - it("section body CSS has transition on max-height for animated expand/collapse", async () => { + it("section body CSS has transition on height for animated expand/collapse", async () => { vi.resetModules(); const { SidebarViewProvider } = await import("../src/sidebar_view"); const provider = new SidebarViewProvider(makeExtensionUri()); @@ -1684,8 +1684,8 @@ describe("sidebar — section toggle animation", () => { expect(src).toMatch(/toggleSection|animateSection|section.*animate/i); // Must NOT use bare display none/block for section body toggle // (the old pattern was: body.style.display = ... ? "block" : "none") - // New pattern should use max-height or height transition - expect(src).toMatch(/maxHeight|max-height|scrollHeight/); + // New pattern should use height transition (not max-height) + expect(src).toMatch(/\.height\s*=|offsetHeight|scrollHeight/); }); it("section body transition does NOT apply to tree-node .children (file tree stays instant)", () => { From 12d30c3321b36a86acb49d67a9e144e61dd3dcdd Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 22:19:13 -0400 Subject: [PATCH 19/34] fix(sidebar): replace CSS transition with rAF animation for section toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS transitions on height/max-height were fighting the flex layout, causing visible jitter — the section animated to its content height, then jumped when flex:1 kicked in at transitionend. Replaced with a requestAnimationFrame loop that: - Measures scrollHeight (expand) or offsetHeight (collapse) once - Interpolates height every frame with easeOutCubic - Only applies flex classes after the animation completes (expand) or pins height before removing them (collapse) - Cancels in-flight animations on re-click Removed the CSS transition property from .section-body entirely. The rAF approach gives full frame-level control with no layout system conflicts. Duration: 200ms, easing: cubic ease-out. --- packages/extension/src/sidebar_view.ts | 1 - packages/extension/src/sidebar_webview.ts | 101 ++++++++++--------- packages/extension/test/sidebar_view.test.ts | 28 ++--- 3 files changed, 66 insertions(+), 64 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 8ac90680..8dead414 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -613,7 +613,6 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { flex: 1; overflow: hidden; min-height: 0; - transition: height 0.2s cubic-bezier(0.1, 0.9, 0.2, 1); } .section-body.expanded { overflow-y: auto; diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index ec26ce4c..ff124857 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -534,73 +534,76 @@ function createIconEl(icon: string): HTMLElement { /** * Animated expand/collapse for section bodies (RESEARCH PROJECTS, - * DEVELOPMENT PROJECTS, FLEET). Uses a height CSS transition that - * works WITH the flex layout instead of fighting it. - * - * Expand: add .expanded to section first (so flex allocates space), - * measure the flex-computed target height, animate body from 0 to - * that target, then release the explicit height so flex owns it. - * - * Collapse: pin the body's current height, remove .expanded (flex - * releases), animate body to 0, then hide. + * DEVELOPMENT PROJECTS, FLEET). Driven by requestAnimationFrame so + * we have full control over every frame — CSS transitions fought the + * flex layout and caused jitter. * * File tree .children toggling remains instant (display none/block). */ + const SECTION_ANIM_MS = 200; + + function easeOutCubic(t: number): number { + return 1 - Math.pow(1 - t, 3); + } + function toggleSectionBody(body: HTMLElement, expanding: boolean, section: HTMLElement): void { // Cancel any in-flight animation - if ((body as any)._sectionAnimEnd) { - body.removeEventListener("transitionend", (body as any)._sectionAnimEnd); - (body as any)._sectionAnimEnd = null; + if ((body as any)._sectionAnimId) { + cancelAnimationFrame((body as any)._sectionAnimId); + (body as any)._sectionAnimId = null; } if (expanding) { - // 1. Show the body and add .expanded to section so flex allocates space + // Measure content height before animation body.style.display = "block"; - body.classList.remove("expanded"); - section.classList.add("expanded"); - resetSectionSizes(); - - // 2. Let flex compute the target height - // (body has flex:1 inside section.expanded, so it fills the space) - void body.offsetHeight; - const targetHeight = body.offsetHeight; - - // 3. Pin at 0 and animate to the flex-computed target + body.style.height = "auto"; + body.style.overflow = "hidden"; + const targetHeight = body.scrollHeight; body.style.height = "0px"; - void body.offsetHeight; - body.style.height = targetHeight + "px"; - - const onEnd = () => { - body.removeEventListener("transitionend", onEnd); - (body as any)._sectionAnimEnd = null; - // Release explicit height — flex takes over seamlessly (same value) - body.style.height = ""; - body.classList.add("expanded"); + + const start = performance.now(); + const tick = (now: number) => { + const t = Math.min((now - start) / SECTION_ANIM_MS, 1); + body.style.height = (targetHeight * easeOutCubic(t)) + "px"; + if (t < 1) { + (body as any)._sectionAnimId = requestAnimationFrame(tick); + } else { + (body as any)._sectionAnimId = null; + // Release to flex + body.style.height = ""; + body.style.overflow = ""; + body.classList.add("expanded"); + section.classList.add("expanded"); + resetSectionSizes(); + } }; - (body as any)._sectionAnimEnd = onEnd; - body.addEventListener("transitionend", onEnd); + (body as any)._sectionAnimId = requestAnimationFrame(tick); + } else { - // 1. Pin the body at its current flex-computed height + // Pin current height before collapsing body.classList.remove("expanded"); - const currentHeight = body.offsetHeight; - body.style.height = currentHeight + "px"; + const startHeight = body.offsetHeight; + body.style.overflow = "hidden"; + body.style.height = startHeight + "px"; - // 2. Remove flex:1 — but the pinned height holds the size stable + // Remove flex immediately — pinned height holds size stable section.classList.remove("expanded"); resetSectionSizes(); - // 3. Force reflow then animate to 0 - void body.offsetHeight; - body.style.height = "0px"; - - const onEnd = () => { - body.removeEventListener("transitionend", onEnd); - (body as any)._sectionAnimEnd = null; - body.style.display = "none"; - body.style.height = ""; + const start = performance.now(); + const tick = (now: number) => { + const t = Math.min((now - start) / SECTION_ANIM_MS, 1); + body.style.height = (startHeight * (1 - easeOutCubic(t))) + "px"; + if (t < 1) { + (body as any)._sectionAnimId = requestAnimationFrame(tick); + } else { + (body as any)._sectionAnimId = null; + body.style.display = "none"; + body.style.height = ""; + body.style.overflow = ""; + } }; - (body as any)._sectionAnimEnd = onEnd; - body.addEventListener("transitionend", onEnd); + (body as any)._sectionAnimId = requestAnimationFrame(tick); } } diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index a07010d0..4cbcbfcc 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1662,17 +1662,19 @@ describe("sidebar — drag image pill", () => { // ── Section collapse/expand animation ──────────────────────────────────────── describe("sidebar — section toggle animation", () => { - it("section body CSS has transition on height for animated expand/collapse", async () => { - vi.resetModules(); - const { SidebarViewProvider } = await import("../src/sidebar_view"); - const provider = new SidebarViewProvider(makeExtensionUri()); - const view = makeWebviewView(); - provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }); - const html = view.webview.html; - // section-body must have a transition property for smooth expand/collapse - expect(html).toMatch(/\.section-body[^}]*transition/s); + it("webview uses requestAnimationFrame for section expand/collapse, not CSS transitions", () => { + const src = readFileSync( + resolve(__dirname, "..", "src", "sidebar_webview.ts"), + "utf8", + ); + // Must use requestAnimationFrame for smooth animation + expect(src).toContain("requestAnimationFrame"); + // Must have an animation helper + expect(src).toMatch(/toggleSectionBody/); // Must use overflow hidden during animation - expect(html).toMatch(/\.section-body[^}]*overflow/s); + expect(src).toMatch(/overflow.*hidden/); + // Must measure content height via scrollHeight or offsetHeight + expect(src).toMatch(/scrollHeight|offsetHeight/); }); it("webview uses animated toggle for section expand/collapse, not instant display swap", () => { @@ -1680,11 +1682,9 @@ describe("sidebar — section toggle animation", () => { resolve(__dirname, "..", "src", "sidebar_webview.ts"), "utf8", ); - // Must have an animation helper (toggleSectionAnimated or similar) + // Must have the animation helper expect(src).toMatch(/toggleSection|animateSection|section.*animate/i); - // Must NOT use bare display none/block for section body toggle - // (the old pattern was: body.style.display = ... ? "block" : "none") - // New pattern should use height transition (not max-height) + // Must animate height expect(src).toMatch(/\.height\s*=|offsetHeight|scrollHeight/); }); From 733e17f31d8668460cee75b58ea0ecd3f5e22f00 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 22:26:20 -0400 Subject: [PATCH 20/34] fix(sidebar): inline CSS height transitions for section toggle (VS Code's approach) Replaces the rAF animation loop with the same technique VS Code's PaneView uses: set an inline CSS transition on the element, set the target height, and let the browser's compositor handle interpolation. The key insight from VS Code's source (paneview.ts + paneview.css): SplitView sets explicit pixel heights on each pane container. PaneView adds an 'animated' class for 200ms that enables CSS transitions on height. The browser handles all the interpolation natively. Our adaptation for a flex layout: - Expand: temporarily add .expanded to measure the flex-computed target height, remove it, pin body at 0, set inline transition, set target height. CSS transition interpolates. On transitionend: clear inline styles, add .expanded class (flex takes over at the same height). - Collapse: pin body at current offsetHeight, set inline transition, remove .expanded, set height to 0. On transitionend: display none, clear inline styles. The inline transition is set and removed per-toggle, so it doesn't interfere with flex layout during normal operation. Duration: 150ms ease-out (matching VS Code's 0.15s). --- packages/extension/src/sidebar_webview.ts | 93 +++++++++----------- packages/extension/test/sidebar_view.test.ts | 12 +-- 2 files changed, 49 insertions(+), 56 deletions(-) diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts index ff124857..b99466bb 100644 --- a/packages/extension/src/sidebar_webview.ts +++ b/packages/extension/src/sidebar_webview.ts @@ -533,77 +533,68 @@ function createIconEl(icon: string): HTMLElement { } /** - * Animated expand/collapse for section bodies (RESEARCH PROJECTS, - * DEVELOPMENT PROJECTS, FLEET). Driven by requestAnimationFrame so - * we have full control over every frame — CSS transitions fought the - * flex layout and caused jitter. + * Animated expand/collapse for section bodies. Mirrors VS Code's + * PaneView approach: temporarily enable CSS transitions on explicit + * heights, let the browser interpolate, then restore flex. * * File tree .children toggling remains instant (display none/block). */ - const SECTION_ANIM_MS = 200; - - function easeOutCubic(t: number): number { - return 1 - Math.pow(1 - t, 3); - } + const SECTION_ANIM_MS = 150; function toggleSectionBody(body: HTMLElement, expanding: boolean, section: HTMLElement): void { - // Cancel any in-flight animation - if ((body as any)._sectionAnimId) { - cancelAnimationFrame((body as any)._sectionAnimId); - (body as any)._sectionAnimId = null; - } - if (expanding) { - // Measure content height before animation + // 1. Show body and let flex compute the final layout body.style.display = "block"; - body.style.height = "auto"; - body.style.overflow = "hidden"; - const targetHeight = body.scrollHeight; + section.classList.add("expanded"); + resetSectionSizes(); + void body.offsetHeight; // force layout with flex + const targetHeight = body.offsetHeight; + + // 2. Pin at 0 without the browser seeing the expanded state + section.classList.remove("expanded"); body.style.height = "0px"; + body.style.overflow = "hidden"; + body.style.transition = `height ${SECTION_ANIM_MS}ms ease-out`; + void body.offsetHeight; // commit the 0px state - const start = performance.now(); - const tick = (now: number) => { - const t = Math.min((now - start) / SECTION_ANIM_MS, 1); - body.style.height = (targetHeight * easeOutCubic(t)) + "px"; - if (t < 1) { - (body as any)._sectionAnimId = requestAnimationFrame(tick); - } else { - (body as any)._sectionAnimId = null; - // Release to flex - body.style.height = ""; - body.style.overflow = ""; - body.classList.add("expanded"); - section.classList.add("expanded"); - resetSectionSizes(); - } + // 3. Set target — CSS transition handles the interpolation + section.classList.add("expanded"); + resetSectionSizes(); + body.style.height = targetHeight + "px"; + + const onEnd = () => { + body.removeEventListener("transitionend", onEnd); + body.style.height = ""; + body.style.overflow = ""; + body.style.transition = ""; + body.classList.add("expanded"); }; - (body as any)._sectionAnimId = requestAnimationFrame(tick); + body.addEventListener("transitionend", onEnd); } else { - // Pin current height before collapsing + // 1. Pin current height body.classList.remove("expanded"); const startHeight = body.offsetHeight; - body.style.overflow = "hidden"; body.style.height = startHeight + "px"; + body.style.overflow = "hidden"; + body.style.transition = `height ${SECTION_ANIM_MS}ms ease-out`; - // Remove flex immediately — pinned height holds size stable + // 2. Remove flex — pinned height holds stable section.classList.remove("expanded"); resetSectionSizes(); + void body.offsetHeight; // commit - const start = performance.now(); - const tick = (now: number) => { - const t = Math.min((now - start) / SECTION_ANIM_MS, 1); - body.style.height = (startHeight * (1 - easeOutCubic(t))) + "px"; - if (t < 1) { - (body as any)._sectionAnimId = requestAnimationFrame(tick); - } else { - (body as any)._sectionAnimId = null; - body.style.display = "none"; - body.style.height = ""; - body.style.overflow = ""; - } + // 3. Animate to 0 + body.style.height = "0px"; + + const onEnd = () => { + body.removeEventListener("transitionend", onEnd); + body.style.display = "none"; + body.style.height = ""; + body.style.overflow = ""; + body.style.transition = ""; }; - (body as any)._sectionAnimId = requestAnimationFrame(tick); + body.addEventListener("transitionend", onEnd); } } diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts index 4cbcbfcc..20d6893a 100644 --- a/packages/extension/test/sidebar_view.test.ts +++ b/packages/extension/test/sidebar_view.test.ts @@ -1662,19 +1662,21 @@ describe("sidebar — drag image pill", () => { // ── Section collapse/expand animation ──────────────────────────────────────── describe("sidebar — section toggle animation", () => { - it("webview uses requestAnimationFrame for section expand/collapse, not CSS transitions", () => { + it("webview uses inline CSS transitions for section expand/collapse with height measurement", () => { const src = readFileSync( resolve(__dirname, "..", "src", "sidebar_webview.ts"), "utf8", ); - // Must use requestAnimationFrame for smooth animation - expect(src).toContain("requestAnimationFrame"); // Must have an animation helper expect(src).toMatch(/toggleSectionBody/); // Must use overflow hidden during animation expect(src).toMatch(/overflow.*hidden/); - // Must measure content height via scrollHeight or offsetHeight - expect(src).toMatch(/scrollHeight|offsetHeight/); + // Must measure content height via offsetHeight + expect(src).toMatch(/offsetHeight/); + // Must set an inline CSS transition on the body during animation + expect(src).toMatch(/style\.transition/); + // Must listen for transitionend to clean up + expect(src).toContain("transitionend"); }); it("webview uses animated toggle for section expand/collapse, not instant display swap", () => { From 288242010a1599a123785133ebfc6a4061d22703 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 31 Aug 2026 22:41:19 -0400 Subject: [PATCH 21/34] Replace flex section layout with pixel-positioned sections (#697) VS Code SplitView/PaneView pattern: .section becomes position:absolute with JS-computed top/height. layoutSections() is the single layout function, sectionSizes map is the single source of truth. - .sidebar-sections: position:relative (was flex column) - .section: position:absolute; left:0; right:0 (was flex-shrink:0) - .animated CSS class enables transition on top/height for 150ms - toggleSectionBody uses .animated class (was inline style.transition) - Sash drag writes to sectionSizes + calls layoutSections (was style.flex) - prefers-reduced-motion suppresses .animated class - ResizeObserver re-layouts on sidebar resize - Fleet section wired through same pixel layout - HEADER_HEIGHT=28 constant (collapsed = header only) 101 tests pass (10 new + 91 updated existing). Build clean. --- packages/extension/src/sidebar_view.ts | 22 +- packages/extension/src/sidebar_webview.ts | 201 +++++++++++++------ packages/extension/test/sidebar_view.test.ts | 127 +++++++++--- 3 files changed, 252 insertions(+), 98 deletions(-) diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts index 8dead414..a6a905b5 100644 --- a/packages/extension/src/sidebar_view.ts +++ b/packages/extension/src/sidebar_view.ts @@ -600,31 +600,33 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider { } /* ── Accordion sections ────────────────────────────────────── */ .section { + position: absolute; + left: 0; + right: 0; display: flex; flex-direction: column; - flex-shrink: 0; - min-height: 0; - } - .section.expanded { - flex: 1; overflow: hidden; + min-height: 0; } .section-body { - flex: 1; overflow: hidden; min-height: 0; + flex: 1; } .section-body.expanded { overflow-y: auto; } .sidebar-sections { flex: 1; - display: flex; - flex-direction: column; - justify-content: flex-end; + position: relative; overflow: hidden; min-height: 0; } + /* Animated expand/collapse — VS Code PaneView pattern. + Added temporarily by JS during toggles; removed after 150ms. */ + .sidebar-sections.animated .section { + transition: top 0.15s ease-out, height 0.15s ease-out; + } .tree-node { display: flex; align-items: center; @@ -901,7 +903,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {