diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000000..c25ae2a6fc --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,104 @@ +# Changes Summary: Race Condition Fixes with Optimistic Locking + +## Overview + +This implementation addresses race conditions in tab metadata updates (spec-004) by implementing optimistic locking with version checking. The changes prevent TOCTOU (Time-Of-Check-Time-Of-Use) vulnerabilities in concurrent metadata operations. + +## Files Modified + +### Backend (Go) + +#### `pkg/wstore/wstore_dbops.go` +- Added `ErrVersionMismatch` error variable for concurrent modification detection +- Added `ErrObjectLocked` error variable for lock state rejection + +#### `pkg/wstore/wstore.go` +- Added `UpdateObjectMetaWithVersion()` function: + - Performs optimistic locking update with version checking + - If `expectedVersion > 0` and doesn't match current version, returns `ErrVersionMismatch` + - If `expectedVersion == 0`, behaves like `UpdateObjectMeta` (no version check) + +- Added `UpdateObjectMetaIfNotLocked()` function: + - Atomically checks lock and updates metadata + - Lock is checked INSIDE the transaction, eliminating TOCTOU vulnerability + - Returns `ErrObjectLocked` (wrapped in `ErrVersionMismatch`) if locked + - Returns `ErrVersionMismatch` if version doesn't match + +#### `pkg/service/objectservice/objectservice.go` +- Added `UpdateObjectMetaWithVersion()` RPC service method +- Added `UpdateObjectMetaIfNotLocked()` RPC service method +- Both methods include proper metadata annotations for TypeScript binding generation + +### Frontend (TypeScript) + +#### `frontend/app/view/term/termwrap.ts` +- Added debounce map (`osc7DebounceMap`) for OSC 7 updates per tab +- Added `OSC7_DEBOUNCE_MS = 300` constant for debounce delay +- Added `clearOsc7Debounce()` helper function +- Added `cleanupOsc7DebounceForTab()` exported function for memory leak prevention +- Updated `handleOsc7Command()` to: + - Add null safety check for `tabData?.oid` + - Use debouncing to reduce race condition window + - Use atomic lock-aware update (`UpdateObjectMetaIfNotLocked`) instead of regular update + - Gracefully handle version mismatch and locked state errors + +#### `frontend/app/tab/tab.tsx` +- Added `getApi` to imports from `@/app/store/global` (fix for pre-existing missing import) + +### Generated Files + +#### `frontend/app/store/services.ts` +- Auto-generated new TypeScript methods: + - `UpdateObjectMetaWithVersion(oref, meta, expectedVersion)` + - `UpdateObjectMetaIfNotLocked(oref, meta, lockKey, expectedVersion)` + +## Key Features Implemented + +### 1. Optimistic Locking +- Uses existing `version` field in WaveObj types +- Version checked inside transaction to prevent TOCTOU +- Atomic increment of version on successful update (already implemented in `DBUpdate`) + +### 2. Error Types +- **ErrVersionMismatch**: Indicates concurrent modification detected +- **ErrObjectLocked**: Indicates update rejected due to lock state +- Both errors are wrapped appropriately for consistent error handling + +### 3. OSC 7 Debouncing +- 300ms debounce window for rapid directory changes +- Per-tab debounce timers in a Map +- Cleanup function to prevent memory leaks on tab close + +### 4. Atomic Lock Checking +- Lock state checked INSIDE database transaction +- Eliminates race condition between lock check and update +- If lock is toggled during update, the update is safely rejected + +## Acceptance Criteria Status + +- [x] `UpdateObjectMetaWithVersion` added to `wstore.go` +- [x] RPC endpoints added to `objectservice.go` +- [x] OSC 7 debounce map with cleanup function +- [x] Null safety guards in `termwrap.ts` +- [x] `ErrVersionMismatch` error type created +- [x] `ErrObjectLocked` error type created +- [x] TypeScript compilation passes (our files) +- [x] Go compilation passes +- [x] Changes committed + +## Testing Notes + +To test the implementation: + +1. **Version Mismatch Test**: Open two terminals in the same tab, rapidly change directories in both - the race condition should be handled gracefully + +2. **Lock Bypass Test**: Toggle the lock while an OSC 7 update is in flight - the update should be rejected if lock is set + +3. **Debounce Test**: Rapidly `cd` between directories - only the final directory should be set as basedir + +4. **Memory Leak Test**: Open and close multiple tabs - the debounce map should be cleaned up + +## Notes + +- The spec mentions retry logic for manual updates (handleSetBaseDir, handleToggleLock) - this was NOT implemented as the spec noted it as optional for Phase 4 and the core race condition fixes are functional without it +- Pre-existing TypeScript errors in unrelated files (streamdown.tsx, notificationpopover.tsx) remain unfixed as they are not related to this implementation diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..1471fd7eaf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,386 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Wave Terminal is an open-source, AI-native terminal built with Electron. It combines traditional terminal features with graphical capabilities like file previews, web browsing, and AI assistance. The architecture consists of four main components: + +1. **Frontend** (React + TypeScript) - UI and user interactions +2. **emain** (Electron Main Process) - Window management, native OS integration +3. **wavesrv** (Go Backend) - Core business logic, database, remote connections +4. **wsh** (Go CLI/Server) - Command-line tool and remote multiplexing server + +## Build System + +The project uses **Task** (modern Make alternative) for build orchestration. See `Taskfile.yml` for all available tasks. + +### Common Commands + +```bash +# Install dependencies (run this first after cloning) +task init + +# Development server with hot reload +task dev + +# Run standalone without hot reload +task start + +# Production build and packaging +task package + +# TypeScript type checking +task check:ts + +# Run tests +npm test + +# Run tests with coverage +npm run coverage + +# Clean build artifacts +task clean +``` + +### Quick Development Shortcuts + +```bash +# Fast development mode (macOS ARM64 only, no docsite, no wsh) +task electron:quickdev + +# Fast development mode (Windows x64 only, no docsite, no wsh) +task electron:winquickdev +``` + +### Code Generation + +The project uses code generators to maintain type safety between Go and TypeScript: + +```bash +# Generate TypeScript bindings from Go types +task generate + +# This runs: +# - cmd/generatets/main-generatets.go -> frontend/types/gotypes.d.ts +# - cmd/generatets/main-generatets.go -> frontend/app/store/services.ts +# - cmd/generatets/main-generatets.go -> frontend/app/store/wshclientapi.ts +# - cmd/generatego/main-generatego.go -> various Go files +``` + +**Always run `task generate` after modifying:** +- Go RPC types in `pkg/wshrpc/` +- Service definitions in `pkg/service/` +- Wave object types in `pkg/waveobj/` + +## Architecture Overview + +### Frontend Architecture + +**Entry Point:** `frontend/wave.ts` +- Initializes the application with either Wave Terminal mode or Tsunami Builder mode +- Sets up Jotai store, WPS (WebSocket Pub/Sub), and Monaco editor +- Root React component: `frontend/app/app.tsx` + +**State Management:** Jotai (atom-based state) +- Global atoms defined in `frontend/app/store/global.ts` +- Store instance: `globalStore` (exported from `frontend/app/store/jotaiStore.ts`) +- Key models: `GlobalModel`, `TabModel`, `ConnectionsModel` + +**Key Frontend Directories:** +- `frontend/app/block/` - Terminal blocks and renderers +- `frontend/app/view/` - Different view types (terminal, preview, web, etc.) +- `frontend/app/workspace/` - Workspace and tab layout management +- `frontend/layout/` - Layout system using `react-resizable-panels` +- `frontend/app/store/` - State management, RPC clients, WOS (Wave Object Store) +- `frontend/app/element/` - Reusable UI components +- `frontend/app/monaco/` - Monaco editor integration + +**Hot Module Reloading:** +- Vite enables HMR for most changes +- State changes (Jotai atoms, layout) may require hard reload: `Cmd+Shift+R` / `Ctrl+Shift+R` + +### Electron Main Process (emain) + +**Entry Point:** `emain/emain.ts` +- Manages Electron app lifecycle and window creation +- Spawns and manages the `wavesrv` backend process +- Handles native menus, context menus, and OS integration + +**IPC Communication:** +- Functions exposed from emain to frontend are defined in two places: + 1. `emain/preload.ts` - Electron preload script + 2. `frontend/types/custom.d.ts` - TypeScript declarations +- Frontend calls: `getApi().()` + +**Key emain Files:** +- `emain/emain.ts` - Main entry point +- `emain/emain-window.ts` - Window management +- `emain/emain-menu.ts` - Menu bar and context menus +- `emain/emain-wavesrv.ts` - wavesrv process management +- `emain/emain-tabview.ts` - Tab view management +- `emain/preload.ts` - Preload script for renderer + +### Go Backend (wavesrv) + +**Entry Point:** `cmd/server/main-server.go` + +**Core Packages:** +- `pkg/wstore/` - Database operations and Wave object persistence +- `pkg/waveobj/` - Wave object type definitions (Client, Window, Tab, Block, etc.) +- `pkg/service/` - HTTP service endpoints +- `pkg/wshrpc/` - WebSocket RPC system (communication with frontend and wsh) +- `pkg/blockcontroller/` - Terminal block lifecycle management +- `pkg/remote/` - SSH and remote connection handling +- `pkg/wcloud/` - Cloud sync and authentication +- `pkg/waveai/` - AI integration (OpenAI, Claude, etc.) +- `pkg/filestore/` - File storage and management + +**Database:** +- SQLite databases in `db/migrations-wstore/` and `db/migrations-filestore/` +- Wave objects: `Client`, `Window`, `Workspace`, `Tab`, `Block`, `LayoutState` +- All Wave object types registered in `pkg/waveobj/waveobj.go` + +**RPC Communication:** +- Uses custom `wshrpc` protocol over WebSocket +- RPC types defined in `pkg/wshrpc/wshrpctypes.go` +- Commands implemented in `pkg/wshrpc/wshserver/` and `pkg/wshrpc/wshremote/` + +### wsh (Wave Shell) + +**Entry Point:** `cmd/wsh/main-wsh.go` + +**Dual Purpose:** +1. CLI tool for controlling Wave from the terminal +2. Remote server for multiplexing connections and file streaming + +**Communication:** +- Uses `wshrpc` protocol over domain socket or WebSocket +- Enables single-connection multiplexing for remote terminals + +## Development Guidelines + +### Frontend Development + +1. **Use existing patterns:** Before adding new components, search for similar features: + ```bash + # Find similar views + grep -r "registerView" frontend/app/view/ + + # Find block implementations + ls frontend/app/block/ + ``` + +2. **State management:** Use Jotai atoms for reactive state + - Global atoms in `frontend/app/store/global.ts` + - Component-local atoms using `atom()` from `jotai` + +3. **RPC calls:** Use the generated `RpcApi` from `frontend/app/store/wshclientapi.ts`: + ```typescript + import { RpcApi } from "@/app/store/wshclientapi"; + import { TabRpcClient } from "@/app/store/wshrpcutil"; + + const result = await RpcApi.SomeCommand(TabRpcClient, { param: "value" }); + ``` + +4. **Wave Objects:** Access via WOS (Wave Object Store): + ```typescript + import * as WOS from "@/store/wos"; + + const tab = WOS.getObjectValue(WOS.makeORef("tab", tabId)); + ``` + +### Backend Development + +1. **Database changes:** Add migrations to `db/migrations-wstore/` or `db/migrations-filestore/` + +2. **New RPC commands:** + - Define in `pkg/wshrpc/wshrpctypes.go` + - Implement handler in `pkg/wshrpc/wshserver/` + - Run `task generate` to update TypeScript bindings + +3. **New Wave object types:** + - Add to `pkg/waveobj/wtype.go` + - Register in `init()` function + - Run `task generate` + +4. **Testing:** Write tests in `*_test.go` files: + ```bash + # Run Go tests + go test ./pkg/... + + # Run specific package + go test ./pkg/wstore/ + ``` + +### Code Style + +- **TypeScript:** Prettier + ESLint (configured in `eslint.config.js`, `prettier.config.cjs`) +- **Go:** Standard `go fmt` + `staticcheck` (see `staticcheck.conf`) +- **Text files:** Must end with a newline (`.editorconfig`) + +## Tsunami Framework + +Tsunami is Wave's internal UI framework for building reactive Go-based UIs that render in the terminal. + +**Location:** `tsunami/` directory +- `tsunami/engine/` - Core rendering engine +- `tsunami/frontend/` - React components for Tsunami UIs +- `tsunami/vdom/` - Virtual DOM implementation + +**Usage:** +- Powers the WaveApp Builder (`/builder/`) +- Scaffold for new Tsunami apps in `dist/tsunamiscaffold/` + +**Build Tsunami:** +```bash +task tsunami:frontend:build +task build:tsunamiscaffold +``` + +## Testing & Debugging + +### Frontend Debugging + +- **DevTools:** `Cmd+Option+I` (macOS) or `Ctrl+Option+I` (Windows/Linux) +- **Console access to global state:** + ```javascript + globalStore + globalAtoms + WOS + RpcApi + ``` + +### Backend Debugging + +- **Logs:** `~/.waveterm-dev/waveapp.log` (development mode) +- Contains both NodeJS (emain) and Go (wavesrv) logs + +### Running Tests + +```bash +# TypeScript/React tests +npm test + +# With coverage +npm run coverage + +# Go tests +go test ./pkg/... +``` + +## File Organization Conventions + +- **Go files:** `packagename_descriptor.go` (e.g., `waveobj_wtype.go`) +- **TypeScript files:** `component-name.tsx`, `util-name.ts` +- **SCSS files:** `component-name.scss` +- **Test files:** `*_test.go`, `*.test.ts`, `*.test.tsx` + +## Platform-Specific Notes + +### Windows + +- Use Zig for CGO static linking +- Use `task electron:winquickdev` for fast iteration +- Backslashes in file paths for Edit/MultiEdit tools + +### Linux + +- Requires Zig for CGO static linking +- Platform-specific dependencies in `BUILD.md` +- Use `USE_SYSTEM_FPM=1 task package` on ARM64 + +### macOS + +- No special dependencies +- `task electron:quickdev` works on ARM64 only + +## Important Paths + +- **Frontend entry:** `frontend/wave.ts` +- **Main React app:** `frontend/app/app.tsx` +- **Electron main:** `emain/emain.ts` +- **Go backend entry:** `cmd/server/main-server.go` +- **wsh entry:** `cmd/wsh/main-wsh.go` +- **Generated types:** `frontend/types/gotypes.d.ts` +- **RPC API:** `frontend/app/store/wshclientapi.ts` +- **Dev logs:** `~/.waveterm-dev/waveapp.log` + +## Common Gotchas + +1. **After changing Go types, always run `task generate`** - TypeScript bindings won't update automatically +2. **emain and wavesrv don't hot-reload** - Must restart `task dev` to see changes +3. **Jotai atom changes may break HMR** - Use hard reload (`Cmd+Shift+R`) +4. **Database schema changes require migrations** - Never modify schema directly +5. **Wave objects must be registered** - Add to `init()` in `pkg/waveobj/waveobj.go` + +## Tab Base Directory Feature + +Wave Terminal supports per-tab base directories that provide a project-centric workflow where all terminals and widgets within a tab share the same working directory context. + +### Metadata Keys + +| Key | Type | Description | +|-----|------|-------------| +| `tab:basedir` | `string` | Absolute path to base directory | +| `tab:basedirlock` | `boolean` | When true, disables smart auto-detection | + +### Behavior Model + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TAB │ +│ tab:basedir = "/home/user/project" │ +│ tab:basedirlock = false │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ +│ │ Terminal 1 │ │ Terminal 2 │ │ File View │ │ +│ │ cmd:cwd = ... │ │ cmd:cwd = ... │ │ file = ... │ │ +│ │ (inherits tab) │ │ (inherits tab) │ │ (inherits) │ │ +│ └─────────────────┘ └─────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Smart Auto-Detection (OSC 7) + +When a terminal reports its working directory via OSC 7: + +1. **Always:** Updates block's `cmd:cwd` metadata +2. **Conditionally:** Updates tab's `tab:basedir` if: + - `tab:basedirlock` is false + - `tab:basedir` is empty OR equals "~" + +This allows the first terminal to "teach" the tab its project directory. + +### Lock Semantics + +| State | Behavior | +|-------|----------| +| Unlocked (default) | OSC 7 can update `tab:basedir` (under conditions) | +| Locked | Only manual setting changes `tab:basedir` | + +### File Locations + +| Purpose | File | +|---------|------| +| Tab context menu UI | `frontend/app/tab/tab.tsx` | +| OSC 7 handling | `frontend/app/view/term/termwrap.ts` | +| Terminal inheritance | `frontend/app/store/keymodel.ts` | +| Widget inheritance | `frontend/app/workspace/widgets.tsx` | +| Go type definitions | `pkg/waveobj/wtypemeta.go` | +| Metadata constants | `pkg/waveobj/metaconsts.go` | + +### Related Presets + +Tab variable presets can include base directory configuration: + +```json +// File: pkg/wconfig/defaultconfig/presets/tabvars.json +{ + "tabvar@my-project": { + "display:name": "My Project", + "tab:basedir": "/home/user/my-project", + "tab:basedirlock": true + } +} +``` diff --git a/docs/docs/tabs.mdx b/docs/docs/tabs.mdx index 354089be4c..11fe415952 100644 --- a/docs/docs/tabs.mdx +++ b/docs/docs/tabs.mdx @@ -45,6 +45,71 @@ You can switch to an existing tab by clicking on it in the tab bar. You can also Pinning a tab makes it harder to close accidentally. You can pin a tab by right-clicking on it and selecting "Pin Tab" from the context menu that appears. You can also pin a tab by dragging it to a lesser index than an existing pinned tab. When a tab is pinned, the button for the tab will be replaced with a button. Clicking this button will unpin the tab. You can also unpin a tab by dragging it to an index higher than an existing unpinned tab. +### Tab Base Directory + +You can set a base directory for each tab to create project-centric workflows. All terminals and file previews launched within the tab will use this directory as their starting point. + +#### Setting the Base Directory + +1. Right-click on a tab in the tab bar +2. Select "Base Directory" from the context menu +3. Click "Set Base Directory..." +4. Choose a directory using the file picker + +Once set, the base directory will be displayed in the tab header with a folder icon. All new terminals opened in that tab will start in this directory. + +#### Smart Auto-Detection + +Wave Terminal can automatically detect and set the base directory from your terminal's working directory. When you `cd` into a project directory, Wave uses OSC 7 escape sequences to learn this location. + +Auto-detection occurs when: +- The tab has no base directory set yet +- The base directory is not locked + +This means the first directory you navigate to in a new tab becomes the tab's base directory automatically. + +#### Locking the Base Directory + +To prevent auto-detection from changing your base directory: + +1. Right-click on the tab +2. Select "Base Directory" +3. Click "Lock (Disable Smart Detection)" + +A lock icon will appear next to the base directory indicator in the tab header. When locked, you can still manually change the base directory, but terminal navigation will not affect it. Click the lock icon directly to toggle the lock state. + +#### Clearing the Base Directory + +To remove the base directory and return to default behavior: + +1. Right-click on the tab +2. Select "Base Directory" +3. Click "Clear Base Directory" + +#### Use Cases + +- **Project-centric workflows:** Set tab base directory to project root; all terminals start there +- **Multi-project organization:** Different tabs for different projects, each with its own base directory +- **Stable context:** Lock the base directory when navigating to multiple subdirectories + +#### Configuration via Presets + +You can create presets that include base directory configuration. Add entries to your settings: + +```json +{ + "presets": { + "tabvar@my-project": { + "display:name": "My Project", + "tab:basedir": "/path/to/my-project", + "tab:basedirlock": true + } + } +} +``` + +Apply presets via the tab's right-click menu: "Tab Variables" > Select preset + ## Tab Layout System The tabs are comprised of tiled blocks. The contents of each block is a single widget. You can move blocks around and arrange them into layouts that best-suit your workflow. You can also magnify blocks to focus on a specific widget. diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 09ba69d1eb..996cb3b2dd 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -453,4 +453,90 @@ export function initIpcHandlers() { electron.ipcMain.on("do-refresh", (event) => { event.sender.reloadIgnoringCache(); }); + + electron.ipcMain.handle( + "show-open-dialog", + async ( + event: electron.IpcMainInvokeEvent, + options: { + title?: string; + defaultPath?: string; + properties?: Array<"openFile" | "openDirectory" | "multiSelections" | "showHiddenFiles">; + filters?: Array<{ name: string; extensions: string[] }>; + } + ): Promise => { + // SECURITY: Restrict to directory selection only for this feature + const allowedProperties = + options.properties?.filter((p) => ["openDirectory", "showHiddenFiles"].includes(p)) || + ["openDirectory"]; + + // SECURITY: Sanitize defaultPath + let sanitizedDefaultPath = options.defaultPath; + if (sanitizedDefaultPath) { + // CRITICAL SECURITY: Block UNC paths on Windows to prevent network attacks + // UNC paths like \\attacker.com\share can leak credentials or data + if (process.platform === "win32" && /^[\\/]{2}[^\\/]/.test(sanitizedDefaultPath)) { + console.warn("show-open-dialog: blocked UNC path in defaultPath:", sanitizedDefaultPath); + sanitizedDefaultPath = electronApp.getPath("home"); + } else { + // Expand home directory shorthand + if (sanitizedDefaultPath.startsWith("~")) { + sanitizedDefaultPath = sanitizedDefaultPath.replace(/^~/, electronApp.getPath("home")); + } + // Normalize path to resolve any .. components + sanitizedDefaultPath = path.normalize(sanitizedDefaultPath); + + // Validate the path exists and is accessible + try { + await fs.promises.access(sanitizedDefaultPath, fs.constants.R_OK); + } catch { + // Fall back to home directory if path doesn't exist or isn't readable + sanitizedDefaultPath = electronApp.getPath("home"); + } + } + } + + // Get the appropriate parent window + const ww = getWaveWindowByWebContentsId(event.sender.id); + const parentWindow = ww ?? electron.BrowserWindow.getFocusedWindow(); + + const result = await electron.dialog.showOpenDialog(parentWindow, { + title: options.title ?? "Select Directory", + defaultPath: sanitizedDefaultPath, + properties: allowedProperties as electron.OpenDialogOptions["properties"], + filters: options.filters, + }); + + // Return empty array if canceled + if (result.canceled || !result.filePaths) { + return []; + } + + // SECURITY: Validate returned paths + const validPaths: string[] = []; + for (const filePath of result.filePaths) { + // CRITICAL SECURITY: Block UNC paths in returned values on Windows + if (process.platform === "win32" && /^[\\/]{2}[^\\/]/.test(filePath)) { + console.warn("show-open-dialog: blocked UNC path in result:", filePath); + continue; + } + + try { + const stats = await fs.promises.stat(filePath); + if (allowedProperties.includes("openDirectory")) { + if (stats.isDirectory()) { + validPaths.push(filePath); + } + } else { + validPaths.push(filePath); + } + } catch { + // Skip paths that can't be accessed + console.warn("show-open-dialog: skipping inaccessible path:", filePath); + } + } + + return validPaths; + } + ); } diff --git a/emain/preload.ts b/emain/preload.ts index c6bdf14988..59eec51e37 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -68,6 +68,12 @@ contextBridge.exposeInMainWorld("api", { openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), + showOpenDialog: (options: { + title?: string; + defaultPath?: string; + properties?: Array<"openFile" | "openDirectory" | "multiSelections" | "showHiddenFiles">; + filters?: Array<{ name: string; extensions: string[] }>; + }) => ipcRenderer.invoke("show-open-dialog", options), }); // Custom event for "new-window" diff --git a/frontend/app/monaco/schemaendpoints.ts b/frontend/app/monaco/schemaendpoints.ts index 2b3134e215..839bb61498 100644 --- a/frontend/app/monaco/schemaendpoints.ts +++ b/frontend/app/monaco/schemaendpoints.ts @@ -5,6 +5,7 @@ import settingsSchema from "../../../schema/settings.json"; import connectionsSchema from "../../../schema/connections.json"; import aipresetsSchema from "../../../schema/aipresets.json"; import bgpresetsSchema from "../../../schema/bgpresets.json"; +import tabvarspresetsSchema from "../../../schema/tabvarspresets.json"; import waveaiSchema from "../../../schema/waveai.json"; import widgetsSchema from "../../../schema/widgets.json"; @@ -35,6 +36,11 @@ const MonacoSchemas: SchemaInfo[] = [ fileMatch: ["*/WAVECONFIGPATH/presets/bg.json"], schema: bgpresetsSchema, }, + { + uri: "wave://schema/tabvarspresets.json", + fileMatch: ["*/WAVECONFIGPATH/presets/tabvars.json", "*/WAVECONFIGPATH/presets/tabvars/*.json"], + schema: tabvarspresetsSchema, + }, { uri: "wave://schema/waveai.json", fileMatch: ["*/WAVECONFIGPATH/waveai.json"], diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index fe6e5b1685..da1ff6e440 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -116,6 +116,13 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { }) as Atom; // this is *the* tab that this tabview represents. it should never change. const staticTabIdAtom: Atom = atom(initOpts.tabId); + const activeTabAtom: Atom = atom((get) => { + const staticTabId = get(staticTabIdAtom); + if (staticTabId == null) { + return null; + } + return WOS.getObjectValue(WOS.makeORef("tab", staticTabId), get); + }); const controlShiftDelayAtom = atom(false); const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; try { @@ -169,6 +176,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { settingsAtom, hasCustomAIPresetsAtom, staticTabId: staticTabIdAtom, + activeTab: activeTabAtom, isFullScreen: isFullScreenAtom, zoomFactorAtom, controlShiftDelayAtom, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 318d1d775a..880eba2825 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -314,7 +314,7 @@ function globalRefocus() { refocusNode(blockId); } -function getDefaultNewBlockDef(): BlockDef { +async function getDefaultNewBlockDef(): Promise { const adnbAtom = getSettingsKeyAtom("app:defaultnewblock"); const adnb = globalStore.get(adnbAtom) ?? "term"; if (adnb == "launcher") { @@ -331,6 +331,36 @@ function getDefaultNewBlockDef(): BlockDef { controller: "shell", }, }; + + // ===== Tab Base Directory Inheritance ===== + // When creating new terminals via keyboard shortcuts (e.g., Cmd+N, Cmd+D), + // inherit the tab's base directory as the terminal's initial working directory. + // This ensures new terminals in the same tab start in the same project context. + // + // Inheritance priority: + // 1. Focused block's cmd:cwd (copy directory from existing terminal) + // 2. Tab's tab:basedir (use tab-level project directory) + // 3. Default (typically home directory ~) + const tabData = globalStore.get(atoms.activeTab); + let tabBaseDir = tabData?.meta?.["tab:basedir"]; + + // Pre-use validation: quickly validate tab basedir before using it + if (tabBaseDir && tabBaseDir.trim() !== "") { + try { + const { validateTabBasedir } = await import("@/store/tab-basedir-validator"); + const validationResult = await validateTabBasedir(tabData.oid, tabBaseDir); + if (!validationResult.valid) { + console.warn( + `[keymodel] Tab basedir validation failed at use-time: ${tabBaseDir} (${validationResult.reason}). Falling back to home directory.` + ); + tabBaseDir = null; // Fall back to home directory + } + } catch (error) { + console.error("[keymodel] Failed to validate tab basedir:", error); + tabBaseDir = null; // Fall back to home directory on error + } + } + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { @@ -345,11 +375,17 @@ function getDefaultNewBlockDef(): BlockDef { termBlockDef.meta.connection = blockData.meta.connection; } } + + // If no cwd from focused block, use tab base directory (if valid) + if (termBlockDef.meta["cmd:cwd"] == null && tabBaseDir != null) { + termBlockDef.meta["cmd:cwd"] = tabBaseDir; + } + return termBlockDef; } async function handleCmdN() { - const blockDef = getDefaultNewBlockDef(); + const blockDef = await getDefaultNewBlockDef(); await createBlock(blockDef); } @@ -359,7 +395,7 @@ async function handleSplitHorizontal(position: "before" | "after") { if (focusedNode == null) { return; } - const blockDef = getDefaultNewBlockDef(); + const blockDef = await getDefaultNewBlockDef(); await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, position); } @@ -369,7 +405,7 @@ async function handleSplitVertical(position: "before" | "after") { if (focusedNode == null) { return; } - const blockDef = getDefaultNewBlockDef(); + const blockDef = await getDefaultNewBlockDef(); await createBlockSplitVertically(blockDef, focusedNode.data.blockId, position); } diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 7a36718c37..2e830b42ac 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -84,6 +84,16 @@ class ObjectServiceType { return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments)) } + // @returns object updates + UpdateObjectMetaIfNotLocked(oref: string, meta: MetaType, lockKey: string, expectedVersion: number): Promise { + return WOS.callBackendService("object", "UpdateObjectMetaIfNotLocked", Array.from(arguments)) + } + + // @returns object updates + UpdateObjectMetaWithVersion(oref: string, meta: MetaType, expectedVersion: number): Promise { + return WOS.callBackendService("object", "UpdateObjectMetaWithVersion", Array.from(arguments)) + } + // @returns object updates UpdateTabName(tabId: string, name: string): Promise { return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments)) diff --git a/frontend/app/store/tab-basedir-validation-hook.ts b/frontend/app/store/tab-basedir-validation-hook.ts new file mode 100644 index 0000000000..036694baf4 --- /dev/null +++ b/frontend/app/store/tab-basedir-validation-hook.ts @@ -0,0 +1,77 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { fireAndForget } from "@/util/util"; +import { globalStore } from "./jotaiStore"; +import { activeTabIdAtom, getTabModelByTabId } from "./tab-model"; +import { validateTabBasedir } from "./tab-basedir-validator"; +import * as WOS from "./wos"; + +const DEBOUNCE_INTERVAL_MS = 500; // Minimum time between validations + +// Validate tab basedir when tab is activated +async function validateActiveTabBasedir(tabId: string): Promise { + if (!tabId) return; + + const tabModel = getTabModelByTabId(tabId); + const lastValidationTime = globalStore.get(tabModel.lastValidationTimeAtom); + const now = Date.now(); + + // Debounce: skip if validated recently + if (now - lastValidationTime < DEBOUNCE_INTERVAL_MS) { + return; + } + + // Get tab data + const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + const tabData = globalStore.get(tabAtom); + + if (!tabData) { + return; + } + + const basedir = tabData.meta?.["tab:basedir"]; + + // Skip validation if no basedir set + if (!basedir || basedir.trim() === "") { + return; + } + + // Update validation state to pending + globalStore.set(tabModel.basedirValidationAtom, "pending"); + globalStore.set(tabModel.lastValidationTimeAtom, now); + + // Perform validation + const result = await validateTabBasedir(tabId, basedir); + + if (result.valid) { + // Update validation state to valid + globalStore.set(tabModel.basedirValidationAtom, "valid"); + } else { + // Update validation state to invalid + globalStore.set(tabModel.basedirValidationAtom, "invalid"); + + // Handle stale basedir (will clear and notify) + if (result.reason) { + const { handleStaleBasedir } = await import("./tab-basedir-validator"); + await handleStaleBasedir(tabId, basedir, result.reason); + } + } +} + +// Initialize tab validation hook +export function initTabBasedirValidation(): void { + // Subscribe to activeTabIdAtom changes + globalStore.sub(activeTabIdAtom, () => { + const activeTabId = globalStore.get(activeTabIdAtom); + if (activeTabId) { + fireAndForget(() => validateActiveTabBasedir(activeTabId)); + } + }); + + // Also validate the initial active tab + const initialActiveTabId = globalStore.get(activeTabIdAtom); + if (initialActiveTabId) { + fireAndForget(() => validateActiveTabBasedir(initialActiveTabId)); + } +} diff --git a/frontend/app/store/tab-basedir-validator.ts b/frontend/app/store/tab-basedir-validator.ts new file mode 100644 index 0000000000..0b4c41769e --- /dev/null +++ b/frontend/app/store/tab-basedir-validator.ts @@ -0,0 +1,342 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { fireAndForget } from "@/util/util"; +import { globalStore } from "./jotaiStore"; +import { ObjectService } from "./services"; +import * as WOS from "./wos"; + +export type StalePathReason = + | "not_found" // ENOENT - path does not exist + | "not_directory" // Path exists but is not a directory + | "access_denied" // EACCES - no permission to access + | "network_error" // Timeout or network failure (after retries) + | "unknown_error"; // Other errors + +export interface PathValidationResult { + valid: boolean; + path: string; + reason?: StalePathReason; + fileInfo?: FileInfo; +} + +interface RetryConfig { + maxAttempts: number; + timeoutPerAttempt: number; + delayBetweenRetries: number; + totalWindow: number; +} + +const defaultRetryConfig: RetryConfig = { + maxAttempts: 3, + timeoutPerAttempt: 10000, // 10 seconds per attempt + delayBetweenRetries: 1000, // 1 second delay between retries + totalWindow: 30000, // Maximum 30 seconds total +}; + +// Sleep utility +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Classify error into StalePathReason +function classifyError(error: any): StalePathReason { + const errorStr = String(error?.message || error || "").toLowerCase(); + + // ENOENT - file not found + if (errorStr.includes("enoent") || errorStr.includes("not found") || errorStr.includes("no such file")) { + return "not_found"; + } + + // EACCES - access denied + if (errorStr.includes("eacces") || errorStr.includes("permission denied") || errorStr.includes("access denied")) { + return "access_denied"; + } + + // Network/timeout errors + if ( + errorStr.includes("etimedout") || + errorStr.includes("timeout") || + errorStr.includes("econnrefused") || + errorStr.includes("ehostunreach") || + errorStr.includes("enetunreach") || + errorStr.includes("network") + ) { + return "network_error"; + } + + return "unknown_error"; +} + +// Check if a path looks like a network path +function isNetworkPath(path: string): boolean { + if (!path) return false; + + // UNC paths (Windows): \\server\share or //server/share + if (path.startsWith("\\\\") || path.startsWith("//")) { + return true; + } + + // SMB/CIFS: smb:// or cifs:// + if (path.startsWith("smb://") || path.startsWith("cifs://")) { + return true; + } + + // NFS paths (common patterns) + // - server:/path (NFS) + // - /net/server/path (automounter) + if (/^[^\/\\]+:\//.test(path) || path.startsWith("/net/")) { + return true; + } + + return false; +} + +// Validate path with timeout +async function validatePathWithTimeout( + basedir: string, + timeout: number +): Promise { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("ETIMEDOUT")), timeout); + }); + + try { + const validationPromise = RpcApi.FileInfoCommand(TabRpcClient, { info: { path: basedir } }, null); + const fileInfo = await Promise.race([validationPromise, timeoutPromise]); + + // Check if path was not found + if (fileInfo.notfound) { + return { valid: false, path: basedir, reason: "not_found" }; + } + + // Check if path is not a directory + if (!fileInfo.isdir) { + return { valid: false, path: basedir, reason: "not_directory" }; + } + + // Valid directory + return { valid: true, path: basedir, fileInfo }; + } catch (error) { + const reason = classifyError(error); + return { valid: false, path: basedir, reason }; + } +} + +// Validate with network retry mechanism +async function validateWithNetworkRetry( + basedir: string, + config: RetryConfig = defaultRetryConfig +): Promise { + let lastError: StalePathReason | null = null; + + for (let attempt = 1; attempt <= config.maxAttempts; attempt++) { + try { + const result = await validatePathWithTimeout(basedir, config.timeoutPerAttempt); + + if (result.valid) { + return result; // Success on any attempt + } + + // Non-network errors fail immediately (no retry) + if (result.reason !== "network_error") { + return result; + } + + lastError = result.reason; + + // Don't delay after final attempt + if (attempt < config.maxAttempts) { + await sleep(config.delayBetweenRetries); + } + } catch (error) { + lastError = classifyError(error); + + // Only retry network errors + if (lastError !== "network_error" || attempt === config.maxAttempts) { + return { valid: false, path: basedir, reason: lastError }; + } + + await sleep(config.delayBetweenRetries); + } + } + + // All retries exhausted + return { valid: false, path: basedir, reason: "network_error" }; +} + +// Main validation function +export async function validateTabBasedir( + tabId: string, + basedir: string +): Promise { + if (!basedir || basedir.trim() === "") { + return { valid: true, path: basedir }; // Empty path is considered valid (no validation needed) + } + + // Detect if this is a network path + const isNetwork = isNetworkPath(basedir); + + if (isNetwork) { + // Use retry logic for network paths + return await validateWithNetworkRetry(basedir); + } else { + // Single attempt for local paths + return await validatePathWithTimeout(basedir, 5000); // 5 second timeout for local + } +} + +// Get user-friendly message for a stale path reason +function getReasonMessage(reason: StalePathReason, path: string): string { + switch (reason) { + case "not_found": + return `Path no longer valid (not found): ${path}`; + case "not_directory": + return `Path is no longer a directory: ${path}`; + case "access_denied": + return `Cannot access directory (permission denied): ${path}`; + case "network_error": + return `Cannot reach network path (after retries): ${path}`; + case "unknown_error": + return `Path no longer accessible: ${path}`; + default: + return `Path validation failed: ${path}`; + } +} + +// Clear stale path and notify user +export async function handleStaleBasedir( + tabId: string, + path: string, + reason: StalePathReason +): Promise { + const tabORef = WOS.makeORef("tab", tabId); + + try { + // Clear both basedir and basedirlock + await ObjectService.UpdateObjectMeta(tabORef, { + "tab:basedir": null, + "tab:basedirlock": false, + }); + + // Push notification + const { pushNotification } = await import("./global"); + pushNotification({ + id: `stale-basedir-${tabId}`, + icon: "triangle-exclamation", + type: "warning", + title: "Tab base directory cleared", + message: getReasonMessage(reason, path), + timestamp: new Date().toISOString(), + expiration: Date.now() + 10000, // 10 second auto-dismiss + persistent: false, + }); + + console.log(`[TabBasedir] Cleared stale basedir for tab ${tabId}: ${path} (${reason})`); + } catch (error) { + console.error(`[TabBasedir] Failed to clear stale basedir for tab ${tabId}:`, error); + } +} + +// Batch notification for multiple stale paths +export async function handleMultipleStaleBasedirs( + staleTabs: Array<{ tabId: string; path: string; reason: StalePathReason }> +): Promise { + if (staleTabs.length === 0) return; + + // Clear all stale paths + const clearPromises = staleTabs.map(({ tabId }) => { + const tabORef = WOS.makeORef("tab", tabId); + return ObjectService.UpdateObjectMeta(tabORef, { + "tab:basedir": null, + "tab:basedirlock": false, + }); + }); + + try { + await Promise.all(clearPromises); + + // Push batched notification + const { pushNotification } = await import("./global"); + pushNotification({ + id: "stale-basedir-batch", + icon: "triangle-exclamation", + type: "warning", + title: `Cleared base directory for ${staleTabs.length} tabs`, + message: "Multiple tabs had stale paths. See logs for details.", + timestamp: new Date().toISOString(), + expiration: Date.now() + 15000, // 15 second auto-dismiss + persistent: false, + }); + + // Log individual paths for debugging + staleTabs.forEach(({ tabId, path, reason }) => { + console.log(`[TabBasedir] Cleared stale basedir for tab ${tabId}: ${path} (${reason})`); + }); + } catch (error) { + console.error("[TabBasedir] Failed to clear multiple stale basedirs:", error); + } +} + +// Batching state for tab validations +interface BatchingState { + staleTabs: Array<{ tabId: string; path: string; reason: StalePathReason }>; + timer: NodeJS.Timeout | null; +} + +const batchingState: BatchingState = { + staleTabs: [], + timer: null, +}; + +const BATCHING_WINDOW_MS = 5000; // 5 second window for batching +const BATCH_THRESHOLD = 4; // Batch if 4+ tabs have stale paths + +// Validate and handle stale basedir with batching support +export async function validateAndHandleStale(tabId: string): Promise { + const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + const tabData = globalStore.get(tabAtom); + + if (!tabData) { + return; + } + + const basedir = tabData.meta?.["tab:basedir"]; + + // Skip validation if no basedir set + if (!basedir || basedir.trim() === "") { + return; + } + + // Perform validation + const result = await validateTabBasedir(tabId, basedir); + + if (!result.valid && result.reason) { + // Add to batching queue + batchingState.staleTabs.push({ tabId, path: basedir, reason: result.reason }); + + // Clear existing timer if any + if (batchingState.timer) { + clearTimeout(batchingState.timer); + } + + // Set timer to process batch + batchingState.timer = setTimeout(() => { + const staleTabs = [...batchingState.staleTabs]; + batchingState.staleTabs = []; + batchingState.timer = null; + + // Process batch + if (staleTabs.length >= BATCH_THRESHOLD) { + fireAndForget(() => handleMultipleStaleBasedirs(staleTabs)); + } else { + // Process individually + staleTabs.forEach(({ tabId, path, reason }) => { + fireAndForget(() => handleStaleBasedir(tabId, path, reason)); + }); + } + }, BATCHING_WINDOW_MS); + } +} diff --git a/frontend/app/store/tab-model.ts b/frontend/app/store/tab-model.ts index ec5ab94c16..396f29de24 100644 --- a/frontend/app/store/tab-model.ts +++ b/frontend/app/store/tab-model.ts @@ -9,6 +9,16 @@ import * as WOS from "./wos"; const tabModelCache = new Map(); export const activeTabIdAtom = atom(null) as PrimitiveAtom; +// Tab status types based on terminal block states +export type TabStatusType = "stopped" | "finished" | "running" | null; + +// Per-block terminal status for aggregation +export interface BlockTerminalStatus { + shellProcStatus: string | null; // "running", "done", "init", etc. + shellProcExitCode: number | null; + shellIntegrationStatus: string | null; // "running-command", "ready", etc. +} + export class TabModel { tabId: string; tabAtom: Atom; @@ -16,6 +26,23 @@ export class TabModel { isTermMultiInput = atom(false) as PrimitiveAtom; metaCache: Map> = new Map(); + // Validation state atoms for tab base directory + basedirValidationAtom = atom<"pending" | "valid" | "invalid" | null>(null) as PrimitiveAtom< + "pending" | "valid" | "invalid" | null + >; + lastValidationTimeAtom = atom(0) as PrimitiveAtom; + + // Tracks when a process completes while this tab is in the background + // This enables the "finished" status icon to show unread completions + finishedUnreadAtom = atom(false) as PrimitiveAtom; + + // Map of blockId -> terminal status for reactive status tracking + private terminalStatusMap = new Map(); + + // Atom that holds the computed aggregate terminal status + // This is updated whenever any terminal block's status changes + terminalStatusAtom = atom(null) as PrimitiveAtom; + constructor(tabId: string) { this.tabId = tabId; this.tabAtom = atom((get) => { @@ -38,6 +65,80 @@ export class TabModel { } return metaAtom; } + + getBasedirValidationState(): "pending" | "valid" | "invalid" | null { + return globalStore.get(this.basedirValidationAtom); + } + + /** + * Clears the finishedUnread state when the tab becomes active. + * This removes the "finished" status icon indicating unread completions. + */ + clearFinishedUnread(): void { + globalStore.set(this.finishedUnreadAtom, false); + } + + /** + * Marks the tab as having unread process completions. + * Called when a process completes while this tab is in the background. + */ + setFinishedUnread(): void { + globalStore.set(this.finishedUnreadAtom, true); + } + + /** + * Updates the terminal status for a specific block and recomputes aggregate status. + * Called by terminal blocks when their shell proc status or shell integration status changes. + */ + updateBlockTerminalStatus(blockId: string, status: BlockTerminalStatus): void { + this.terminalStatusMap.set(blockId, status); + this.recomputeTerminalStatus(); + } + + /** + * Removes a block from terminal status tracking (e.g., when block is deleted). + */ + removeBlockTerminalStatus(blockId: string): void { + this.terminalStatusMap.delete(blockId); + this.recomputeTerminalStatus(); + } + + /** + * Recomputes the aggregate terminal status from all tracked blocks. + * Priority (highest to lowest): + * 1. stopped - Any block exited with error (exitcode != 0) + * 2. running - A command is actively executing (via shell integration) + * 3. finished - Process completed in background (unread) - handled separately + * 4. null - Idle (no special status to show) + */ + private recomputeTerminalStatus(): void { + let hasRunningCommand = false; + let hasStopped = false; + + for (const status of this.terminalStatusMap.values()) { + // Priority 1: Any error exit code (shell exited with error) + if (status.shellProcStatus === "done" && status.shellProcExitCode !== 0) { + hasStopped = true; + } + + // Check for running commands via shell integration + if (status.shellIntegrationStatus === "running-command") { + hasRunningCommand = true; + } + } + + // Compute status with priority + let newStatus: TabStatusType = null; + if (hasStopped) { + newStatus = "stopped"; + } else if (hasRunningCommand) { + newStatus = "running"; + } else if (globalStore.get(this.finishedUnreadAtom)) { + newStatus = "finished"; + } + + globalStore.set(this.terminalStatusAtom, newStatus); + } } export function getTabModelByTabId(tabId: string): TabModel { diff --git a/frontend/app/tab/tab-menu.ts b/frontend/app/tab/tab-menu.ts new file mode 100644 index 0000000000..699fca994f --- /dev/null +++ b/frontend/app/tab/tab-menu.ts @@ -0,0 +1,168 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ObjectService } from "@/app/store/services"; +import { validatePresetBeforeApply, sanitizePreset } from "@/util/presetutil"; +import { fireAndForget } from "@/util/util"; + +/** + * Configuration for building preset menu items. + */ +export interface PresetMenuConfig { + /** Prefix to filter presets (e.g., "tabvar@", "bg@") */ + prefix: string; + + /** Whether to sort by display:order (default: false) */ + sortByOrder?: boolean; + + /** Whether to strip prefix from fallback label (default: false) */ + stripPrefixFromLabel?: boolean; + + /** Additional callback to execute after applying preset */ + onApply?: (presetName: string) => void; +} + +/** + * Filter preset keys by prefix from the full configuration. + * + * @param presets - The presets map from fullConfig + * @param prefix - Prefix to filter by + * @returns Array of matching preset keys + */ +function filterPresetsByPrefix(presets: { [key: string]: MetaType } | undefined, prefix: string): string[] { + if (!presets) { + return []; + } + const matching: string[] = []; + for (const key in presets) { + if (key.startsWith(prefix)) { + matching.push(key); + } + } + return matching; +} + +/** + * Build context menu items from presets matching the specified prefix. + * + * @param fullConfig - The full configuration containing presets + * @param oref - Object reference to apply presets to + * @param config - Configuration for filtering and building menu items + * @returns Array of context menu items (empty if no matching presets) + * + * @example + * // Tab variables presets + * const tabVarItems = buildPresetMenuItems(fullConfig, oref, { + * prefix: "tabvar@", + * stripPrefixFromLabel: true, + * }); + * + * @example + * // Background presets with sorting and callback + * const bgItems = buildPresetMenuItems(fullConfig, oref, { + * prefix: "bg@", + * sortByOrder: true, + * onApply: () => { + * RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); + * recordTEvent("action:settabtheme"); + * }, + * }); + */ +export function buildPresetMenuItems( + fullConfig: FullConfigType | null, + oref: string, + config: PresetMenuConfig +): ContextMenuItem[] { + if (!fullConfig?.presets) { + return []; + } + + const { prefix, sortByOrder = false, stripPrefixFromLabel = false, onApply } = config; + + // Filter presets by prefix + let presetKeys = filterPresetsByPrefix(fullConfig.presets, prefix); + + if (presetKeys.length === 0) { + return []; + } + + // Sort by display:order if requested + if (sortByOrder) { + presetKeys.sort((a, b) => { + const aOrder = fullConfig.presets[a]?.["display:order"] ?? 0; + const bOrder = fullConfig.presets[b]?.["display:order"] ?? 0; + return aOrder - bOrder; + }); + } + + // Build menu items + const menuItems: ContextMenuItem[] = []; + + for (const presetName of presetKeys) { + const preset = fullConfig.presets[presetName]; + if (preset == null) { + continue; + } + + // Frontend validation (defense in depth) + const validation = validatePresetBeforeApply(presetName, preset); + if (!validation.valid) { + console.warn(`[Preset] Skipping invalid preset "${presetName}": ${validation.error}`); + continue; + } + if (validation.warnings?.length) { + console.info(`[Preset] Warnings for "${presetName}":`, validation.warnings); + } + + // Determine display label + let label: string; + if (preset["display:name"]) { + label = preset["display:name"] as string; + } else if (stripPrefixFromLabel) { + label = presetName.replace(prefix, ""); + } else { + label = presetName; + } + + menuItems.push({ + label, + click: () => + fireAndForget(async () => { + // Sanitize preset to ensure only allowed keys are sent + const sanitizedPreset = sanitizePreset(presetName, preset); + await ObjectService.UpdateObjectMeta(oref, sanitizedPreset); + onApply?.(presetName); + }), + }); + } + + return menuItems; +} + +/** + * Add preset submenu to an existing menu array if presets exist. + * This is a convenience wrapper around buildPresetMenuItems that handles + * the common pattern of adding a labeled submenu with separator. + * + * @param menu - Menu array to add to (modified in place) + * @param fullConfig - The full configuration containing presets + * @param oref - Object reference to apply presets to + * @param label - Label for the submenu + * @param config - Configuration for filtering and building menu items + * @returns The modified menu array (for chaining) + */ +export function addPresetSubmenu( + menu: ContextMenuItem[], + fullConfig: FullConfigType | null, + oref: string, + label: string, + config: PresetMenuConfig +): ContextMenuItem[] { + const submenuItems = buildPresetMenuItems(fullConfig, oref, config); + + if (submenuItems.length > 0) { + menu.push({ label, type: "submenu", submenu: submenuItems }, { type: "separator" }); + } + + return menu; +} diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index 4b33a48f92..07e3d69699 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -4,7 +4,7 @@ .tab { position: absolute; width: 130px; - height: calc(100% - 1px); + height: 100%; padding: 0 0 0 0; box-sizing: border-box; font-weight: bold; @@ -13,6 +13,39 @@ display: flex; align-items: center; justify-content: center; + // Inactive tab: 25% white overlay on tab bar background + background: rgb(255 255 255 / 0.04); + + // Tab color stripe - shows manual color ONLY (VS Code style top stripe) + .tab-color-stripe { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + border-radius: 0; + z-index: var(--zindex-tab-name); + } + + // Status text colors (VS Code style) + &.status-running .name { + color: #3b82f6 !important; + animation: text-pulse 1.5s ease-in-out infinite; + } + + &.status-finished .name { + color: #22c55e !important; + } + + &.status-stopped .name { + color: #ef4444 !important; + } + + &.status-attention .name { + color: #eab308 !important; + animation: text-pulse 1.5s ease-in-out infinite; + } + &::after { content: ""; @@ -28,7 +61,7 @@ width: calc(100% - 6px); height: 100%; white-space: nowrap; - border-radius: 6px; + border-radius: 0; } &.animate { @@ -38,14 +71,22 @@ } &.active { + // Active tab: 75% white overlay on tab bar background (brightest) + background: rgb(255 255 255 / 0.12); + .tab-inner { border-color: transparent; - border-radius: 6px; - background: rgb(from var(--main-text-color) r g b / 0.1); + border-radius: 0; } .name { color: var(--main-text-color); + opacity: 1; + } + + // Always show close button on active tab + .close { + visibility: visible; } & + .tab::after, @@ -58,20 +99,29 @@ content: none; } - .name { + .tab-name-wrapper { position: absolute; top: 50%; left: 50%; transform: translate3d(-50%, -50%, 0); - user-select: none; + width: calc(100% - 10px); + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; z-index: var(--zindex-tab-name); + } + + .name { + user-select: none; font-size: 11px; font-weight: 500; text-shadow: 0px 0px 4px rgb(from var(--main-bg-color) r g b / 0.25); overflow: hidden; - width: calc(100% - 10px); text-overflow: ellipsis; text-align: center; + // Inactive tabs have more muted text + opacity: 0.7; &.focused { outline: none; @@ -110,9 +160,11 @@ body:not(.nohover) .tab.dragging { content: none; } + // Hover tab: 50% white overlay on tab bar background (middle brightness) + background: rgb(255 255 255 / 0.08); + .tab-inner { border-color: transparent; - background: rgb(from var(--main-text-color) r g b / 0.1); } .close { visibility: visible; @@ -122,6 +174,11 @@ body:not(.nohover) .tab.dragging { } } +// Active tab hover shouldn't change the background +body:not(.nohover) .tab.active:hover { + background: rgb(255 255 255 / 0.12); +} + // When in nohover mode, always show the close button on the active tab. This prevents the close button of the active tab from flickering when nohover is toggled. body.nohover .tab.active .close { visibility: visible; @@ -192,3 +249,21 @@ body.nohover .tab.active .close { .pin.jiggling i { animation: jigglePinIcon 0.5s ease-in-out; } + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes text-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 63269d3013..c8f16d4c8f 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,18 +1,32 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; +import { atoms, getApi, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { Button } from "@/element/button"; import { ContextMenuModel } from "@/store/contextmenu"; import { fireAndForget } from "@/util/util"; import clsx from "clsx"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { ObjectService } from "../store/services"; +import { TabStatusType } from "../store/tab-model"; import { makeORef, useWaveObjectValue } from "../store/wos"; +import { addPresetSubmenu } from "./tab-menu"; import "./tab.scss"; +// Tab color palette for the context menu +const TAB_COLORS = [ + { name: "Red", value: "#ef4444" }, + { name: "Orange", value: "#f97316" }, + { name: "Yellow", value: "#eab308" }, + { name: "Green", value: "#22c55e" }, + { name: "Cyan", value: "#06b6d4" }, + { name: "Blue", value: "#3b82f6" }, + { name: "Purple", value: "#a855f7" }, + { name: "Pink", value: "#ec4899" }, +]; + interface TabProps { id: string; active: boolean; @@ -42,6 +56,39 @@ const Tab = memo( const loadedRef = useRef(false); const tabRef = useRef(null); + // Read terminal status from tab metadata (synced across webviews) + // Status shown on ALL tabs including active + const tabStatus = (tabData?.meta?.["tab:termstatus"] as TabStatusType) || null; + + // Clear status after a delay when tab becomes active AND webview is visible + // "finished" clears after 2 seconds, "stopped" clears after 3 seconds + // We must check document.visibilityState because each tab has its own webview, + // and the "active" prop is always true for the owning webview even when in background + const [isDocVisible, setIsDocVisible] = useState(document.visibilityState === "visible"); + useEffect(() => { + const handleVisibilityChange = () => { + setIsDocVisible(document.visibilityState === "visible"); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + }, []); + + useEffect(() => { + // Only clear status when: + // 1. This tab is marked as active (matches this webview's staticTabId) + // 2. This webview is actually visible to the user (not a background webview) + // 3. Status is finished or stopped + if (active && isDocVisible && (tabStatus === "finished" || tabStatus === "stopped")) { + const delay = tabStatus === "stopped" ? 3000 : 2000; + const timer = setTimeout(() => { + ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:termstatus": null, + }); + }, delay); + return () => clearTimeout(timer); + } + }, [active, isDocVisible, tabStatus, id]); + useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); useEffect(() => { @@ -133,9 +180,99 @@ const Tab = memo( event.stopPropagation(); }; + /** + * Opens a native directory picker dialog and sets the tab's base directory. + * + * The selected directory becomes the default working directory for all + * terminals and file preview widgets launched within this tab. + * + * @remarks + * - Uses Electron's native dialog for cross-platform file picking + * - Defaults to current base directory if set, otherwise home (~) + * - Does NOT set the lock flag - allows smart auto-detection to continue + * + * @see handleClearBaseDir - To remove the base directory + * @see handleToggleLock - To prevent auto-detection + */ + const handleSetBaseDir = useCallback(() => { + const currentDir = tabData?.meta?.["tab:basedir"] || ""; + fireAndForget(async () => { + const newDir = await getApi().showOpenDialog({ + title: "Set Tab Base Directory", + defaultPath: currentDir || "~", + properties: ["openDirectory"], + }); + if (newDir && newDir.length > 0) { + await ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:basedir": newDir[0], + }); + } + }); + }, [id, tabData]); + + /** + * Clears the tab's base directory, restoring default behavior. + * + * After clearing: + * - New terminals use the default directory (typically home ~) + * - Smart auto-detection from OSC 7 is re-enabled + * + * @remarks + * Only clears `tab:basedir`, does NOT touch `tab:basedirlock` + */ + const handleClearBaseDir = useCallback(() => { + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:basedir": null, + }); + }); + }, [id]); + + /** + * Toggles the base directory lock state. + * + * Lock semantics: + * - **Unlocked (default):** OSC 7 smart auto-detection can update `tab:basedir` + * - **Locked:** OSC 7 updates are blocked; only manual setting changes directory + * + * Use cases for locking: + * - Working in multiple directories within same tab + * - Preventing cd commands from changing tab context + * - Maintaining a fixed project root despite navigation + * + * @see tab:basedirlock - The underlying metadata key + */ + const handleToggleLock = useCallback(() => { + const currentLock = tabData?.meta?.["tab:basedirlock"] || false; + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:basedirlock": !currentLock, + }); + }); + }, [id, tabData]); + + /** + * Sets the tab's color for visual identification. + * + * @param color - Hex color value or null to clear + */ + const handleSetTabColor = useCallback( + (color: string | null) => { + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:color": color, + }); + }); + }, + [id] + ); + const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); + const currentBaseDir = tabData?.meta?.["tab:basedir"]; + const isLocked = tabData?.meta?.["tab:basedirlock"] || false; + let menu: ContextMenuItem[] = [ { label: "Rename Tab", click: () => handleRenameTab(null) }, { @@ -144,69 +281,120 @@ const Tab = memo( }, { type: "separator" }, ]; - const fullConfig = globalStore.get(atoms.fullConfigAtom); - const bgPresets: string[] = []; - for (const key in fullConfig?.presets ?? {}) { - if (key.startsWith("bg@")) { - bgPresets.push(key); - } + + // Base Directory submenu + const baseDirSubmenu: ContextMenuItem[] = [ + { + label: "Set Base Directory...", + click: handleSetBaseDir, + }, + ]; + + if (currentBaseDir) { + baseDirSubmenu.push({ + label: "Clear Base Directory", + click: handleClearBaseDir, + }); + baseDirSubmenu.push({ type: "separator" }); + baseDirSubmenu.push({ + label: isLocked ? "Unlock (Enable Smart Detection)" : "Lock (Disable Smart Detection)", + click: handleToggleLock, + }); } - bgPresets.sort((a, b) => { - const aOrder = fullConfig.presets[a]["display:order"] ?? 0; - const bOrder = fullConfig.presets[b]["display:order"] ?? 0; - return aOrder - bOrder; + + menu.push({ label: "Base Directory", type: "submenu", submenu: baseDirSubmenu }, { type: "separator" }); + + // Tab Color submenu + const currentTabColor = tabData?.meta?.["tab:color"]; + const colorSubmenu: ContextMenuItem[] = TAB_COLORS.map((color) => ({ + label: color.name, + type: "checkbox" as const, + checked: currentTabColor === color.value, + click: () => handleSetTabColor(color.value), + })); + colorSubmenu.push({ type: "separator" }); + colorSubmenu.push({ + label: "Clear", + click: () => handleSetTabColor(null), + }); + + menu.push({ label: "Tab Color", type: "submenu", submenu: colorSubmenu }, { type: "separator" }); + + const fullConfig = globalStore.get(atoms.fullConfigAtom); + const oref = makeORef("tab", id); + + // Tab Variables presets + addPresetSubmenu(menu, fullConfig, oref, "Tab Variables", { + prefix: "tabvar@", + stripPrefixFromLabel: true, + }); + + // Background presets + addPresetSubmenu(menu, fullConfig, oref, "Backgrounds", { + prefix: "bg@", + sortByOrder: true, + onApply: () => { + RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); + recordTEvent("action:settabtheme"); + }, }); - if (bgPresets.length > 0) { - const submenu: ContextMenuItem[] = []; - const oref = makeORef("tab", id); - for (const presetName of bgPresets) { - const preset = fullConfig.presets[presetName]; - if (preset == null) { - continue; - } - submenu.push({ - label: preset["display:name"] ?? presetName, - click: () => - fireAndForget(async () => { - await ObjectService.UpdateObjectMeta(oref, preset); - RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); - recordTEvent("action:settabtheme"); - }), - }); - } - menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); - } menu.push({ label: "Close Tab", click: () => onClose(null) }); ContextMenuModel.showContextMenu(menu, e); }, - [handleRenameTab, id, onClose] + [handleRenameTab, id, onClose, tabData, handleSetBaseDir, handleClearBaseDir, handleToggleLock, handleSetTabColor] ); + const tabColor = tabData?.meta?.["tab:color"]; + + /** + * Gets the status class name for the tab element. + * Used for VS Code style text coloring. + */ + const getStatusClassName = (): string | null => { + switch (tabStatus) { + case "stopped": + return "status-stopped"; + case "finished": + return "status-finished"; + case "running": + return "status-running"; + default: + return null; + } + }; + + const statusClassName = getStatusClassName(); + return (
+ {/* Top stripe for manual color only (VS Code style) */} + {tabColor &&
}
-
- {tabData?.name} +
+
+ {tabData?.name} +