From 6d33e432a3caa5fdc55af7525df2597841def542 Mon Sep 17 00:00:00 2001 From: mandarwagh9 Date: Thu, 28 May 2026 20:12:04 +0530 Subject: [PATCH 01/11] perf(bundle): split monaco/hljs/xterm and lazy-load main panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite.config.ts manualChunks puts monaco-editor, highlight.js, and @xterm/* into their own chunks instead of folding them into the entry bundle. The five heavy components (CodeEditor, FileExplorer, AIChatPanel, SerialMonitor, BuildPanel) plus SettingsModal and SetupWizard are now React.lazy() with a small fallback. Result on `npm run build`: - index-*.js: 1.77 MB → 411 KB (-77%) - monaco-*.js: 4.26 MB (lazy) - hljs-*.js: 969 KB (lazy) - xterm-*.js: 331 KB (lazy) Cold start no longer pays for the editor until a file is opened. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 56 +++++++++++++++++++++++++++++++------------------- vite.config.ts | 17 +++++++++++++++ 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index cb11c35..be65b85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; +import { useEffect, useRef, useCallback, useMemo, useState, lazy, Suspense } from 'react'; import { useUIStore } from './stores/uiStore'; import { useSettingsStore } from './stores/settingsStore'; import { useFileStore } from './stores/fileStore'; @@ -12,17 +12,27 @@ import { Sidebar } from './components/Layout/Sidebar'; import { TabBar } from './components/Layout/TabBar'; import { StatusBar } from './components/Layout/StatusBar'; import { BottomPanel } from './components/Layout/BottomPanel'; -import { CodeEditor } from './components/Editor/CodeEditor'; -import { FileExplorer } from './components/FileExplorer/FileExplorer'; -import { AIChatPanel } from './components/AI/AIChatPanel'; -import { SerialMonitor } from './components/Serial/SerialMonitor'; -import { BuildPanel } from './components/Build/BuildPanel'; -import { SettingsModal } from './components/Settings/SettingsModal'; -import { SetupWizard } from './components/Settings/SetupWizard'; import { ErrorBoundary } from './components/Common/ErrorBoundary'; import { ToastContainer, setToastDispatcher, type ToastItem } from './components/Common/Toast'; import './styles/global.css'; +// Lazy-load the heavy panels so the initial bundle is just shell + design +// system. Monaco (CodeEditor) and Highlight.js (AIChatPanel) are the +// biggest dependencies; this defers ~4 MB until the user opens those views. +const CodeEditor = lazy(() => import('./components/Editor/CodeEditor').then(m => ({ default: m.CodeEditor }))); +const FileExplorer = lazy(() => import('./components/FileExplorer/FileExplorer').then(m => ({ default: m.FileExplorer }))); +const AIChatPanel = lazy(() => import('./components/AI/AIChatPanel').then(m => ({ default: m.AIChatPanel }))); +const SerialMonitor = lazy(() => import('./components/Serial/SerialMonitor').then(m => ({ default: m.SerialMonitor }))); +const BuildPanel = lazy(() => import('./components/Build/BuildPanel').then(m => ({ default: m.BuildPanel }))); +const SettingsModal = lazy(() => import('./components/Settings/SettingsModal').then(m => ({ default: m.SettingsModal }))); +const SetupWizard = lazy(() => import('./components/Settings/SetupWizard').then(m => ({ default: m.SetupWizard }))); + +const PanelFallback = () => ( +
+ Loading… +
+); + const SIDEBAR_MIN = 350; const SIDEBAR_MAX = 700; @@ -356,15 +366,15 @@ void loop() { const renderSidebarContent = () => { switch (sidebarSection) { case 'files': - return ; + return }>; case 'ai': - return ; + return }>; case 'serial': - return ; + return }>; case 'build': - return ; + return }>; default: - return ; + return }>; } }; @@ -406,12 +416,14 @@ void loop() {
Editor failed to load
}> - + }> + + @@ -420,8 +432,10 @@ void loop() { - - + + + + ); diff --git a/vite.config.ts b/vite.config.ts index 0b29aa9..f41937c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,5 +15,22 @@ export default defineConfig({ target: "esnext", minify: "esbuild", sourcemap: false, + chunkSizeWarningLimit: 1500, + rollupOptions: { + output: { + manualChunks: (id) => { + // Monaco is the heaviest dep — keep it in its own chunk so the + // initial paint doesn't pay for the editor until a file is opened. + if (id.includes("node_modules/monaco-editor/")) return "monaco"; + // highlight.js bundles ~196 languages by default; isolating it + // lets the lazy MessageBubble/CodeBlock path be its own chunk. + if (id.includes("node_modules/highlight.js/")) return "hljs"; + // xterm is only loaded when the terminal panel opens. + if (id.includes("node_modules/@xterm/")) return "xterm"; + // React + state stack stays in the entry chunk. + return undefined; + }, + }, + }, }, }); From 69607d2ee76150688ba35d3b588813df043557ff Mon Sep 17 00:00:00 2001 From: mandarwagh9 Date: Thu, 28 May 2026 20:12:12 +0530 Subject: [PATCH 02/11] feat(design): focus ring system, AA light theme, 3-tier shadows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token additions in styles/global.css: - --focus-ring + --focus-ring-offset, applied via :focus-visible to buttons, links, [role=button|menuitem|tab], inputs (with a 4px --accent-focus halo via box-shadow). Inputs and divs that act as buttons now show a keyboard-nav outline. - --shadow-sm / --shadow-md / --shadow-lg consolidating ~9 ad-hoc shadow values that had drifted across components. - Light-theme palette rebuilt for WCAG AA contrast: primary text clears 7:1 on every surface, accent moved from #4F46E5 to #3730A3 for a 4.5:1 minimum on white. Shadows on light surfaces dropped to lower opacity so they read as elevation, not grime. - prefers-reduced-motion: reduce honored globally — animations collapse to 0.01ms when the OS signals motion sensitivity. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/styles/global.css | 119 ++++++++++++++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/src/styles/global.css b/src/styles/global.css index 594c8b0..8b0a765 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -5,33 +5,34 @@ --bg-tertiary: #1A1A1A; --bg-surface: #1E1E1E; --bg-surface-hover: #272727; - + /* Border Colors - subtle and refined */ --border-default: #2A2A2A; --border-active: #404040; --border-subtle: #1F1F1F; - + /* Text Colors - high contrast like Vercel */ --text-primary: #EDEDED; --text-secondary: #A1A1A1; --text-muted: #6B6B6B; --text-inverse: #000000; - + /* Accent Colors - Vercel's blurple/indigo */ --accent: #6366F1; --accent-hover: #818CF8; --accent-muted: rgba(99, 102, 241, 0.15); - + --accent-focus: rgba(99, 102, 241, 0.45); + /* Status Colors - refined */ --success: #22C55E; --error: #EF4444; --warning: #F59E0B; --info: #3B82F6; - + /* Typography */ --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Consolas', monospace; --font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - + /* Font Sizes */ --text-xs: 11px; --text-sm: 12px; @@ -39,7 +40,7 @@ --text-md: 14px; --text-lg: 16px; --text-xl: 20px; - + /* Spacing */ --space-xs: 4px; --space-sm: 8px; @@ -47,24 +48,36 @@ --space-lg: 16px; --space-xl: 24px; --space-xxl: 32px; - + /* Layout */ --titlebar-height: 32px; --menubar-height: 28px; --sidebar-width: 48px; --tabbar-height: 36px; --statusbar-height: 24px; - + /* Effects */ --transition-fast: 100ms ease; --transition-normal: 150ms ease; --transition-slow: 250ms ease; - + /* Radius */ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; + + /* Elevation — 3-tier shadow scale. Use sm for inputs/inline UI, + md for popovers/menus/tooltips, lg for modals/dialogs. + Consolidated from ~9 ad-hoc shadow values across the codebase. */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.55); + + /* Focus — apply with `outline: var(--focus-ring); outline-offset: 2px` + inside a `:focus-visible` selector. Used to standardize keyboard nav. */ + --focus-ring: 2px solid var(--accent); + --focus-ring-offset: 2px; } * { @@ -126,37 +139,87 @@ a:hover { color: var(--text-primary); } - button { +button { cursor: pointer; border: none; background: none; color: inherit; } +/* Global keyboard-navigation focus ring. Mouse interactions stay quiet via + :focus-visible (only fires when the browser thinks the user is using the + keyboard). Individual components may override with a tighter ring but + should not remove it entirely. */ +:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + border-radius: var(--radius-sm); +} + +button:focus-visible, +a:focus-visible, +[role="button"]:focus-visible, +[role="menuitem"]:focus-visible, +[role="tab"]:focus-visible, +[tabindex]:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: var(--focus-ring); + outline-offset: 0; + box-shadow: 0 0 0 4px var(--accent-focus); +} + +/* Respect reduced-motion preferences across all components. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + [data-theme='light'] { + /* Light theme rebuilt for WCAG AA contrast — primary text on every + surface clears 7:1, secondary text clears 4.5:1. Surfaces are slightly + warmer (#FAFAFA → #F1F1F1) so they don't look gray-on-white. */ --bg-primary: #FFFFFF; - --bg-secondary: #F5F5F5; - --bg-tertiary: #EEEEEE; - --bg-surface: #E8E8E8; + --bg-secondary: #FAFAFA; + --bg-tertiary: #F3F3F3; + --bg-surface: #ECECEC; --bg-surface-hover: #E0E0E0; - --border-default: #E0E0E0; - --border-active: #CCCCCC; - --border-subtle: #EBEBEB; + --border-default: #D4D4D4; + --border-active: #A3A3A3; + --border-subtle: #E5E5E5; - --text-primary: #1A1A1A; - --text-secondary: #555555; - --text-muted: #888888; + --text-primary: #0F0F10; + --text-secondary: #404045; + --text-muted: #6B6B70; --text-inverse: #FFFFFF; - --accent: #4F46E5; - --accent-hover: #6366F1; - --accent-muted: rgba(79, 70, 229, 0.12); - - --success: #16A34A; - --error: #DC2626; - --warning: #D97706; - --info: #2563EB; + --accent: #3730A3; + --accent-hover: #4338CA; + --accent-muted: rgba(55, 48, 163, 0.10); + --accent-focus: rgba(55, 48, 163, 0.35); + + --success: #15803D; + --error: #B91C1C; + --warning: #B45309; + --info: #1D4ED8; + + /* Shadows on light surfaces need lower opacity to read as elevation + instead of grime. */ + --shadow-sm: 0 1px 2px rgba(15, 15, 16, 0.08); + --shadow-md: 0 4px 12px rgba(15, 15, 16, 0.12); + --shadow-lg: 0 16px 40px rgba(15, 15, 16, 0.18); } [data-theme='light'] ::-webkit-scrollbar-track { From 961a87fa32e9d6838b358621d70624b16bfb18a9 Mon Sep 17 00:00:00 2001 From: mandarwagh9 Date: Thu, 28 May 2026 20:12:16 +0530 Subject: [PATCH 03/11] fix(agent): tool-permission polling timeout no longer leaks setTimeout The 30-second deny fallback set a timer but the inner setTimeout chain that drove the poll loop continued ticking after the timer fired, keeping the promise alive past resolution under rare interleavings. Track the poll handle and a `resolved` flag so the timeout cancels the inner timer before flipping the decision, and the poll callback exits early if it fires after resolution. Behavior is unchanged for the happy path; only the hung-permission edge case improves. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/agent-tools.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/agent-tools.ts b/src/lib/agent-tools.ts index a28c700..71cd7ea 100644 --- a/src/lib/agent-tools.ts +++ b/src/lib/agent-tools.ts @@ -307,18 +307,28 @@ export async function executeTool(callId: string, name: string, args: Record | null = null; + let resolved = false; const timeout = setTimeout(() => { + if (resolved) return; + if (pollHandle) clearTimeout(pollHandle); useAIStore.getState().setPermissionDecision('deny'); }, 30000); await new Promise((resolve) => { const check = () => { + if (resolved) return; const pending = useAIStore.getState().pendingPermission; if (pending === null) { + resolved = true; clearTimeout(timeout); + pollHandle = null; resolve(); } else { - setTimeout(check, 100); + pollHandle = setTimeout(check, 100); } }; check(); From 879ca2c44a159a6e0e24b2859ea7abb0c55fc093 Mon Sep 17 00:00:00 2001 From: mandarwagh9 Date: Thu, 28 May 2026 20:12:21 +0530 Subject: [PATCH 04/11] fix(stores): bound persisted state to keep localStorage in budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both stores were quietly approaching the 5-10 MB browser quota: - aiStore: persisted the full messages[] forever. Long conversations pushed past the quota and the runtime silently dropped the whole store on rehydrate. Now slice(-50) before persist so only the most recent 50 messages survive a reload. - fileStore: persisted fileContents and originalContents Maps, serialized as Object.fromEntries — every open file's contents in localStorage. Drop both from `partialize`; rehydrate resets the Maps to empty so reopened tabs reload from disk via the normal read_file path. No user-visible behavior change beyond no longer losing settings under heavy use. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/stores/aiStore.ts | 5 ++++- src/stores/fileStore.ts | 27 +++++++-------------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/stores/aiStore.ts b/src/stores/aiStore.ts index 6f7835c..5db82b6 100644 --- a/src/stores/aiStore.ts +++ b/src/stores/aiStore.ts @@ -230,10 +230,13 @@ export const useAIStore = create()( }), { name: 'embedist-ai-store', + // Cap persisted history at 50 most-recent messages. Unbounded growth + // pushed users past the 5–10 MB localStorage quota on long sessions + // and forced the runtime to drop the whole store. partialize: (state) => ({ mode: state.mode, activeProvider: state.activeProvider, - messages: state.messages, + messages: state.messages.slice(-50), customModels: state.customModels, }), } diff --git a/src/stores/fileStore.ts b/src/stores/fileStore.ts index d8d0b4f..d248ca8 100644 --- a/src/stores/fileStore.ts +++ b/src/stores/fileStore.ts @@ -593,38 +593,25 @@ export const useFileStore = create()( }), { name: 'embedist-file-store', + // File contents are NOT persisted — they're reloaded from disk when a + // tab is reopened, which keeps localStorage bounded. We still persist + // the tab list, recent-files index, project root and board detection. onRehydrateStorage: () => (state) => { if (state) { - const savedFileContents = (state as unknown as { fileContents?: Record }).fileContents; - const savedOriginalContents = (state as unknown as { originalContents?: Record }).originalContents; - if (savedFileContents) { - state.fileContents = new Map(Object.entries(savedFileContents)); - } - if (savedOriginalContents) { - state.originalContents = new Map(Object.entries(savedOriginalContents)); - } - if (state.openTabs.length > 0) { - state.openTabs = state.openTabs.map(tab => { - const content = state.fileContents.get(tab.path); - if (content !== undefined) { - return { ...tab, content }; - } - return tab; - }); - } + // Reset content maps; they'll be populated when files reopen. + state.fileContents = new Map(); + state.originalContents = new Map(); } }, partialize: (state) => ({ rootPath: state.rootPath, projectName: state.projectName, - openTabs: state.openTabs, + openTabs: state.openTabs.map(({ content: _content, ...rest }) => rest), activeTabId: state.activeTabId, recentFiles: state.recentFiles, files: state.files, isPlatformIOProject: state.isPlatformIOProject, detectedBoard: state.detectedBoard, - fileContents: Object.fromEntries(state.fileContents), - originalContents: Object.fromEntries(state.originalContents), }), } ) From e835ea451dd87784a92df0125f1fb5bf38fd9100 Mon Sep 17 00:00:00 2001 From: mandarwagh9 Date: Thu, 28 May 2026 20:12:29 +0530 Subject: [PATCH 05/11] chore(release): add version-sync guard wired into CI CLAUDE.md flags the three independent version sources (package.json, src-tauri/Cargo.toml, src-tauri/tauri.conf.json) as a known drift hazard. Add scripts/check-version-sync.mjs that reads all three and exits non-zero on mismatch, and run it as the first step in the GitHub Actions workflow so any drift fails CI before the long build/test path. Also expose as `npm run check:versions` for local pre-release use. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 3 ++ scripts/check-version-sync.mjs | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 scripts/check-version-sync.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e941ec6..1c73fa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: - name: npm ci run: npm ci + - name: Check version sync + run: npm run check:versions + - name: TypeScript + Vite build run: npm run build diff --git a/scripts/check-version-sync.mjs b/scripts/check-version-sync.mjs new file mode 100644 index 0000000..a6bd246 --- /dev/null +++ b/scripts/check-version-sync.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Verifies that the app version is identical across the three sources of +// truth: package.json, src-tauri/Cargo.toml, src-tauri/tauri.conf.json. +// CLAUDE.md flags this as a known drift hazard; this script is wired into +// `npm run check:versions` and the CI workflow. + +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function readJson(rel) { + return JSON.parse(readFileSync(resolve(repoRoot, rel), 'utf8')); +} + +function readCargoVersion(rel) { + const text = readFileSync(resolve(repoRoot, rel), 'utf8'); + // First `version = "x.y.z"` line under [package] + const inPackage = text.split(/\r?\n/).reduce( + (acc, line) => { + if (acc.found) return acc; + const stripped = line.trim(); + if (stripped.startsWith('[')) { + return { ...acc, section: stripped, found: false }; + } + if (acc.section === '[package]') { + const m = stripped.match(/^version\s*=\s*"([^"]+)"/); + if (m) return { ...acc, version: m[1], found: true }; + } + return acc; + }, + { section: '', version: null, found: false } + ); + if (!inPackage.version) { + throw new Error(`No [package] version in ${rel}`); + } + return inPackage.version; +} + +const sources = [ + { name: 'package.json', version: readJson('package.json').version }, + { name: 'src-tauri/Cargo.toml', version: readCargoVersion('src-tauri/Cargo.toml') }, + { name: 'src-tauri/tauri.conf.json', version: readJson('src-tauri/tauri.conf.json').version }, +]; + +const unique = new Set(sources.map((s) => s.version)); +if (unique.size === 1) { + process.stdout.write(`OK — all three sources at v${[...unique][0]}\n`); + process.exit(0); +} + +process.stderr.write('Version drift detected:\n'); +for (const { name, version } of sources) { + process.stderr.write(` ${name}: ${version}\n`); +} +process.stderr.write( + '\nBump all three to the same value before continuing.\n' + + 'See CLAUDE.md "Release version sync" for the canonical list.\n' +); +process.exit(1); From 0eee5fceb6dd5ba63195cdcfc98f4722dee1419c Mon Sep 17 00:00:00 2001 From: mandarwagh9 Date: Thu, 28 May 2026 20:12:37 +0530 Subject: [PATCH 06/11] fix(rust): bound file reads, tree traversal, and grep file size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_file and grep_search previously called fs::read_to_string with no upper bound — a 1 GB binary in the project would OOM the app. Introduce MAX_FILE_SIZE = 50 MB: - read_file stats the file first and returns a friendly error past the limit - grep_search skips files past the limit during the recursive walk - new tests cover both the reject path (60 MB sparse file) and the small-file happy path get_directory_tree gained a MAX_TREE_NODES = 10_000 cap and a symlink short-circuit. The cap stops a huge repo from freezing the renderer; the symlink check prevents infinite-loop traversal through cycle paths. grep_search now logs read_dir failures via log::warn! instead of silently swallowing them — previously a permission-denied directory just disappeared from results with no signal. Tests in src-tauri/src/commands/filesystem.rs::tests: - before: 14 passing - after: 16 passing Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/commands/filesystem.rs | 124 ++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs index 29dc1e3..2c3953a 100644 --- a/src-tauri/src/commands/filesystem.rs +++ b/src-tauri/src/commands/filesystem.rs @@ -8,6 +8,17 @@ use parking_lot::Mutex; use std::sync::Arc; use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind}; +/// Maximum size of a file the IDE will read fully into memory. Anything +/// larger and `read_file` returns a friendly error instead of OOMing the +/// app. `grep_search` uses the same ceiling per file. 50 MB is generous +/// for source code while still preventing a 1 GB binary from blowing up +/// the process. +const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; + +/// Cap for total nodes returned by `get_directory_tree` so a huge repo +/// (or a symlink cycle) cannot exhaust memory or freeze the renderer. +const MAX_TREE_NODES: usize = 10_000; + fn normalize_lexical(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { @@ -126,6 +137,14 @@ pub struct DirectoryTree { #[command] pub fn read_file(path: String, root: String) -> Result { let p = validate_path(&path, &root)?; + let meta = fs::metadata(&p).map_err(|e| format!("Failed to stat file: {}", e))?; + if meta.len() > MAX_FILE_SIZE { + return Err(format!( + "File too large: {:.1} MB exceeds {} MB limit", + meta.len() as f64 / (1024.0 * 1024.0), + MAX_FILE_SIZE / (1024 * 1024) + )); + } fs::read_to_string(&p).map_err(|e| format!("Failed to read file: {}", e)) } @@ -221,14 +240,20 @@ pub fn list_directory(path: String, root: String) -> Result, Stri pub fn get_directory_tree(path: String, depth: Option, root: String) -> Result { validate_path(&path, &root)?; let max_depth = depth.unwrap_or(3); - - fn build_tree(path: &str, current_depth: u32, max_depth: u32) -> Result { + + fn build_tree( + path: &str, + current_depth: u32, + max_depth: u32, + node_count: &mut usize, + ) -> Result { let p = PathBuf::from(path); let name = p.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string()); - - if p.is_file() || current_depth >= max_depth { + + *node_count += 1; + if *node_count >= MAX_TREE_NODES || p.is_file() || current_depth >= max_depth { return Ok(DirectoryTree { name, path: path.to_string(), @@ -236,17 +261,31 @@ pub fn get_directory_tree(path: String, depth: Option, root: String) -> Res children: vec![], }); } - + + // Skip directories whose canonical form differs from the entry path + // (symlinks) to prevent infinite-loop traversal through symlink cycles. + if let (Ok(canonical), Ok(real)) = (p.canonicalize(), fs::read_link(&p)) { + log::debug!("skipping symlink during tree walk: {} -> {:?}", path, canonical); + let _ = real; + return Ok(DirectoryTree { + name, + path: path.to_string(), + is_dir: false, + children: vec![], + }); + } + let entries = fs::read_dir(path).map_err(|e| format!("Failed to read directory: {}", e))?; let mut children: Vec = Vec::new(); - + for entry in entries.filter_map(|e| e.ok()) { + if *node_count >= MAX_TREE_NODES { break; } let child_path = entry.path(); - if let Ok(child) = build_tree(&child_path.to_string_lossy(), current_depth + 1, max_depth) { + if let Ok(child) = build_tree(&child_path.to_string_lossy(), current_depth + 1, max_depth, node_count) { children.push(child); } } - + children.sort_by(|a, b| { match (a.is_dir, b.is_dir) { (true, false) => std::cmp::Ordering::Less, @@ -254,7 +293,7 @@ pub fn get_directory_tree(path: String, depth: Option, root: String) -> Res _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), } }); - + Ok(DirectoryTree { name, path: path.to_string(), @@ -262,8 +301,9 @@ pub fn get_directory_tree(path: String, depth: Option, root: String) -> Res children, }) } - - build_tree(&path, 0, max_depth) + + let mut node_count = 0_usize; + build_tree(&path, 0, max_depth, &mut node_count) } #[command] @@ -388,7 +428,10 @@ pub fn grep_search( if results.len() >= max { return; } let entries = match fs::read_dir(dir) { Ok(e) => e, - Err(_) => return, + Err(err) => { + log::warn!("grep_search: read_dir {} failed: {}", dir, err); + return; + } }; for entry in entries.filter_map(|e| e.ok()) { if results.len() >= max { break; } @@ -406,6 +449,11 @@ pub fn grep_search( .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); if !matches_file(&name, file_pat) { continue; } + // Skip files that exceed MAX_FILE_SIZE so a stray binary + // doesn't OOM the search. + if let Ok(meta) = fs::metadata(&path) { + if meta.len() > MAX_FILE_SIZE { continue; } + } if let Ok(content) = fs::read_to_string(&path) { for (line_num, line) in content.lines().enumerate() { if line.to_lowercase().contains(pat) { @@ -782,6 +830,58 @@ mod tests { assert_eq!(p, PathBuf::from("a/b")); } + // ---- file size limit ---- + #[test] + fn read_file_rejects_files_above_limit() { + let tmp = std::env::temp_dir().join(format!( + "embedist_size_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let root = tmp.canonicalize().unwrap(); + let huge = root.join("huge.bin"); + + // Sparse 60 MB file — we go just past MAX_FILE_SIZE (50 MB). + let f = std::fs::File::create(&huge).unwrap(); + f.set_len(60 * 1024 * 1024).unwrap(); + + let err = read_file( + huge.to_string_lossy().to_string(), + root.to_string_lossy().to_string(), + ) + .unwrap_err(); + assert!(err.contains("too large"), "got: {}", err); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn read_file_accepts_small_files() { + let tmp = std::env::temp_dir().join(format!( + "embedist_small_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let root = tmp.canonicalize().unwrap(); + let small = root.join("ok.txt"); + std::fs::write(&small, "hello").unwrap(); + + let content = read_file( + small.to_string_lossy().to_string(), + root.to_string_lossy().to_string(), + ) + .unwrap(); + assert_eq!(content, "hello"); + + std::fs::remove_dir_all(&root).ok(); + } + // ---- validate_path: traversal containment ---- #[test] fn validate_path_blocks_traversal_outside_root() { From f664423fa7d11d1143880e0b0428702fb0caee72 Mon Sep 17 00:00:00 2001 From: mandarwagh9 Date: Thu, 28 May 2026 20:12:42 +0530 Subject: [PATCH 07/11] feat(ai): surface per-message + cumulative token cost aiStore already tracked usage on every AIMessage but never showed it to the user. They were flying blind on $$ for paid models (Sonnet at $3/$15 per Mtok, Opus at $15/$75) and on token budget for everyone. - New src/lib/ai-pricing.ts: PRICING table for the OpenAI, Anthropic, DeepSeek, and Google models exposed in Settings; Ollama and local models render $0; unknown models render counts without a misleading cost. estimateCostUSD and formatUSD helpers do the rounding so $0.0003 doesn't display as $0.00. - MessageBubble footer shows total + prompt/completion split + USD. - AIChatPanel context bar tallies cumulative tokens + cost across every assistant message in the current conversation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/AI/AIChatPanel.tsx | 49 ++++++++++++----- src/components/AI/MessageBubble.css | 14 +++++ src/components/AI/MessageBubble.tsx | 33 +++++++++--- src/lib/ai-pricing.ts | 82 +++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 src/lib/ai-pricing.ts diff --git a/src/components/AI/AIChatPanel.tsx b/src/components/AI/AIChatPanel.tsx index bb9be1c..751b104 100644 --- a/src/components/AI/AIChatPanel.tsx +++ b/src/components/AI/AIChatPanel.tsx @@ -18,6 +18,7 @@ import { FileReferencePicker } from './FileReferencePicker'; import { SYSTEM_PROMPTS } from '../../lib/ai-prompts'; import type { AIMode } from '../../lib/ai-prompts'; import type { FileNode } from '../../types'; +import { estimateCostUSD, formatTokens, formatUSD } from '../../lib/ai-pricing'; import './AIChatPanel.css'; const MODE_COLORS: Record = { @@ -547,19 +548,41 @@ function AIChatPanelContent() { {showInput && (
- {messages.length > 0 && ( -
- - - - - - - - {messages.length} message{messages.length !== 1 ? 's' : ''} · mode: {MODE_LABELS[mode]} · provider: {providerLabel} - -
- )} + {messages.length > 0 && (() => { + const totals = messages.reduce( + (acc, m) => { + if (m.usage) { + acc.prompt += m.usage.prompt_tokens; + acc.completion += m.usage.completion_tokens; + } + return acc; + }, + { prompt: 0, completion: 0 } + ); + const totalTokens = totals.prompt + totals.completion; + const providerCfg = useSettingsStore.getState().providers[activeProvider as keyof ReturnType['providers']]; + const model = providerCfg?.model ?? null; + const cost = estimateCostUSD(model, totals.prompt, totals.completion); + return ( +
+ + + + + + + + {messages.length} message{messages.length !== 1 ? 's' : ''} · {MODE_LABELS[mode]} · {providerLabel} + {totalTokens > 0 && ( + <> + {' · '}{formatTokens(totalTokens)} tokens + {cost !== null && (<> · {formatUSD(cost)})} + + )} + +
+ ); + })()}