diff --git a/.agents/skills/react/SKILL.md b/.agents/skills/react/SKILL.md index e2f49fd20c..9bac31b731 100644 --- a/.agents/skills/react/SKILL.md +++ b/.agents/skills/react/SKILL.md @@ -1,95 +1,95 @@ --- name: react -description: "LobeHub React/SPA component conventions: antd-style with `createStaticStyles` + `cssVar.*` (prefer zero-runtime over `createStyles` + `token`), `@lobehub/ui/base-ui` primitives before `@lobehub/ui` before antd, `Flexbox`/`Center` for layouts, react-router-dom navigation, and the `.desktop.tsx` sync rule. Use when writing or editing any `.tsx` under `src/**`, picking a styling helper, choosing a component (Select/Modal/Drawer/Button/Tooltip), wiring routes in `desktopRouter.config.tsx`/`.desktop.tsx`, or adding a `Link`/`useNavigate` call in the SPA. Triggers on `createStyles`/`createStaticStyles`, `cssVar`, `@lobehub/ui`, `antd-style`, `Flexbox`, `useNavigate`, `react-router-dom`, `Link`, 'new component', 'add a page', 'edit a layout', 'desktopRouter', 'componentMap.desktop'." +description: 'Use when writing or editing any `.tsx` under `src/**`. Triggers: createStaticStyles, createStyles, cssVar, antd-style, Flexbox, Center, Select, Modal, Drawer, Button, Tooltip, DropdownMenu, Popover, Switch, ScrollArea, Link, useNavigate, react-router-dom, next/link, desktopRouter, componentMap.desktop, .desktop.tsx, new component, new page, edit layout, add styles, zustand selector, @lobehub/ui, antd import.' user-invocable: false --- # React Component Writing Guide -- Use antd-style for complex styles; for simple cases, use inline `style` attribute - - **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required - - Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`) - - See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern -- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`) -- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation - - Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents - - Fall back to `@lobehub/ui` higher-level components when base-ui has no match - - Only implement a custom component as a last resort — never reach for antd directly -- Use selectors to access zustand store data +## Styling -## @lobehub/ui Components +| Scenario | Approach | +| ---------------------------------------------------------- | -------------------------------------------------------------- | +| Most cases | `createStaticStyles` + `cssVar.*` (zero-runtime, module-level) | +| Simple one-off | Inline `style` attribute | +| Truly dynamic (JS color fns like `readableColor`/`chroma`) | `createStyles` + `token` — **last resort** | -If unsure about component usage, search existing code in this project. Most components extend antd with additional props. +## Component Priority -Reference: `node_modules/@lobehub/ui/es/index.mjs` for all available components. +1. **`src/components`** — project-specific reusable components +2. **`@lobehub/ui/base-ui`** — headless primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) +3. **`@lobehub/ui`** — higher-level components (ActionIcon, Markdown, DragPage…) +4. **Custom implementation** — last resort; never reach for antd directly -**Common Components:** +If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`. -- General: ActionIcon, ActionIconGroup, Block, Button, Icon -- Data Display: Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip -- Data Entry: CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select -- Feedback: Alert, Drawer, Modal -- Layout: Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow -- Navigation: Burger, Dropdown, Menu, SideNav, Tabs +### Common @lobehub/ui Components + +| Category | Components | +| ------------ | ------------------------------------------------------------------------------- | +| General | ActionIcon, ActionIconGroup, Block, Button, Icon | +| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip | +| Data Entry | CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select | +| Feedback | Alert, Drawer, Modal | +| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow | +| Navigation | Burger, Dropdown, Menu, SideNav, Tabs | + +## Layout + +Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples. + +- Use `gap` instead of `margin` for spacing between flex children +- Use `flex={1}` to fill available space +- Nest Flexbox for complex layouts; set `overflow: 'auto'` for scrollable regions + +## Navigation + +**For SPA pages, use `react-router-dom`, NOT `next/link`.** + +```tsx +// ❌ Wrong +import Link from 'next/link'; + +// ✅ Correct +import { Link, useNavigate } from 'react-router-dom'; +``` + +Access navigate from stores: `useGlobalStore.getState().navigate?.('/settings');` + +## Desktop File Sync Rule + +Files with a `.desktop.ts(x)` variant must be edited **in sync**. Drift causes blank pages in Electron. + +| Base file (web) | Desktop file (Electron) | +| -------------------------- | ---------------------------------- | +| `desktopRouter.config.tsx` | `desktopRouter.config.desktop.tsx` | +| `componentMap.ts` | `componentMap.desktop.ts` | + +**After editing any `.ts`/`.tsx`:** glob for `.desktop.{ts,tsx}` in the same directory. If found, apply the equivalent sync-import change. ## Routing Architecture -Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA). +| Route Type | Use Case | Implementation | +| ------------------ | ---------- | -------------------------------------------------- | +| Next.js App Router | Auth pages | `src/app/[variants]/(auth)/` | +| React Router DOM | Main SPA | `desktopRouter.config.tsx` + `.desktop.tsx` (pair) | -| Route Type | Use Case | Implementation | -| ------------------ | --------------------------------- | ---------------------------------------------------------------------------- | -| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` | -| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) | - -### Key Files - -- Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx` -- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen. -- Mobile router: `src/spa/router/mobileRouter.config.tsx` -- Router utilities: `src/utils/router.tsx` - -### `.desktop.{ts,tsx}` File Sync Rule - -**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron. - -Known pairs that must stay in sync: - -| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) | -| ----------------------------------------------------- | ------------------------------------------------------------- | -| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` | -| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` | - -**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change. - -### Router Utilities +Router utilities: ```tsx import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router'; - element: dynamicElement(() => import('./chat'), 'Desktop > Chat'); element: redirectElement('/settings/profile'); errorElement: ; ``` -### Navigation +## Common Mistakes -**Important**: For SPA pages, use `Link` from `react-router-dom`, NOT `next/link`. - -```tsx -// ❌ Wrong -import Link from 'next/link'; -Home; - -// ✅ Correct -import { Link } from 'react-router-dom'; -Home; - -// In components -import { useNavigate } from 'react-router-dom'; -const navigate = useNavigate(); -navigate('/chat'); - -// From stores -const navigate = useGlobalStore.getState().navigate; -navigate?.('/settings'); -``` +| Mistake | Fix | +| ---------------------------------------- | ------------------------------------------------------ | +| Using `next/link` in SPA | Use `react-router-dom` `Link` | +| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` | +| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` | +| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` | +| Using `margin` for flex spacing | Use `gap` prop on Flexbox | +| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) | diff --git a/apps/desktop/src/main/const/protocol.ts b/apps/desktop/src/main/const/protocol.ts index 89def17632..9203c2640a 100644 --- a/apps/desktop/src/main/const/protocol.ts +++ b/apps/desktop/src/main/const/protocol.ts @@ -1 +1,4 @@ export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend'; + +export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile'; +export const LOCAL_FILE_PROTOCOL_HOST = 'file'; diff --git a/apps/desktop/src/main/controllers/OpenInAppCtr.ts b/apps/desktop/src/main/controllers/OpenInAppCtr.ts new file mode 100644 index 0000000000..4542dabd4f --- /dev/null +++ b/apps/desktop/src/main/controllers/OpenInAppCtr.ts @@ -0,0 +1,43 @@ +import type { + DetectAppsResult, + OpenInAppParams, + OpenInAppResult, +} from '@lobechat/electron-client-ipc'; + +import { getCachedDetection } from '@/modules/openInApp/cache'; +import { detectApp } from '@/modules/openInApp/detectors'; +import { launchApp } from '@/modules/openInApp/launchers'; +import { createLogger } from '@/utils/logger'; + +import { ControllerModule, IpcMethod } from './index'; + +const logger = createLogger('controllers:OpenInAppCtr'); + +export default class OpenInAppCtr extends ControllerModule { + static override readonly groupName = 'openInApp'; + + @IpcMethod() + async detectApps(): Promise { + const apps = await getCachedDetection(); + return { apps }; + } + + @IpcMethod() + async openInApp({ appId, path }: OpenInAppParams): Promise { + // Re-validate installation status before launching: per spec, the main + // process must reject if the app disappeared between probe and launch. + const installed = await detectApp(appId, process.platform); + if (!installed) { + logger.warn(`openInApp: ${appId} reported not installed`); + return { error: `${appId} is not installed`, success: false }; + } + + const result = await launchApp(appId, path, process.platform); + if (result.success) { + logger.info(`openInApp: launched ${appId} with path ${path}`); + } else { + logger.error(`openInApp: launch failed for ${appId}: ${result.error}`); + } + return result; + } +} diff --git a/apps/desktop/src/main/controllers/__tests__/OpenInAppCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/OpenInAppCtr.test.ts new file mode 100644 index 0000000000..a7fb99679e --- /dev/null +++ b/apps/desktop/src/main/controllers/__tests__/OpenInAppCtr.test.ts @@ -0,0 +1,147 @@ +import type { DetectedApp, OpenInAppResult } from '@lobechat/electron-client-ipc'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { App } from '@/core/App'; +import type { IpcContext } from '@/utils/ipc'; +import { IpcHandler } from '@/utils/ipc/base'; + +import OpenInAppCtr from '../OpenInAppCtr'; + +const { getCachedDetectionMock, detectAppMock, launchAppMock, ipcHandlers, ipcMainHandleMock } = + vi.hoisted(() => { + const handlers = new Map any>(); + const handle = vi.fn((channel: string, handler: any) => { + handlers.set(channel, handler); + }); + return { + detectAppMock: vi.fn(), + getCachedDetectionMock: vi.fn(), + ipcHandlers: handlers, + ipcMainHandleMock: handle, + launchAppMock: vi.fn(), + }; + }); + +const invokeIpc = async ( + channel: string, + payload?: any, + context?: Partial, +): Promise => { + const handler = ipcHandlers.get(channel); + if (!handler) throw new Error(`IPC handler for ${channel} not found`); + + const fakeEvent = { + sender: context?.sender ?? ({ id: 'test' } as any), + }; + + if (payload === undefined) { + return handler(fakeEvent); + } + + return handler(fakeEvent, payload); +}; + +vi.mock('@/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('electron', () => ({ + ipcMain: { + handle: ipcMainHandleMock, + }, +})); + +vi.mock('@/modules/openInApp/cache', () => ({ + getCachedDetection: getCachedDetectionMock, +})); + +vi.mock('@/modules/openInApp/detectors', () => ({ + detectApp: detectAppMock, +})); + +vi.mock('@/modules/openInApp/launchers', () => ({ + launchApp: launchAppMock, +})); + +const mockApp = {} as unknown as App; + +describe('OpenInAppCtr', () => { + beforeEach(() => { + vi.clearAllMocks(); + ipcHandlers.clear(); + ipcMainHandleMock.mockClear(); + (IpcHandler.getInstance() as any).registeredChannels?.clear(); + new OpenInAppCtr(mockApp); + }); + + describe('detectApps', () => { + it('should call getCachedDetection and return the apps list', async () => { + const apps: DetectedApp[] = [ + { displayName: 'Visual Studio Code', id: 'vscode', installed: true }, + { displayName: 'Cursor', id: 'cursor', installed: false }, + ]; + getCachedDetectionMock.mockResolvedValue(apps); + + const result = await invokeIpc('openInApp.detectApps'); + + expect(getCachedDetectionMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ apps }); + }); + }); + + describe('openInApp', () => { + it('should launch the app when installed', async () => { + detectAppMock.mockResolvedValue(true); + const launchResult: OpenInAppResult = { success: true }; + launchAppMock.mockResolvedValue(launchResult); + + const result = await invokeIpc('openInApp.openInApp', { + appId: 'vscode', + path: '/tmp/project', + }); + + expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform); + expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/project', process.platform); + expect(result).toEqual({ success: true }); + }); + + it('should not launch and return error when app is not installed', async () => { + detectAppMock.mockResolvedValue(false); + + const result = await invokeIpc('openInApp.openInApp', { + appId: 'cursor', + path: '/tmp/project', + }); + + expect(detectAppMock).toHaveBeenCalledWith('cursor', process.platform); + expect(launchAppMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + error: 'cursor is not installed', + success: false, + }); + }); + + it('should pass through launch errors when launchApp fails', async () => { + detectAppMock.mockResolvedValue(true); + const launchResult: OpenInAppResult = { + error: 'Path not found: /tmp/missing', + success: false, + }; + launchAppMock.mockResolvedValue(launchResult); + + const result = await invokeIpc('openInApp.openInApp', { + appId: 'vscode', + path: '/tmp/missing', + }); + + expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform); + expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/missing', process.platform); + expect(result).toEqual(launchResult); + }); + }); +}); diff --git a/apps/desktop/src/main/controllers/registry.ts b/apps/desktop/src/main/controllers/registry.ts index aa3d9cd582..c344a9a7a2 100644 --- a/apps/desktop/src/main/controllers/registry.ts +++ b/apps/desktop/src/main/controllers/registry.ts @@ -13,6 +13,7 @@ import McpInstallCtr from './McpInstallCtr'; import MenuController from './MenuCtr'; import NetworkProxyCtr from './NetworkProxyCtr'; import NotificationCtr from './NotificationCtr'; +import OpenInAppCtr from './OpenInAppCtr'; import RemoteServerConfigCtr from './RemoteServerConfigCtr'; import RemoteServerSyncCtr from './RemoteServerSyncCtr'; import ScreenCaptureCtr from './ScreenCaptureCtr'; @@ -37,6 +38,7 @@ export const controllerIpcConstructors = [ MenuController, NetworkProxyCtr, NotificationCtr, + OpenInAppCtr, RemoteServerConfigCtr, RemoteServerSyncCtr, ScreenCaptureCtr, diff --git a/apps/desktop/src/main/core/App.ts b/apps/desktop/src/main/core/App.ts index 2c12c2cb27..c12efdac8f 100644 --- a/apps/desktop/src/main/core/App.ts +++ b/apps/desktop/src/main/core/App.ts @@ -31,6 +31,7 @@ import { createLogger } from '@/utils/logger'; import { BrowserManager } from './browser/BrowserManager'; import { I18nManager } from './infrastructure/I18nManager'; import { IoCContainer } from './infrastructure/IoCContainer'; +import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager'; import { ProtocolManager } from './infrastructure/ProtocolManager'; import { RendererUrlManager } from './infrastructure/RendererUrlManager'; import { StaticFileServerManager } from './infrastructure/StaticFileServerManager'; @@ -62,6 +63,7 @@ export class App { staticFileServerManager: StaticFileServerManager; protocolManager: ProtocolManager; rendererUrlManager: RendererUrlManager; + localFileProtocolManager: LocalFileProtocolManager; toolDetectorManager: ToolDetectorManager; screenCaptureManager: ScreenCaptureManager; chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar']; @@ -102,6 +104,7 @@ export class App { this.storeManager = new StoreManager(this); this.rendererUrlManager = new RendererUrlManager(); + this.localFileProtocolManager = new LocalFileProtocolManager(); protocol.registerSchemesAsPrivileged([ { privileges: { @@ -114,6 +117,7 @@ export class App { scheme: ELECTRON_BE_PROTOCOL_SCHEME, }, this.rendererUrlManager.protocolScheme, + this.localFileProtocolManager.protocolScheme, ]); // load controllers @@ -152,6 +156,10 @@ export class App { // should register before app ready this.rendererUrlManager.configureRendererLoader(); + // Serves arbitrary local files (e.g. project file previews) via + // `localfile://` to the renderer. Active in both dev and prod. + this.localFileProtocolManager.registerHandler(); + // initialize protocol handlers this.protocolManager.initialize(); diff --git a/apps/desktop/src/main/core/infrastructure/LocalFileProtocolManager.ts b/apps/desktop/src/main/core/infrastructure/LocalFileProtocolManager.ts new file mode 100644 index 0000000000..8a8c90a043 --- /dev/null +++ b/apps/desktop/src/main/core/infrastructure/LocalFileProtocolManager.ts @@ -0,0 +1,155 @@ +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import { app, protocol } from 'electron'; + +import { LOCAL_FILE_PROTOCOL_HOST, LOCAL_FILE_PROTOCOL_SCHEME } from '@/const/protocol'; +import { createLogger } from '@/utils/logger'; + +import { getExportMimeType } from '../../utils/mime'; + +const LOCAL_FILE_PROTOCOL_PRIVILEGES = { + allowServiceWorkers: false, + bypassCSP: false, + corsEnabled: true, + secure: true, + standard: true, + stream: true, + supportFetchAPI: true, +} as const; + +const logger = createLogger('core:LocalFileProtocolManager'); + +const EXTRA_MIME_TYPES: Record = { + '.avif': 'image/avif', + '.bmp': 'image/bmp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', +}; + +const getMimeType = (filePath: string): string => { + const ext = path.extname(filePath).toLowerCase(); + return getExportMimeType(filePath) ?? EXTRA_MIME_TYPES[ext] ?? 'application/octet-stream'; +}; + +/** + * Custom `localfile://` protocol that serves arbitrary local files to the + * Electron renderer (e.g. previews for the project Files tree). + * + * URL shape: `localfile://file/` + * - host is fixed to `file` so the scheme behaves as `standard` + * - the absolute path is encoded in the URL pathname + * + * Examples: + * localfile://file//Users/alice/Pictures/cat.png + * localfile://file/C:/Users/alice/Pictures/cat.png + */ +export class LocalFileProtocolManager { + private handlerRegistered = false; + + get protocolScheme() { + return { + privileges: LOCAL_FILE_PROTOCOL_PRIVILEGES, + scheme: LOCAL_FILE_PROTOCOL_SCHEME, + }; + } + + registerHandler() { + if (this.handlerRegistered) return; + + const register = () => { + if (this.handlerRegistered) return; + + protocol.handle(LOCAL_FILE_PROTOCOL_SCHEME, async (request) => { + try { + const url = new URL(request.url); + + if (url.hostname !== LOCAL_FILE_PROTOCOL_HOST) { + return new Response('Not Found', { status: 404 }); + } + + const resolvedPath = this.resolveFilePath(url.pathname); + if (!resolvedPath) { + return new Response('Invalid path', { status: 400 }); + } + + const fileStat = await stat(resolvedPath); + if (!fileStat.isFile()) { + return new Response('Not a file', { status: 404 }); + } + + const buffer = await readFile(resolvedPath); + const headers = new Headers(); + headers.set('Content-Type', getMimeType(resolvedPath)); + headers.set('Content-Length', String(buffer.byteLength)); + // Local files are immutable from the renderer's perspective for a + // single preview session; allow short-lived caching to avoid + // re-reading large images during scrolling/refresh. + headers.set('Cache-Control', 'private, max-age=60'); + + return new Response(buffer, { headers, status: 200 }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return new Response('Not Found', { status: 404 }); + } + if (code === 'EACCES' || code === 'EPERM') { + return new Response('Forbidden', { status: 403 }); + } + logger.error(`Failed to serve localfile request ${request.url}:`, error); + return new Response('Internal Server Error', { status: 500 }); + } + }); + + this.handlerRegistered = true; + logger.debug(`Registered ${LOCAL_FILE_PROTOCOL_SCHEME}:// handler`); + }; + + if (app.isReady()) { + register(); + } else { + app.whenReady().then(register); + } + } + + /** + * Decode the URL pathname back into an absolute filesystem path. + * + * Pathname examples produced by `new URL('localfile://file//abs/path')`: + * posix: `//abs/path` -> `/abs/path` + * windows: `/C:/abs/path` -> `C:/abs/path` + * + * Returns null when the path is non-absolute or escapes via segments we + * cannot safely normalize (defense-in-depth, not a sandbox). + */ + private resolveFilePath(pathname: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(pathname); + } catch { + return null; + } + + // Strip the single leading slash inserted by URL parsing on standard + // schemes; what remains should already be an absolute filesystem path. + let candidate = decoded.startsWith('/') ? decoded.slice(1) : decoded; + if (!candidate) return null; + + if (process.platform === 'win32') { + // posix-style absolute path won't have a drive letter; treat as invalid + // on Windows. + candidate = candidate.replaceAll('/', '\\'); + } else if (!candidate.startsWith('/')) { + // We expect an absolute POSIX path: `localfile://file//abs/path` yields + // pathname `//abs/path` -> after stripping one slash -> `/abs/path`. + candidate = `/${candidate}`; + } + + const normalized = path.normalize(candidate); + if (!path.isAbsolute(normalized)) return null; + + return normalized; + } +} diff --git a/apps/desktop/src/main/core/infrastructure/__tests__/LocalFileProtocolManager.test.ts b/apps/desktop/src/main/core/infrastructure/__tests__/LocalFileProtocolManager.test.ts new file mode 100644 index 0000000000..f881eccc9b --- /dev/null +++ b/apps/desktop/src/main/core/infrastructure/__tests__/LocalFileProtocolManager.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocalFileProtocolManager } from '../LocalFileProtocolManager'; + +const { mockApp, mockProtocol, mockReadFile, mockStat, protocolHandlerRef } = vi.hoisted(() => { + const protocolHandlerRef = { current: null as any }; + + return { + mockApp: { + isReady: vi.fn().mockReturnValue(true), + whenReady: vi.fn().mockResolvedValue(undefined), + }, + mockProtocol: { + handle: vi.fn((_scheme: string, handler: any) => { + protocolHandlerRef.current = handler; + }), + }, + mockReadFile: vi.fn(), + mockStat: vi.fn(), + protocolHandlerRef, + }; +}); + +vi.mock('electron', () => ({ + app: mockApp, + protocol: mockProtocol, +})); + +vi.mock('node:fs/promises', () => ({ + readFile: mockReadFile, + stat: mockStat, +})); + +vi.mock('@/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +describe('LocalFileProtocolManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + protocolHandlerRef.current = null; + mockApp.isReady.mockReturnValue(true); + mockStat.mockImplementation(async () => ({ isFile: () => true, size: 1024 })); + mockReadFile.mockImplementation(async () => Buffer.from('image-bytes')); + }); + + afterEach(() => { + protocolHandlerRef.current = null; + }); + + it('exposes scheme metadata for registerSchemesAsPrivileged', () => { + const manager = new LocalFileProtocolManager(); + expect(manager.protocolScheme).toEqual({ + privileges: expect.objectContaining({ + bypassCSP: false, + secure: true, + standard: true, + supportFetchAPI: true, + }), + scheme: 'localfile', + }); + }); + + it('serves a POSIX absolute path with the correct mime type', async () => { + const manager = new LocalFileProtocolManager(); + manager.registerHandler(); + + expect(mockProtocol.handle).toHaveBeenCalledWith('localfile', expect.any(Function)); + const handler = protocolHandlerRef.current; + + const response = await handler({ + headers: new Headers(), + method: 'GET', + url: 'localfile://file/Users/alice/Pictures/cat.png', + }); + + expect(mockStat).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png'); + expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png'); + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('image/png'); + expect(response.headers.get('Content-Length')).toBe('11'); // 'image-bytes'.length + }); + + it('serves source files as text through the localfile protocol', async () => { + const manager = new LocalFileProtocolManager(); + manager.registerHandler(); + const handler = protocolHandlerRef.current; + + const response = await handler({ + headers: new Headers(), + method: 'GET', + url: 'localfile://file/Users/alice/project/App.tsx', + }); + + expect(mockStat).toHaveBeenCalledWith('/Users/alice/project/App.tsx'); + expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx'); + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); + }); + + it('decodes percent-encoded characters in the path', async () => { + const manager = new LocalFileProtocolManager(); + manager.registerHandler(); + const handler = protocolHandlerRef.current; + + await handler({ + headers: new Headers(), + method: 'GET', + url: 'localfile://file/Users/alice/My%20Pictures/%E5%9B%BE%20%23.png', + }); + + expect(mockStat).toHaveBeenCalledWith('/Users/alice/My Pictures/图 #.png'); + }); + + it('rejects requests to a different host', async () => { + const manager = new LocalFileProtocolManager(); + manager.registerHandler(); + const handler = protocolHandlerRef.current; + + const response = await handler({ + headers: new Headers(), + method: 'GET', + url: 'localfile://other/Users/alice/cat.png', + }); + + expect(response.status).toBe(404); + expect(mockStat).not.toHaveBeenCalled(); + }); + + it('returns 404 when the path is a directory', async () => { + mockStat.mockImplementation(async () => ({ isFile: () => false, size: 0 })); + + const manager = new LocalFileProtocolManager(); + manager.registerHandler(); + const handler = protocolHandlerRef.current; + + const response = await handler({ + headers: new Headers(), + method: 'GET', + url: 'localfile://file/Users/alice/folder', + }); + + expect(response.status).toBe(404); + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it('maps ENOENT errors to a 404 response', async () => { + mockStat.mockImplementation(async () => { + const err: NodeJS.ErrnoException = new Error('no such file'); + err.code = 'ENOENT'; + throw err; + }); + + const manager = new LocalFileProtocolManager(); + manager.registerHandler(); + const handler = protocolHandlerRef.current; + + const response = await handler({ + headers: new Headers(), + method: 'GET', + url: 'localfile://file/nonexistent.png', + }); + + expect(response.status).toBe(404); + }); + + it('defers registration until app ready when not yet ready', async () => { + mockApp.isReady.mockReturnValue(false); + let resolveReady: () => void = () => undefined; + mockApp.whenReady.mockReturnValue( + new Promise((resolve) => { + resolveReady = resolve; + }), + ); + + const manager = new LocalFileProtocolManager(); + manager.registerHandler(); + + expect(mockProtocol.handle).not.toHaveBeenCalled(); + resolveReady(); + await new Promise((r) => setImmediate(r)); + expect(mockProtocol.handle).toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/modules/openInApp/__tests__/cache.test.ts b/apps/desktop/src/main/modules/openInApp/__tests__/cache.test.ts new file mode 100644 index 0000000000..86dd7f70a0 --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/__tests__/cache.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearDetectionCache, getCachedDetection } from '../cache'; +import { detectAllApps } from '../detectors'; + +vi.mock('@/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('../detectors', () => ({ + detectAllApps: vi.fn(), +})); + +const mockedDetectAll = vi.mocked(detectAllApps); + +beforeEach(() => { + vi.clearAllMocks(); + clearDetectionCache(); +}); + +describe('getCachedDetection', () => { + it('invokes detection on first call', async () => { + mockedDetectAll.mockResolvedValueOnce([ + { displayName: 'VS Code', id: 'vscode', installed: true }, + ]); + + const result = await getCachedDetection('darwin'); + + expect(result).toEqual([{ displayName: 'VS Code', id: 'vscode', installed: true }]); + expect(mockedDetectAll).toHaveBeenCalledTimes(1); + }); + + it('concurrent callers share a single inflight promise', async () => { + let resolveFn: (value: any) => void = () => {}; + const inflight = new Promise((resolve) => { + resolveFn = resolve; + }); + mockedDetectAll.mockReturnValueOnce(inflight); + + const p1 = getCachedDetection('darwin'); + const p2 = getCachedDetection('darwin'); + const p3 = getCachedDetection('darwin'); + + expect(mockedDetectAll).toHaveBeenCalledTimes(1); + + resolveFn([{ displayName: 'VS Code', id: 'vscode', installed: true }]); + const results = await Promise.all([p1, p2, p3]); + + // all three share the same resolved value + expect(results[0]).toBe(results[1]); + expect(results[1]).toBe(results[2]); + expect(mockedDetectAll).toHaveBeenCalledTimes(1); + }); + + it('subsequent serial calls reuse the cached promise', async () => { + mockedDetectAll.mockResolvedValueOnce([ + { displayName: 'VS Code', id: 'vscode', installed: true }, + ]); + + await getCachedDetection('darwin'); + await getCachedDetection('darwin'); + await getCachedDetection('darwin'); + + expect(mockedDetectAll).toHaveBeenCalledTimes(1); + }); + + it('re-invokes detection after clearDetectionCache', async () => { + mockedDetectAll.mockResolvedValueOnce([ + { displayName: 'VS Code', id: 'vscode', installed: true }, + ]); + await getCachedDetection('darwin'); + expect(mockedDetectAll).toHaveBeenCalledTimes(1); + + clearDetectionCache(); + mockedDetectAll.mockResolvedValueOnce([ + { displayName: 'VS Code', id: 'vscode', installed: false }, + ]); + await getCachedDetection('darwin'); + + expect(mockedDetectAll).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/main/modules/openInApp/__tests__/detectors.test.ts b/apps/desktop/src/main/modules/openInApp/__tests__/detectors.test.ts new file mode 100644 index 0000000000..9591e2062f --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/__tests__/detectors.test.ts @@ -0,0 +1,274 @@ +import { execFile } from 'node:child_process'; +import { access } from 'node:fs/promises'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { detectAllApps, detectApp } from '../detectors'; +import { extractAllIcons } from '../iconExtractor'; + +// Mock logger +vi.mock('@/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +// Mock node:fs/promises +vi.mock('node:fs/promises', () => ({ + access: vi.fn(), +})); + +// Mock node:child_process - execFile is wrapped via promisify, so the mock must +// expose execFile as the underlying callback-style function we can drive. +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})); + +// Mock the icon extractor — detection tests should not depend on real icon +// extraction. The default returns an empty Map (no icons) which leaves the +// `icon` field absent from all detection results. +vi.mock('../iconExtractor', () => ({ + extractAllIcons: vi.fn(async () => new Map()), +})); + +const mockedAccess = vi.mocked(access); +const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType; + +interface ExecOutcome { + code: number; + error?: NodeJS.ErrnoException; + stderr?: string; + stdout?: string; +} + +const respondExec = (outcome: ExecOutcome) => { + mockedExecFile.mockImplementationOnce( + (_file: string, _args: string[], _opts: unknown, cb: any) => { + const callback = typeof _opts === 'function' ? _opts : cb; + if (outcome.code === 0) { + callback(null, outcome.stdout ?? '', outcome.stderr ?? ''); + } else { + const err: NodeJS.ErrnoException & { stderr?: string } = + outcome.error ?? new Error('exec failed'); + err.stderr = outcome.stderr ?? ''; + (err as any).code = outcome.code; + callback(err, '', outcome.stderr ?? ''); + } + return undefined as any; + }, + ); +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('detectApp', () => { + describe('appBundle strategy', () => { + it('returns true when fs.access resolves for any path', async () => { + mockedAccess.mockRejectedValueOnce(new Error('missing')); + mockedAccess.mockResolvedValueOnce(undefined); + + const result = await detectApp('terminal', 'darwin'); + + expect(result).toBe(true); + expect(mockedAccess).toHaveBeenCalledTimes(2); + }); + + it('returns false when all paths reject', async () => { + mockedAccess.mockRejectedValue(new Error('missing')); + + const result = await detectApp('vscode', 'darwin'); + + expect(result).toBe(false); + }); + }); + + describe('commandV strategy', () => { + it('returns true on exit 0', async () => { + respondExec({ code: 0, stdout: '/usr/bin/zed' }); + + const result = await detectApp('zed', 'linux'); + + expect(result).toBe(true); + expect(mockedExecFile).toHaveBeenCalledWith( + '/bin/sh', + ['-c', 'command -v "zed"'], + expect.any(Function), + ); + }); + + it('returns false on non-zero exit', async () => { + respondExec({ code: 1, stderr: 'not found' }); + + const result = await detectApp('zed', 'linux'); + + expect(result).toBe(false); + }); + + it('rejects unsafe binary names without spawning a shell', async () => { + // We monkey-patch a registry entry transiently to inject a malicious binary. + const registry = await import('../registry'); + const originalGhostty = registry.APP_REGISTRY.ghostty.detect.linux; + registry.APP_REGISTRY.ghostty.detect.linux = { + binary: 'foo; rm -rf /', + type: 'commandV', + }; + + const result = await detectApp('ghostty', 'linux'); + + expect(result).toBe(false); + expect(mockedExecFile).not.toHaveBeenCalled(); + + registry.APP_REGISTRY.ghostty.detect.linux = originalGhostty; + }); + }); + + describe('registryAppPaths strategy', () => { + it('returns true on exit 0', async () => { + respondExec({ code: 0, stdout: 'C:\\Program Files\\code.exe' }); + + const result = await detectApp('vscode', 'win32'); + + expect(result).toBe(true); + expect(mockedExecFile).toHaveBeenCalledWith( + 'where', + ['Code.exe'], + { windowsHide: true }, + expect.any(Function), + ); + }); + + it('returns false on non-zero exit', async () => { + respondExec({ code: 1, stderr: 'not found' }); + + const result = await detectApp('vscode', 'win32'); + + expect(result).toBe(false); + }); + }); + + it('returns false when platform has no detect entry for the app', async () => { + const result = await detectApp('xcode', 'linux'); + + expect(result).toBe(false); + expect(mockedAccess).not.toHaveBeenCalled(); + expect(mockedExecFile).not.toHaveBeenCalled(); + }); + + it('returns true for ALWAYS_INSTALLED entries without probing', async () => { + const darwinFinder = await detectApp('finder', 'darwin'); + const win32Explorer = await detectApp('explorer', 'win32'); + const linuxFiles = await detectApp('files', 'linux'); + + expect(darwinFinder).toBe(true); + expect(win32Explorer).toBe(true); + expect(linuxFiles).toBe(true); + expect(mockedAccess).not.toHaveBeenCalled(); + expect(mockedExecFile).not.toHaveBeenCalled(); + }); +}); + +describe('detectAllApps', () => { + it('returns one entry per AppId regardless of platform', async () => { + mockedAccess.mockRejectedValue(new Error('missing')); + mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => { + const callback = typeof _opts === 'function' ? _opts : cb; + const err: NodeJS.ErrnoException = new Error('fail'); + callback(err, '', ''); + return undefined as any; + }); + + const apps = await detectAllApps('linux'); + + const registry = await import('../registry'); + expect(apps.length).toBe(Object.keys(registry.APP_REGISTRY).length); + // every entry has the three required fields + for (const app of apps) { + expect(app).toEqual( + expect.objectContaining({ + displayName: expect.any(String), + id: expect.any(String), + installed: expect.any(Boolean), + }), + ); + } + }); + + it('marks unsupported-on-platform apps as not installed', async () => { + mockedAccess.mockRejectedValue(new Error('missing')); + mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => { + const callback = typeof _opts === 'function' ? _opts : cb; + const err: NodeJS.ErrnoException = new Error('fail'); + callback(err, '', ''); + return undefined as any; + }); + + const apps = await detectAllApps('linux'); + + const xcode = apps.find((a) => a.id === 'xcode'); + expect(xcode?.installed).toBe(false); + }); + + it('marks ALWAYS_INSTALLED platform file manager as installed without probes', async () => { + mockedAccess.mockRejectedValue(new Error('missing')); + mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => { + const callback = typeof _opts === 'function' ? _opts : cb; + const err: NodeJS.ErrnoException = new Error('fail'); + callback(err, '', ''); + return undefined as any; + }); + + const apps = await detectAllApps('darwin'); + + const finder = apps.find((a) => a.id === 'finder'); + expect(finder?.installed).toBe(true); + }); + + it('merges extracted icons onto installed apps only', async () => { + mockedAccess.mockRejectedValue(new Error('missing')); + mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => { + const callback = typeof _opts === 'function' ? _opts : cb; + const err: NodeJS.ErrnoException = new Error('fail'); + callback(err, '', ''); + return undefined as any; + }); + + vi.mocked(extractAllIcons).mockResolvedValueOnce( + new Map([['finder', 'data:image/png;base64,FAKE']]), + ); + + const apps = await detectAllApps('darwin'); + + const finder = apps.find((a) => a.id === 'finder'); + expect(finder?.icon).toBe('data:image/png;base64,FAKE'); + + // not-installed apps must not have an icon field + const xcode = apps.find((a) => a.id === 'xcode'); + expect(xcode?.installed).toBe(false); + expect(xcode?.icon).toBeUndefined(); + }); + + it('passes only installed AppIds to extractAllIcons', async () => { + mockedAccess.mockRejectedValue(new Error('missing')); + mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => { + const callback = typeof _opts === 'function' ? _opts : cb; + const err: NodeJS.ErrnoException = new Error('fail'); + callback(err, '', ''); + return undefined as any; + }); + + vi.mocked(extractAllIcons).mockResolvedValueOnce(new Map()); + + await detectAllApps('darwin'); + + expect(extractAllIcons).toHaveBeenCalledTimes(1); + const [ids, platform] = vi.mocked(extractAllIcons).mock.calls[0]; + expect(platform).toBe('darwin'); + // only finder is ALWAYS_INSTALLED on darwin; all others fail probes + expect(ids).toEqual(['finder']); + }); +}); diff --git a/apps/desktop/src/main/modules/openInApp/__tests__/iconExtractor.test.ts b/apps/desktop/src/main/modules/openInApp/__tests__/iconExtractor.test.ts new file mode 100644 index 0000000000..4e97fff8ff --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/__tests__/iconExtractor.test.ts @@ -0,0 +1,261 @@ +import { execFile } from 'node:child_process'; +import { access, mkdtemp, readFile, unlink } from 'node:fs/promises'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { __resetForTest, extractAllIcons, extractAppIcon } from '../iconExtractor'; + +vi.mock('@/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('node:fs/promises', () => ({ + access: vi.fn(), + mkdtemp: vi.fn(), + readFile: vi.fn(), + unlink: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})); + +const mockedAccess = vi.mocked(access); +const mockedMkdtemp = vi.mocked(mkdtemp); +const mockedReadFile = vi.mocked(readFile); +const mockedUnlink = vi.mocked(unlink); +const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType; + +/** + * Drives the next execFile call. The promisified callback signature is + * `(error, stdout, stderr)`; non-error responses resolve with stdout. + */ +const respondExec = ( + match: { args?: string[]; binary: string }, + outcome: { error?: Error; stderr?: string; stdout?: string }, +) => { + mockedExecFile.mockImplementationOnce( + (_file: string, _args: string[], _opts: unknown, cb: any) => { + const callback = typeof _opts === 'function' ? _opts : cb; + if (_file !== match.binary) { + callback(new Error(`unexpected binary: ${_file}`), '', ''); + return undefined as any; + } + if (match.args && JSON.stringify(_args) !== JSON.stringify(match.args)) { + callback(new Error(`unexpected args: ${JSON.stringify(_args)}`), '', ''); + return undefined as any; + } + if (outcome.error) { + callback(outcome.error, '', outcome.stderr ?? ''); + } else { + callback(null, outcome.stdout ?? '', outcome.stderr ?? ''); + } + return undefined as any; + }, + ); +}; + +// Shorthand: tools-available probe passes (which plutil + which sips both 0). +const respondToolsAvailable = () => { + // /usr/bin/which plutil + respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/plutil\n' }); + // /usr/bin/which sips + respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/sips\n' }); +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockedAccess.mockReset(); + mockedMkdtemp.mockReset(); + mockedReadFile.mockReset(); + mockedUnlink.mockReset(); + mockedExecFile.mockReset(); + mockedUnlink.mockResolvedValue(undefined); + __resetForTest(); +}); + +describe('extractAppIcon', () => { + it('returns a data URL when plutil + sips succeed on darwin', async () => { + respondToolsAvailable(); + mockedAccess.mockResolvedValueOnce(undefined); // bundle exists + // plutil CFBundleIconFile lookup + respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' }); + mockedAccess.mockResolvedValueOnce(undefined); // .icns exists + mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test'); + // sips conversion + respondExec({ binary: 'sips' }, { stdout: '' }); + mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header + + const result = await extractAppIcon('vscode', 'darwin'); + + expect(result).toBe( + `data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`, + ); + }); + + it('appends .icns suffix when CFBundleIconFile has no extension', async () => { + respondToolsAvailable(); + mockedAccess.mockResolvedValueOnce(undefined); // bundle exists + respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' }); + mockedAccess.mockImplementationOnce(async (p: any) => { + // .icns existence check — verify suffix appended + if (typeof p === 'string' && p.endsWith('Terminal.icns')) return undefined; + throw new Error('wrong path: ' + String(p)); + }); + mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test'); + respondExec({ binary: 'sips' }, { stdout: '' }); + mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50])); + + const result = await extractAppIcon('terminal', 'darwin'); + + expect(result).toBeDefined(); + expect(result!.startsWith('data:image/png;base64,')).toBe(true); + }); + + it('falls back to the next path when the first bundle does not exist', async () => { + respondToolsAvailable(); + // terminal has two candidate paths; first fails, second succeeds. + mockedAccess.mockRejectedValueOnce(new Error('missing')); + mockedAccess.mockResolvedValueOnce(undefined); + respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' }); + mockedAccess.mockResolvedValueOnce(undefined); + mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test'); + respondExec({ binary: 'sips' }, { stdout: '' }); + mockedReadFile.mockResolvedValueOnce(Buffer.from([0xff])); + + const result = await extractAppIcon('terminal', 'darwin'); + + expect(result).toBeDefined(); + }); + + it('returns undefined when no bundle path exists', async () => { + respondToolsAvailable(); + mockedAccess.mockRejectedValue(new Error('missing')); + + const result = await extractAppIcon('vscode', 'darwin'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when plutil cannot read CFBundleIconFile', async () => { + respondToolsAvailable(); + mockedAccess.mockResolvedValueOnce(undefined); + respondExec({ binary: 'plutil' }, { error: new Error('plutil: not found') }); + + const result = await extractAppIcon('vscode', 'darwin'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when the resolved .icns is missing', async () => { + respondToolsAvailable(); + mockedAccess.mockResolvedValueOnce(undefined); // bundle exists + respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' }); + mockedAccess.mockRejectedValueOnce(new Error('missing icns')); // .icns missing + + const result = await extractAppIcon('vscode', 'darwin'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when sips fails', async () => { + respondToolsAvailable(); + mockedAccess.mockResolvedValueOnce(undefined); + respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' }); + mockedAccess.mockResolvedValueOnce(undefined); + mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test'); + respondExec({ binary: 'sips' }, { error: new Error('sips error') }); + + const result = await extractAppIcon('vscode', 'darwin'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when the produced PNG is empty', async () => { + respondToolsAvailable(); + mockedAccess.mockResolvedValueOnce(undefined); + respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' }); + mockedAccess.mockResolvedValueOnce(undefined); + mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test'); + respondExec({ binary: 'sips' }, { stdout: '' }); + mockedReadFile.mockResolvedValueOnce(Buffer.alloc(0)); + + const result = await extractAppIcon('vscode', 'darwin'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when registry has no darwin entry for the app', async () => { + respondToolsAvailable(); + const result = await extractAppIcon('explorer', 'darwin'); + expect(result).toBeUndefined(); + expect(mockedAccess).not.toHaveBeenCalled(); + }); + + it('returns undefined on win32 (extractor is macOS-only)', async () => { + const result = await extractAppIcon('vscode', 'win32'); + expect(result).toBeUndefined(); + expect(mockedExecFile).not.toHaveBeenCalled(); + }); + + it('returns undefined on linux (extractor is macOS-only)', async () => { + const result = await extractAppIcon('vscode', 'linux'); + expect(result).toBeUndefined(); + expect(mockedExecFile).not.toHaveBeenCalled(); + }); +}); + +describe('extractAllIcons', () => { + it('returns a map of only AppIds with successfully extracted icons', async () => { + respondToolsAvailable(); + + // vscode succeeds + mockedAccess.mockResolvedValueOnce(undefined); // bundle + respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' }); + mockedAccess.mockResolvedValueOnce(undefined); // .icns + mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test'); + respondExec({ binary: 'sips' }, { stdout: '' }); + mockedReadFile.mockResolvedValueOnce(Buffer.from('vscode')); + + // cursor fails at bundle access (try all paths fail) + mockedAccess.mockRejectedValue(new Error('missing')); + + // xcode succeeds — reset access for it + // (subsequent calls to mockedAccess will keep returning rejection) + // So this test exercises: success, fail-no-bundle. + + const map = await extractAllIcons(['vscode', 'cursor'], 'darwin'); + + expect(map.has('vscode')).toBe(true); + expect(map.has('cursor')).toBe(false); + }); + + it('returns empty map when input list is empty', async () => { + const map = await extractAllIcons([], 'darwin'); + expect(map.size).toBe(0); + }); + + it('does not throw when extraction errors', async () => { + respondToolsAvailable(); + mockedAccess.mockResolvedValueOnce(undefined); + respondExec({ binary: 'plutil' }, { error: new Error('boom') }); + + const map = await extractAllIcons(['vscode'], 'darwin'); + + expect(map.size).toBe(0); + }); + + it('skips all when tools are unavailable', async () => { + // /usr/bin/which plutil fails + respondExec({ binary: '/usr/bin/which' }, { error: new Error('not found') }); + + const map = await extractAllIcons(['vscode', 'terminal'], 'darwin'); + + expect(map.size).toBe(0); + }); +}); diff --git a/apps/desktop/src/main/modules/openInApp/__tests__/launchers.test.ts b/apps/desktop/src/main/modules/openInApp/__tests__/launchers.test.ts new file mode 100644 index 0000000000..0e9c5eedd8 --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/__tests__/launchers.test.ts @@ -0,0 +1,247 @@ +import { execFile } from 'node:child_process'; +import { access } from 'node:fs/promises'; + +import { shell } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { launchApp } from '../launchers'; + +vi.mock('@/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('node:fs/promises', () => ({ + access: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})); + +vi.mock('electron', () => ({ + shell: { + openPath: vi.fn(), + }, +})); + +const mockedAccess = vi.mocked(access); +const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType; +const mockedShell = vi.mocked(shell); + +type LastCall = { file: string; args: string[] }; + +const captureExec = (): LastCall => { + expect(mockedExecFile).toHaveBeenCalled(); + const [file, args] = mockedExecFile.mock.calls[0]; + return { args: args as string[], file: file as string }; +}; + +interface ExecOutcome { + code: number; + stderr?: string; + stdout?: string; +} + +const respondExec = (outcome: ExecOutcome) => { + mockedExecFile.mockImplementationOnce( + (_file: string, _args: string[], _opts: unknown, cb: any) => { + const callback = typeof _opts === 'function' ? _opts : cb; + if (outcome.code === 0) { + callback(null, outcome.stdout ?? '', outcome.stderr ?? ''); + } else { + const err: NodeJS.ErrnoException & { stderr?: string } = new Error('exec failed'); + err.stderr = outcome.stderr ?? ''; + (err as any).code = outcome.code; + callback(err, '', outcome.stderr ?? ''); + } + return undefined as any; + }, + ); +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockedAccess.mockResolvedValue(undefined); +}); + +describe('launchApp - path validation', () => { + it('rejects relative paths', async () => { + const result = await launchApp('vscode', 'relative/path', 'darwin'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Path must be absolute'); + expect(mockedExecFile).not.toHaveBeenCalled(); + }); + + it('rejects paths that do not exist', async () => { + mockedAccess.mockRejectedValueOnce(new Error('ENOENT')); + + const result = await launchApp('vscode', '/missing', 'darwin'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Path not found: /missing'); + expect(mockedExecFile).not.toHaveBeenCalled(); + }); + + it('returns error when app is not available on platform', async () => { + const result = await launchApp('xcode', '/some/path', 'linux'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Xcode'); + expect(result.error).toContain('not available on this platform'); + }); +}); + +describe('launchApp - macOpenA strategy', () => { + it('spawns open -a ', async () => { + respondExec({ code: 0 }); + + const result = await launchApp('vscode', '/work/dir', 'darwin'); + + expect(result.success).toBe(true); + const call = captureExec(); + expect(call.file).toBe('open'); + expect(call.args).toEqual(['-a', 'Visual Studio Code', '/work/dir']); + }); + + it('returns stderr substring on failure', async () => { + respondExec({ code: 1, stderr: ' cannot open Cursor.app ' }); + + const result = await launchApp('cursor', '/work/dir', 'darwin'); + + expect(result.success).toBe(false); + expect(result.error).toBe('cannot open Cursor.app'); + }); +}); + +describe('launchApp - macOpen strategy', () => { + it('spawns open ', async () => { + respondExec({ code: 0 }); + + const result = await launchApp('finder', '/work/dir', 'darwin'); + + expect(result.success).toBe(true); + const call = captureExec(); + expect(call.file).toBe('open'); + expect(call.args).toEqual(['/work/dir']); + }); +}); + +describe('launchApp - exec strategy', () => { + it('spawns ', async () => { + respondExec({ code: 0 }); + + const result = await launchApp('vscode', '/work/dir', 'linux'); + + expect(result.success).toBe(true); + const call = captureExec(); + expect(call.file).toBe('code'); + expect(call.args).toEqual(['/work/dir']); + }); + + it('appends registry-provided args before path', async () => { + const registry = await import('../registry'); + const original = registry.APP_REGISTRY.vscode.launch.linux; + registry.APP_REGISTRY.vscode.launch.linux = { + args: ['--new-window'], + binary: 'code', + type: 'exec', + }; + + respondExec({ code: 0 }); + + const result = await launchApp('vscode', '/work/dir', 'linux'); + + expect(result.success).toBe(true); + const call = captureExec(); + expect(call.args).toEqual(['--new-window', '/work/dir']); + + registry.APP_REGISTRY.vscode.launch.linux = original; + }); + + it('rejects suspicious binary names', async () => { + const registry = await import('../registry'); + const original = registry.APP_REGISTRY.vscode.launch.linux; + registry.APP_REGISTRY.vscode.launch.linux = { + binary: 'rm; ls', + type: 'exec', + }; + + const result = await launchApp('vscode', '/work/dir', 'linux'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid binary name'); + expect(mockedExecFile).not.toHaveBeenCalled(); + + registry.APP_REGISTRY.vscode.launch.linux = original; + }); + + it('rejects binary names with spaces', async () => { + const registry = await import('../registry'); + const original = registry.APP_REGISTRY.vscode.launch.linux; + registry.APP_REGISTRY.vscode.launch.linux = { + binary: 'foo bar', + type: 'exec', + }; + + const result = await launchApp('vscode', '/work/dir', 'linux'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid binary name'); + + registry.APP_REGISTRY.vscode.launch.linux = original; + }); + + it('accepts absolute-path binary names', async () => { + const registry = await import('../registry'); + const original = registry.APP_REGISTRY.vscode.launch.linux; + registry.APP_REGISTRY.vscode.launch.linux = { + binary: '/usr/local/bin/code', + type: 'exec', + }; + + respondExec({ code: 0 }); + + const result = await launchApp('vscode', '/work/dir', 'linux'); + + expect(result.success).toBe(true); + const call = captureExec(); + expect(call.file).toBe('/usr/local/bin/code'); + + registry.APP_REGISTRY.vscode.launch.linux = original; + }); + + it('returns stderr substring on non-zero exit', async () => { + respondExec({ code: 1, stderr: 'command not found' }); + + const result = await launchApp('vscode', '/work/dir', 'linux'); + + expect(result.success).toBe(false); + expect(result.error).toBe('command not found'); + }); +}); + +describe('launchApp - shellOpenPath strategy', () => { + it('delegates to shell.openPath', async () => { + mockedShell.openPath.mockResolvedValueOnce(''); + + const result = await launchApp('explorer', '/abs/work-dir', 'win32'); + + expect(result.success).toBe(true); + expect(mockedShell.openPath).toHaveBeenCalledWith('/abs/work-dir'); + }); + + it('returns error string from shell.openPath as error', async () => { + mockedShell.openPath.mockResolvedValueOnce('cannot open'); + + const result = await launchApp('files', '/some/dir', 'linux'); + + expect(result.success).toBe(false); + expect(result.error).toBe('cannot open'); + }); +}); diff --git a/apps/desktop/src/main/modules/openInApp/cache.ts b/apps/desktop/src/main/modules/openInApp/cache.ts new file mode 100644 index 0000000000..faf909e9a5 --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/cache.ts @@ -0,0 +1,18 @@ +import type { DetectedApp } from '@lobechat/electron-client-ipc'; + +import { detectAllApps } from './detectors'; + +let cachedPromise: Promise | null = null; + +export const getCachedDetection = ( + platform: NodeJS.Platform = process.platform, +): Promise => { + if (!cachedPromise) { + cachedPromise = detectAllApps(platform); + } + return cachedPromise; +}; + +export const clearDetectionCache = (): void => { + cachedPromise = null; +}; diff --git a/apps/desktop/src/main/modules/openInApp/detectors.ts b/apps/desktop/src/main/modules/openInApp/detectors.ts new file mode 100644 index 0000000000..0d275939fb --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/detectors.ts @@ -0,0 +1,109 @@ +import { execFile } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { promisify } from 'node:util'; + +import type { DetectedApp, OpenInAppId } from '@lobechat/electron-client-ipc'; + +import { createLogger } from '@/utils/logger'; + +import { extractAllIcons } from './iconExtractor'; +import type { DetectStrategy } from './registry'; +import { ALWAYS_INSTALLED, APP_REGISTRY } from './registry'; + +// Icon extraction shells out to plutil + sips on macOS (see iconExtractor.ts) +// so Electron itself cannot crash on `app.getFileIcon` regressions. Renderer +// falls back to lucide if extraction returns undefined. + +const logger = createLogger('modules:openInApp:detectors'); + +const execFileAsync = promisify(execFile); + +const SAFE_BINARY_REGEX = /^[\w.-]+$/; + +const probeAppBundle = async (paths: string[]): Promise => { + for (const path of paths) { + try { + await access(path); + return true; + } catch { + // try next + } + } + return false; +}; + +const probeCommandV = async (binary: string): Promise => { + if (!SAFE_BINARY_REGEX.test(binary)) { + logger.debug(`rejecting unsafe binary name for commandV: ${binary}`); + return false; + } + try { + await execFileAsync('/bin/sh', ['-c', `command -v "${binary}"`]); + return true; + } catch (error) { + logger.debug(`commandV probe failed for ${binary}: ${(error as Error).message}`); + return false; + } +}; + +const probeRegistryAppPaths = async (exeName: string): Promise => { + try { + await execFileAsync('where', [exeName], { windowsHide: true }); + return true; + } catch (error) { + logger.debug(`where probe failed for ${exeName}: ${(error as Error).message}`); + return false; + } +}; + +const runDetectStrategy = (strategy: DetectStrategy): Promise => { + switch (strategy.type) { + case 'appBundle': { + return probeAppBundle(strategy.paths); + } + case 'commandV': { + return probeCommandV(strategy.binary); + } + case 'registryAppPaths': { + return probeRegistryAppPaths(strategy.exeName); + } + } +}; + +export const detectApp = async (id: OpenInAppId, platform: NodeJS.Platform): Promise => { + if (ALWAYS_INSTALLED[platform] === id) { + return true; + } + const descriptor = APP_REGISTRY[id]; + const strategy = descriptor?.detect[platform]; + if (!strategy) { + return false; + } + return runDetectStrategy(strategy); +}; + +export const detectAllApps = async ( + platform: NodeJS.Platform = process.platform, +): Promise => { + const entries = Object.entries(APP_REGISTRY) as Array< + [OpenInAppId, (typeof APP_REGISTRY)[OpenInAppId]] + >; + const installedFlags = await Promise.all(entries.map(([id]) => detectApp(id, platform))); + + // Extract icons for installed apps only. Extraction shells out to plutil + + // sips (see iconExtractor.ts) so it cannot crash the renderer; failures + // resolve to undefined and the renderer falls back to lucide icons. + const installedIds = entries.filter((_entry, i) => installedFlags[i]).map(([id]) => id); + const icons = await extractAllIcons(installedIds, platform); + + return entries.map(([id, descriptor], i) => { + const installed = installedFlags[i]; + const icon = installed ? icons.get(id) : undefined; + return { + displayName: descriptor.displayName, + id, + installed, + ...(icon ? { icon } : {}), + } satisfies DetectedApp; + }); +}; diff --git a/apps/desktop/src/main/modules/openInApp/iconExtractor.ts b/apps/desktop/src/main/modules/openInApp/iconExtractor.ts new file mode 100644 index 0000000000..befbc797df --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/iconExtractor.ts @@ -0,0 +1,210 @@ +import { execFile } from 'node:child_process'; +import { access, mkdtemp, readFile, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import type { OpenInAppId } from '@lobechat/electron-client-ipc'; + +import { createLogger } from '@/utils/logger'; + +import { APP_REGISTRY } from './registry'; + +const logger = createLogger('modules:openInApp:iconExtractor'); + +// Manual promise wrapper rather than util.promisify(execFile): the latter +// relies on execFile's custom `util.promisify.custom` symbol to return +// `{ stdout, stderr }`, which vi.fn() mocks don't carry — so destructuring +// silently yields `undefined` under test. This wrapper resolves directly to +// the stdout string and is mock-friendly. +const execFileToString = ( + file: string, + args: string[], + opts?: { timeout?: number }, +): Promise => + new Promise((resolve, reject) => { + const cb = (err: Error | null, stdout: string, stderr: string) => { + if (err) { + (err as Error & { stderr?: string }).stderr = stderr; + reject(err); + } else { + resolve(stdout); + } + }; + if (opts) execFile(file, args, opts, cb); + else execFile(file, args, cb); + }); + +/** Render dimensions for the extracted PNG. 64 keeps the payload tiny while + * staying crisp at the renderer's 16-20 px display size on retina. */ +const ICON_SIZE = 64; + +/** Per-extraction bound. plutil and sips are local file ops; tens of ms is + * typical, so a generous timeout still catches real hangs. */ +const EXEC_TIMEOUT_MS = 5000; + +let tmpDirPromise: Promise | undefined; + +const ensureTmpDir = async (): Promise => { + if (tmpDirPromise) return tmpDirPromise; + tmpDirPromise = (async () => { + try { + return await mkdtemp(path.join(tmpdir(), 'lobehub-openinapp-')); + } catch (error) { + logger.debug(`failed to create tmp dir: ${(error as Error).message}`); + return undefined; + } + })(); + return tmpDirPromise; +}; + +let toolsAvailablePromise: Promise | undefined; + +/** + * Confirm `plutil` and `sips` are both on PATH. Both ship with every macOS + * install so this is effectively a sanity check; cached for the process lifetime. + */ +const areToolsAvailable = (): Promise => { + if (toolsAvailablePromise) return toolsAvailablePromise; + toolsAvailablePromise = (async () => { + try { + await execFileToString('/usr/bin/which', ['plutil']); + await execFileToString('/usr/bin/which', ['sips']); + return true; + } catch { + logger.debug('plutil or sips missing from PATH; falling back to renderer icons'); + return false; + } + })(); + return toolsAvailablePromise; +}; + +const resolveDarwinBundlePath = async (id: OpenInAppId): Promise => { + const strategy = APP_REGISTRY[id]?.detect.darwin; + if (!strategy || strategy.type !== 'appBundle') return undefined; + for (const candidate of strategy.paths) { + try { + await access(candidate); + return candidate; + } catch { + // try next + } + } + return undefined; +}; + +/** + * Look up the bundle's icon file name via Info.plist (`CFBundleIconFile`). + * Returns the resolved absolute .icns path, or undefined if not derivable. + */ +const resolveIcnsPath = async (bundlePath: string): Promise => { + const plistPath = path.join(bundlePath, 'Contents', 'Info.plist'); + try { + const stdout = await execFileToString( + 'plutil', + ['-extract', 'CFBundleIconFile', 'raw', plistPath], + { timeout: EXEC_TIMEOUT_MS }, + ); + const iconName = stdout.trim(); + if (!iconName) return undefined; + const fileName = iconName.endsWith('.icns') ? iconName : `${iconName}.icns`; + const icnsPath = path.join(bundlePath, 'Contents', 'Resources', fileName); + await access(icnsPath); + return icnsPath; + } catch (error) { + logger.debug(`resolveIcnsPath failed for ${bundlePath}: ${(error as Error).message}`); + return undefined; + } +}; + +/** + * Resize/convert the given .icns to a 64×64 PNG using sips, then return the + * base64 data URL. The PNG file is unlinked after read. + */ +const renderIcnsToDataUrl = async ( + icnsPath: string, + tmpDir: string, + filename: string, +): Promise => { + const outPath = path.join(tmpDir, filename); + try { + await execFileToString( + 'sips', + [ + '-z', + String(ICON_SIZE), + String(ICON_SIZE), + '-s', + 'format', + 'png', + icnsPath, + '--out', + outPath, + ], + { timeout: EXEC_TIMEOUT_MS }, + ); + const buf = await readFile(outPath); + if (buf.length === 0) return undefined; + return `data:image/png;base64,${buf.toString('base64')}`; + } catch (error) { + logger.debug(`sips failed for ${icnsPath}: ${(error as Error).message}`); + return undefined; + } finally { + unlink(outPath).catch(() => undefined); + } +}; + +/** + * Extract the real macOS app icon for the given AppId by reading the bundle's + * Info.plist (`CFBundleIconFile`) and rendering the resolved .icns via `sips`. + * Both `plutil` and `sips` ship with every macOS install — no Xcode, swift, or + * electron-builder bundling required, and no JXA / NSImage drawing path + * (which is broken in JXA: lockFocus and NSGraphicsContext class methods are + * not exposed). macOS only; other platforms return undefined. + */ +export const extractAppIcon = async ( + id: OpenInAppId, + platform: NodeJS.Platform = process.platform, +): Promise => { + if (platform !== 'darwin') return undefined; + try { + if (!(await areToolsAvailable())) return undefined; + const bundlePath = await resolveDarwinBundlePath(id); + if (!bundlePath) return undefined; + const icnsPath = await resolveIcnsPath(bundlePath); + if (!icnsPath) return undefined; + const tmpDir = await ensureTmpDir(); + if (!tmpDir) return undefined; + return await renderIcnsToDataUrl(icnsPath, tmpDir, `${id}.png`); + } catch (error) { + logger.debug(`extractAppIcon error for ${id}: ${(error as Error).message}`); + return undefined; + } +}; + +/** + * Resolve icons for a list of installed AppIds. Sequential — keeps spawn + * pressure low and matches the underlying single-thread tools. + */ +export const extractAllIcons = async ( + installedIds: OpenInAppId[], + platform: NodeJS.Platform = process.platform, +): Promise> => { + const map = new Map(); + for (const id of installedIds) { + try { + const icon = await extractAppIcon(id, platform); + if (icon) map.set(id, icon); + } catch (error) { + logger.debug(`extractAllIcons: skipping ${id} after error: ${(error as Error).message}`); + } + } + return map; +}; + +/** + * Test-only: reset the module-level caches so each test starts fresh. + */ +export const __resetForTest = () => { + tmpDirPromise = undefined; + toolsAvailablePromise = undefined; +}; diff --git a/apps/desktop/src/main/modules/openInApp/launchers.ts b/apps/desktop/src/main/modules/openInApp/launchers.ts new file mode 100644 index 0000000000..695ec8d98b --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/launchers.ts @@ -0,0 +1,106 @@ +import { execFile } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import type { OpenInAppId, OpenInAppResult } from '@lobechat/electron-client-ipc'; +import { shell } from 'electron'; + +import { createLogger } from '@/utils/logger'; + +import type { LaunchStrategy } from './registry'; +import { APP_REGISTRY } from './registry'; + +const logger = createLogger('modules:openInApp:launchers'); + +const execFileAsync = promisify(execFile); + +const SAFE_BINARY_REGEX = /^[\w.-]+$/; + +const isAllowedBinary = (binary: string): boolean => + SAFE_BINARY_REGEX.test(binary) || path.isAbsolute(binary); + +interface ExecError extends Error { + stderr?: string; +} + +const formatExecError = (error: unknown): string => { + const err = error as ExecError; + const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : ''; + const fallback = err?.message ?? 'Launch failed'; + return (stderr || fallback).slice(0, 200); +}; + +const runLaunchStrategy = async ( + strategy: LaunchStrategy, + absolutePath: string, +): Promise => { + switch (strategy.type) { + case 'macOpenA': { + try { + await execFileAsync('open', ['-a', strategy.appName, absolutePath]); + return { success: true }; + } catch (error) { + return { error: formatExecError(error), success: false }; + } + } + case 'macOpen': { + try { + await execFileAsync('open', [absolutePath]); + return { success: true }; + } catch (error) { + return { error: formatExecError(error), success: false }; + } + } + case 'exec': { + if (!isAllowedBinary(strategy.binary)) { + return { error: 'Invalid binary name', success: false }; + } + const extraArgs = strategy.args ?? []; + try { + await execFileAsync(strategy.binary, [...extraArgs, absolutePath]); + return { success: true }; + } catch (error) { + return { error: formatExecError(error), success: false }; + } + } + case 'shellOpenPath': { + const result = await shell.openPath(absolutePath); + return result ? { error: result, success: false } : { success: true }; + } + } +}; + +export const launchApp = async ( + id: OpenInAppId, + absolutePath: string, + platform: NodeJS.Platform = process.platform, +): Promise => { + const descriptor = APP_REGISTRY[id]; + const strategy = descriptor?.launch[platform]; + if (!descriptor || !strategy) { + const displayName = descriptor?.displayName ?? id; + return { + error: `${displayName} is not available on this platform`, + success: false, + }; + } + + if (!path.isAbsolute(absolutePath)) { + return { error: 'Path must be absolute', success: false }; + } + + try { + await access(absolutePath); + } catch { + return { error: `Path not found: ${absolutePath}`, success: false }; + } + + const result = await runLaunchStrategy(strategy, absolutePath); + if (result.success) { + logger.info(`launched ${id} at ${absolutePath}`); + } else { + logger.error(`failed to launch ${id} at ${absolutePath}: ${result.error}`); + } + return result; +}; diff --git a/apps/desktop/src/main/modules/openInApp/registry.ts b/apps/desktop/src/main/modules/openInApp/registry.ts new file mode 100644 index 0000000000..0b6e3e5fab --- /dev/null +++ b/apps/desktop/src/main/modules/openInApp/registry.ts @@ -0,0 +1,129 @@ +import type { OpenInAppId } from '@lobechat/electron-client-ipc'; + +export type DetectStrategy = + | { paths: string[]; type: 'appBundle' } + | { exeName: string; type: 'registryAppPaths' } + | { binary: string; type: 'commandV' }; + +export type LaunchStrategy = + | { appName: string; type: 'macOpenA' } + | { type: 'macOpen' } + | { args?: string[]; binary: string; type: 'exec' } + | { type: 'shellOpenPath' }; + +export interface AppDescriptor { + detect: Partial>; + displayName: string; + launch: Partial>; +} + +export const APP_REGISTRY: Record = { + vscode: { + detect: { + darwin: { paths: ['/Applications/Visual Studio Code.app'], type: 'appBundle' }, + linux: { binary: 'code', type: 'commandV' }, + win32: { exeName: 'Code.exe', type: 'registryAppPaths' }, + }, + displayName: 'VS Code', + launch: { + darwin: { appName: 'Visual Studio Code', type: 'macOpenA' }, + linux: { binary: 'code', type: 'exec' }, + win32: { binary: 'code', type: 'exec' }, + }, + }, + cursor: { + detect: { + darwin: { paths: ['/Applications/Cursor.app'], type: 'appBundle' }, + linux: { binary: 'cursor', type: 'commandV' }, + win32: { exeName: 'Cursor.exe', type: 'registryAppPaths' }, + }, + displayName: 'Cursor', + launch: { + darwin: { appName: 'Cursor', type: 'macOpenA' }, + linux: { binary: 'cursor', type: 'exec' }, + win32: { binary: 'cursor', type: 'exec' }, + }, + }, + zed: { + detect: { + darwin: { paths: ['/Applications/Zed.app'], type: 'appBundle' }, + linux: { binary: 'zed', type: 'commandV' }, + }, + displayName: 'Zed', + launch: { + darwin: { appName: 'Zed', type: 'macOpenA' }, + linux: { binary: 'zed', type: 'exec' }, + }, + }, + webstorm: { + detect: { + darwin: { paths: ['/Applications/WebStorm.app'], type: 'appBundle' }, + linux: { binary: 'webstorm', type: 'commandV' }, + win32: { exeName: 'webstorm64.exe', type: 'registryAppPaths' }, + }, + displayName: 'WebStorm', + launch: { + darwin: { appName: 'WebStorm', type: 'macOpenA' }, + linux: { binary: 'webstorm', type: 'exec' }, + win32: { binary: 'webstorm', type: 'exec' }, + }, + }, + xcode: { + detect: { darwin: { paths: ['/Applications/Xcode.app'], type: 'appBundle' } }, + displayName: 'Xcode', + launch: { darwin: { appName: 'Xcode', type: 'macOpenA' } }, + }, + finder: { + detect: { + darwin: { paths: ['/System/Library/CoreServices/Finder.app'], type: 'appBundle' }, + }, + displayName: 'Finder', + launch: { darwin: { type: 'macOpen' } }, + }, + explorer: { + detect: { win32: { exeName: 'explorer.exe', type: 'registryAppPaths' } }, + displayName: 'Explorer', + launch: { win32: { type: 'shellOpenPath' } }, + }, + files: { + detect: { linux: { binary: 'xdg-open', type: 'commandV' } }, + displayName: 'Files', + launch: { linux: { type: 'shellOpenPath' } }, + }, + terminal: { + detect: { + darwin: { + paths: [ + '/System/Applications/Utilities/Terminal.app', + '/Applications/Utilities/Terminal.app', + ], + type: 'appBundle', + }, + }, + displayName: 'Terminal', + launch: { darwin: { appName: 'Terminal', type: 'macOpenA' } }, + }, + iterm2: { + detect: { darwin: { paths: ['/Applications/iTerm.app'], type: 'appBundle' } }, + displayName: 'iTerm2', + launch: { darwin: { appName: 'iTerm', type: 'macOpenA' } }, + }, + ghostty: { + detect: { + darwin: { paths: ['/Applications/Ghostty.app'], type: 'appBundle' }, + linux: { binary: 'ghostty', type: 'commandV' }, + }, + displayName: 'Ghostty', + launch: { + darwin: { appName: 'Ghostty', type: 'macOpenA' }, + linux: { binary: 'ghostty', type: 'exec' }, + }, + }, +}; + +/** AppIds that are always considered "installed" — file managers, which we treat as platform-provided. */ +export const ALWAYS_INSTALLED: Partial> = { + darwin: 'finder', + linux: 'files', + win32: 'explorer', +}; diff --git a/apps/desktop/src/main/utils/mime.ts b/apps/desktop/src/main/utils/mime.ts index a033273c22..cb014cd8fb 100644 --- a/apps/desktop/src/main/utils/mime.ts +++ b/apps/desktop/src/main/utils/mime.ts @@ -4,22 +4,46 @@ export const getExportMimeType = (filePath: string) => { const ext = path.extname(filePath).toLowerCase(); const map: Record = { + '.bash': 'text/plain; charset=utf-8', + '.c': 'text/plain; charset=utf-8', + '.cpp': 'text/plain; charset=utf-8', '.css': 'text/css; charset=utf-8', + '.csv': 'text/csv; charset=utf-8', + '.dockerfile': 'text/plain; charset=utf-8', + '.fish': 'text/plain; charset=utf-8', '.gif': 'image/gif', + '.go': 'text/plain; charset=utf-8', + '.graphql': 'application/graphql; charset=utf-8', + '.h': 'text/plain; charset=utf-8', + '.hpp': 'text/plain; charset=utf-8', '.html': 'text/html; charset=utf-8', '.ico': 'image/x-icon', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.js': 'application/javascript; charset=utf-8', + '.jsx': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', + '.log': 'text/plain; charset=utf-8', '.map': 'application/json; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.mdx': 'text/markdown; charset=utf-8', '.mp4': 'video/mp4', '.png': 'image/png', + '.py': 'text/plain; charset=utf-8', + '.rs': 'text/plain; charset=utf-8', + '.sh': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml; charset=utf-8', + '.toml': 'application/toml; charset=utf-8', + '.ts': 'text/plain; charset=utf-8', + '.tsx': 'text/plain; charset=utf-8', '.txt': 'text/plain; charset=utf-8', + '.xml': 'application/xml; charset=utf-8', + '.yaml': 'application/yaml; charset=utf-8', + '.yml': 'application/yaml; charset=utf-8', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', + '.zsh': 'text/plain; charset=utf-8', }; return map[ext]; diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index e2ea475bda..3ceda87c39 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -846,6 +846,11 @@ "workingPanel.documents.saved": "All changes saved", "workingPanel.documents.title": "Document", "workingPanel.documents.unsaved": "Unsaved changes", + "workingPanel.files.copyAbsolutePath": "Copy Path", + "workingPanel.files.copyRelativePath": "Copy Relative Path", + "workingPanel.files.open": "Open File", + "workingPanel.files.showInReview": "Show in Review", + "workingPanel.files.showInSystem": "Reveal in Folder", "workingPanel.progress": "Progress", "workingPanel.progress.allCompleted": "All tasks completed", "workingPanel.resources": "Resources", @@ -892,6 +897,8 @@ "workingPanel.review.mode.unstaged": "Unstaged", "workingPanel.review.more": "More options", "workingPanel.review.refresh": "Refresh", + "workingPanel.review.revealInTree": "Reveal in tree", + "workingPanel.review.revealNotFound": "File not found in project index", "workingPanel.review.revert": "Discard changes", "workingPanel.review.revert.confirm.cancel": "Cancel", "workingPanel.review.revert.confirm.description": "Working tree changes to {{filePath}} will be permanently discarded. Untracked files are deleted from disk.", diff --git a/locales/en-US/openInApp.json b/locales/en-US/openInApp.json new file mode 100644 index 0000000000..7f3b4c22fd --- /dev/null +++ b/locales/en-US/openInApp.json @@ -0,0 +1,8 @@ +{ + "dropdownLabel": "Open working directory in", + "errors.appNotInstalled": "{{appName}} is not installed", + "errors.launchFailed": "Failed to open in {{appName}}: {{error}}", + "errors.pathNotFound": "Path not found: {{path}}", + "errors.unknown": "unknown error", + "tooltip": "Open in {{appName}}" +} diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index aac6d6342c..9388ff276e 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -846,6 +846,11 @@ "workingPanel.documents.saved": "已保存全部变更", "workingPanel.documents.title": "文档", "workingPanel.documents.unsaved": "有未保存的变更", + "workingPanel.files.copyAbsolutePath": "复制路径", + "workingPanel.files.copyRelativePath": "复制相对路径", + "workingPanel.files.open": "打开文件", + "workingPanel.files.showInReview": "在审查中查看", + "workingPanel.files.showInSystem": "在文件夹中显示", "workingPanel.progress": "进度", "workingPanel.progress.allCompleted": "所有任务已完成", "workingPanel.resources": "资源", @@ -892,6 +897,8 @@ "workingPanel.review.mode.unstaged": "未暂存", "workingPanel.review.more": "更多选项", "workingPanel.review.refresh": "同步", + "workingPanel.review.revealInTree": "在文件树中定位", + "workingPanel.review.revealNotFound": "该文件未在项目索引中,无法定位", "workingPanel.review.revert": "撤销变更", "workingPanel.review.revert.confirm.cancel": "取消", "workingPanel.review.revert.confirm.description": "{{filePath}} 的工作区变更将被永久丢弃,未跟踪的新文件会从磁盘删除。", diff --git a/locales/zh-CN/openInApp.json b/locales/zh-CN/openInApp.json new file mode 100644 index 0000000000..173e5a14de --- /dev/null +++ b/locales/zh-CN/openInApp.json @@ -0,0 +1,8 @@ +{ + "dropdownLabel": "用以下应用打开当前目录", + "errors.appNotInstalled": "未检测到 {{appName}}", + "errors.launchFailed": "{{appName}} 打开失败:{{error}}", + "errors.pathNotFound": "路径不存在:{{path}}", + "errors.unknown": "未知错误", + "tooltip": "用 {{appName}} 打开" +} diff --git a/package.json b/package.json index bcbdb440d1..ba901a2eea 100644 --- a/package.json +++ b/package.json @@ -280,7 +280,7 @@ "@lobehub/icons": "^5.0.0", "@lobehub/market-sdk": "0.33.3", "@lobehub/tts": "^5.1.2", - "@lobehub/ui": "5.10.5", + "@lobehub/ui": "5.12.0", "@modelcontextprotocol/sdk": "^1.26.0", "@napi-rs/canvas": "^0.1.88", "@neondatabase/serverless": "^1.0.2", diff --git a/packages/const/src/index.ts b/packages/const/src/index.ts index c81bc55ed7..821d841269 100644 --- a/packages/const/src/index.ts +++ b/packages/const/src/index.ts @@ -13,6 +13,7 @@ export * from './lobehubSkill'; export * from './message'; export * from './meta'; export * from './plugin'; +export * from './protocol'; export * from './recommendedSkill'; export * from './session'; export * from './settings'; diff --git a/packages/const/src/protocol.test.ts b/packages/const/src/protocol.test.ts new file mode 100644 index 0000000000..8d95316ae5 --- /dev/null +++ b/packages/const/src/protocol.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { buildLocalFileUrl } from './protocol'; + +describe('buildLocalFileUrl', () => { + it('returns null for empty / nullish input', () => { + expect(buildLocalFileUrl(null)).toBeNull(); + expect(buildLocalFileUrl(undefined)).toBeNull(); + expect(buildLocalFileUrl('')).toBeNull(); + }); + + it('rejects relative paths', () => { + expect(buildLocalFileUrl('relative/path.png')).toBeNull(); + expect(buildLocalFileUrl('./img.png')).toBeNull(); + }); + + it('builds a URL from a POSIX absolute path', () => { + expect(buildLocalFileUrl('/Users/alice/Pictures/cat.png')).toBe( + 'localfile://file/Users/alice/Pictures/cat.png', + ); + }); + + it('builds a URL from a Windows absolute path', () => { + expect(buildLocalFileUrl('C:\\Users\\alice\\img.png')).toBe( + 'localfile://file/C%3A/Users/alice/img.png', + ); + }); + + it('percent-encodes special characters per segment', () => { + expect(buildLocalFileUrl('/Users/alice/My Pictures/图 #.png')).toBe( + 'localfile://file/Users/alice/My%20Pictures/%E5%9B%BE%20%23.png', + ); + }); + + it('survives a URL round-trip back to the original POSIX path', () => { + const original = '/Users/alice/My Pictures/cat #1.png'; + const url = new URL(buildLocalFileUrl(original)!); + const decoded = decodeURIComponent(url.pathname); + expect(decoded).toBe(original); + }); +}); diff --git a/packages/const/src/protocol.ts b/packages/const/src/protocol.ts index 421ae3c4a4..146792d47d 100644 --- a/packages/const/src/protocol.ts +++ b/packages/const/src/protocol.ts @@ -5,3 +5,41 @@ export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend'; export const withElectronProtocolIfElectron = (url: string) => { return isDesktop ? `${ELECTRON_BE_PROTOCOL_SCHEME}://lobe${url}` : url; }; + +/** + * Custom protocol the desktop main process exposes for serving arbitrary + * absolute local-disk files to the renderer (e.g. project file previews). + * Backed by `LocalFileProtocolManager` in + * `apps/desktop`. + */ +export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile'; +export const LOCAL_FILE_PROTOCOL_HOST = 'file'; + +/** + * Build a `localfile://file/` URL from an absolute filesystem path. + * + * - POSIX `/Users/a/img.png` -> `localfile://file/Users/a/img.png` + * - Win32 `C:\\Users\\a\\img.png` -> `localfile://file/C:/Users/a/img.png` + * + * Each path segment is percent-encoded so spaces / unicode / `?` / `#` + * survive the URL round-trip. The `/` separator itself is preserved. + * Returns `null` when the input is empty or not an absolute path. + */ +export const buildLocalFileUrl = (absolutePath: string | null | undefined): string | null => { + if (!absolutePath) return null; + + // Normalize Windows backslashes so the URL pathname uses forward slashes. + const forwardSlashed = absolutePath.replaceAll('\\', '/'); + + const isWindowsAbsolute = /^[a-z]:\//i.test(forwardSlashed); + const isPosixAbsolute = forwardSlashed.startsWith('/'); + if (!isWindowsAbsolute && !isPosixAbsolute) return null; + + // Drop the leading slash on POSIX paths so we get exactly one `/` between + // host and the encoded path (the protocol handler re-adds it). + const stripped = isPosixAbsolute ? forwardSlashed.slice(1) : forwardSlashed; + + const encoded = stripped.split('/').map(encodeURIComponent).join('/'); + + return `${LOCAL_FILE_PROTOCOL_SCHEME}://${LOCAL_FILE_PROTOCOL_HOST}/${encoded}`; +}; diff --git a/packages/electron-client-ipc/src/events/index.ts b/packages/electron-client-ipc/src/events/index.ts index 00325a3fbe..34173b5114 100644 --- a/packages/electron-client-ipc/src/events/index.ts +++ b/packages/electron-client-ipc/src/events/index.ts @@ -31,6 +31,13 @@ export type MainBroadcastParams = Parameters< >[0]; export type { GatewayConnectionStatus } from './gatewayConnection'; +export type { + DetectAppsResult, + DetectedApp, + OpenInAppId, + OpenInAppParams, + OpenInAppResult, +} from './openInApp'; export type { AuthorizationPhase, AuthorizationProgress, diff --git a/packages/electron-client-ipc/src/events/openInApp.ts b/packages/electron-client-ipc/src/events/openInApp.ts new file mode 100644 index 0000000000..df137d54aa --- /dev/null +++ b/packages/electron-client-ipc/src/events/openInApp.ts @@ -0,0 +1,38 @@ +export type OpenInAppId = + | 'vscode' + | 'cursor' + | 'zed' + | 'webstorm' + | 'xcode' + | 'finder' + | 'explorer' + | 'files' + | 'terminal' + | 'iterm2' + | 'ghostty'; + +export interface DetectedApp { + displayName: string; + /** + * Base64-encoded PNG data URL (e.g. "data:image/png;base64,..."). Only set + * when the platform could extract a real icon from the installed app. + * Renderer falls back to a hard-coded lucide-react icon when absent. + */ + icon?: string; + id: OpenInAppId; + installed: boolean; +} + +export interface DetectAppsResult { + apps: DetectedApp[]; +} + +export interface OpenInAppParams { + appId: OpenInAppId; + path: string; +} + +export interface OpenInAppResult { + error?: string; + success: boolean; +} diff --git a/packages/types/src/user/preference.ts b/packages/types/src/user/preference.ts index 61be1b7537..f6d36f58ad 100644 --- a/packages/types/src/user/preference.ts +++ b/packages/types/src/user/preference.ts @@ -63,6 +63,8 @@ export const UserLabSchema = z.object({ export type UserLab = z.infer; export interface UserPreference { + /** Last-used app for "Open working directory in…" split button. Empty/unknown values fall back to platform default. */ + defaultOpenInApp?: string; /** * disable markdown rendering in chat input editor * @deprecated Use lab.enableInputMarkdown instead @@ -141,6 +143,7 @@ export interface SSOProvider { export const UserPreferenceSchema = z .object({ + defaultOpenInApp: z.string().optional(), guide: UserGuideSchema.optional(), hideSyncAlert: z.boolean().optional(), lab: UserLabSchema.optional(), diff --git a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.test.tsx b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.test.tsx index b83ab5b25f..002ea93826 100644 --- a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.test.tsx +++ b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.test.tsx @@ -132,7 +132,7 @@ vi.mock('@/features/ExplorerTree', () => { ); }; - return { ExplorerTree }; + return { ExplorerTree, FOLDER_ICON_CSS: '' }; }); const createDocument = (overrides: Partial): AgentDocumentItem => diff --git a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx index c1e9c07078..9bc65f4290 100644 --- a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx +++ b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx @@ -12,7 +12,7 @@ import type { ExplorerTreeHandle, ExplorerTreeNode, } from '@/features/ExplorerTree'; -import { ExplorerTree } from '@/features/ExplorerTree'; +import { ExplorerTree, FOLDER_ICON_CSS } from '@/features/ExplorerTree'; import { useChatStore } from '@/store/chat'; import DocumentExplorerToolbar from './DocumentExplorerToolbar'; @@ -99,6 +99,10 @@ const DocumentExplorerTree = memo(({ agentId, data, mutate, style }) => { })), [documents, resolveParentRowId], ); + const defaultExpandedIds = useMemo( + () => nodes.filter((node) => node.isFolder && node.parentId == null).map((node) => node.id), + [nodes], + ); const parentMap = useMemo(() => { const map = new Map(); @@ -233,14 +237,18 @@ const DocumentExplorerTree = memo(({ agentId, data, mutate, style }) => { return (
+ iconsColored canDrag={canDrag} canDrop={canDrop} canRename={canRename} - density="relaxed" + defaultExpandedIds={defaultExpandedIds} + density="compact" getContextMenuItems={getContextMenuItems} + iconSet="complete" nodes={nodes} ref={treeRef} style={{ height: '100%' }} + unsafeCSS={FOLDER_ICON_CSS} header={ handleCreateDocument(null)} diff --git a/src/features/Conversation/Messages/AssistantGroup/components/Group.test.tsx b/src/features/Conversation/Messages/AssistantGroup/components/Group.test.tsx index 7c33eb809f..1ca2074a8e 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/Group.test.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/Group.test.tsx @@ -44,6 +44,7 @@ vi.mock('./WorkflowCollapse', () => ({ contentOverride?: string; disableMarkdownStreaming?: boolean; domId?: string; + error?: unknown; hasToolsOverride?: boolean; tools?: unknown[]; }>; @@ -57,6 +58,7 @@ vi.mock('./WorkflowCollapse', () => ({ contentOverride, disableMarkdownStreaming, domId, + error, hasToolsOverride, tools, }) => ({ @@ -64,6 +66,7 @@ vi.mock('./WorkflowCollapse', () => ({ contentOverride, disableMarkdownStreaming: !!disableMarkdownStreaming, domId, + hasError: !!error, hasToolsOverride, toolCount: tools?.length ?? 0, }), @@ -79,6 +82,7 @@ vi.mock('./GroupItem', () => ({ contentOverride, disableMarkdownStreaming, domId, + error, hasToolsOverride, id, isFirstBlock, @@ -88,6 +92,7 @@ vi.mock('./GroupItem', () => ({ contentOverride?: string; disableMarkdownStreaming?: boolean; domId?: string; + error?: unknown; hasToolsOverride?: boolean; id: string; isFirstBlock?: boolean; @@ -100,6 +105,7 @@ vi.mock('./GroupItem', () => ({ contentOverride, disableMarkdownStreaming: !!disableMarkdownStreaming, domId, + hasError: !!error, hasToolsOverride, id, isFirstBlock: !!isFirstBlock, @@ -159,6 +165,7 @@ describe('Group', () => { contentOverride: longContent, disableMarkdownStreaming: true, domId: 'block-1__answer', + hasError: false, hasToolsOverride: false, id: 'block-1', isFirstBlock: false, @@ -169,6 +176,7 @@ describe('Group', () => { contentOverride: '', disableMarkdownStreaming: true, domId: 'block-1__workflow', + hasError: false, hasToolsOverride: true, id: 'block-1', isFirstBlock: false, @@ -198,6 +206,7 @@ describe('Group', () => { content: '现在我来搜索资料。', disableMarkdownStreaming: true, domId: undefined, + hasError: false, id: 'block-1', isFirstBlock: false, toolCount: 1, @@ -235,6 +244,7 @@ describe('Group', () => { contentOverride: '我先帮你查一下。', disableMarkdownStreaming: true, domId: 'block-1__answer', + hasError: false, hasToolsOverride: false, id: 'block-1', isFirstBlock: false, @@ -246,6 +256,7 @@ describe('Group', () => { contentOverride: '接下来我会继续整理结果。', disableMarkdownStreaming: true, domId: 'block-1__workflow', + hasError: false, hasToolsOverride: true, toolCount: 1, }, @@ -253,11 +264,64 @@ describe('Group', () => { content: '', disableMarkdownStreaming: false, domId: undefined, + hasError: false, toolCount: 1, }, ]); }); + it('keeps assistant runtime errors outside the workflow collapse', () => { + const { container } = render( + , + ); + + const sequence = Array.from(container.querySelectorAll('[data-testid]')).map((node) => + node.getAttribute('data-testid'), + ); + + expect(sequence).toEqual(['workflow-segment', 'answer-segment']); + expect(parseWorkflowSegment()).toEqual([ + { + content: '', + contentOverride: '', + disableMarkdownStreaming: true, + domId: 'block-1__workflow', + hasError: false, + hasToolsOverride: true, + toolCount: 2, + }, + ]); + expect(parseAnswerSegment()).toEqual({ + content: '', + contentOverride: '', + disableMarkdownStreaming: true, + domId: 'block-1__answer', + hasError: true, + hasToolsOverride: false, + id: 'block-1', + isFirstBlock: false, + toolCount: 0, + }); + }); + it('renders a single tool call inline instead of folding it', () => { render( { content: '', disableMarkdownStreaming: true, domId: undefined, + hasError: false, id: 'block-1', isFirstBlock: false, toolCount: 1, diff --git a/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx b/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx index 3b06fd3c87..b81021caf0 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx @@ -209,6 +209,31 @@ const appendWorkflowRangeBlock = ( block: AssistantContentBlock, allowLeadingSentencePromotion = false, ) => { + if (block.error) { + if (hasTools(block)) { + appendWorkflowBlock( + segments, + createWorkflowRenderBlock(block, { + content: '', + error: undefined, + imageList: undefined, + reasoning: undefined, + }), + ); + appendAnswerBlock( + segments, + createAnswerRenderBlock(block, { + reasoning: undefined, + tools: undefined, + }), + ); + return; + } + + appendAnswerBlock(segments, block); + return; + } + if (!shouldPromoteMixedBlockContent(block)) { const leadingSentenceSplit = allowLeadingSentencePromotion && segments.length === 0 && hasTools(block) diff --git a/src/features/ExplorerTree/folderIconStyle.ts b/src/features/ExplorerTree/folderIconStyle.ts new file mode 100644 index 0000000000..6dc37e17cf --- /dev/null +++ b/src/features/ExplorerTree/folderIconStyle.ts @@ -0,0 +1,26 @@ +const folderClosedSvg = ``; +const folderOpenSvg = ``; + +// PierreFileTree only renders a chevron in [data-item-section="icon"] for +// directories; inject the visible folder glyph into the content cell. +export const FOLDER_ICON_CSS = ` + [data-item-type="folder"] [data-item-section="content"] { + display: flex; + align-items: center; + } + [data-item-type="folder"] [data-item-section="content"]::before { + content: ''; + flex: 0 0 auto; + width: 14px; + height: 14px; + margin-inline-end: 6px; + background-color: currentColor; + -webkit-mask: url("data:image/svg+xml;utf8,${folderClosedSvg}") no-repeat center / contain; + mask: url("data:image/svg+xml;utf8,${folderClosedSvg}") no-repeat center / contain; + opacity: 0.85; + } + [data-item-type="folder"][aria-expanded="true"] [data-item-section="content"]::before { + -webkit-mask-image: url("data:image/svg+xml;utf8,${folderOpenSvg}"); + mask-image: url("data:image/svg+xml;utf8,${folderOpenSvg}"); + } +`; diff --git a/src/features/ExplorerTree/index.tsx b/src/features/ExplorerTree/index.tsx index 5ebda21b31..d230415c7c 100644 --- a/src/features/ExplorerTree/index.tsx +++ b/src/features/ExplorerTree/index.tsx @@ -1,3 +1,4 @@ +export { FOLDER_ICON_CSS } from './folderIconStyle'; export type { ExplorerTreeCanDropCtx, ExplorerTreeHandle, diff --git a/src/features/ExplorerTree/types.ts b/src/features/ExplorerTree/types.ts index 0647989460..85b628ccb7 100644 --- a/src/features/ExplorerTree/types.ts +++ b/src/features/ExplorerTree/types.ts @@ -1,4 +1,4 @@ -import type { FileTreeRowDecoration } from '@pierre/trees'; +import type { FileTreeRowDecoration, GitStatusEntry } from '@pierre/trees'; import type { MenuProps } from 'antd'; import type { CSSProperties, DragEvent, MouseEvent, ReactNode } from 'react'; @@ -59,6 +59,7 @@ export interface ExplorerTreeProps { getRowDecoration?: ( ctx: ExplorerTreeRowDecorationCtx, ) => FileTreeRowDecoration | null | undefined; + gitStatus?: readonly GitStatusEntry[]; header?: ReactNode; iconsColored?: boolean; iconSet?: 'minimal' | 'standard' | 'complete' | 'none'; @@ -79,4 +80,6 @@ export interface ExplorerTreeProps { overscan?: number; selectedIds?: string[]; style?: CSSProperties; + /** Raw CSS injected into the pierre/trees shadow DOM via FILE_TREE_UNSAFE_CSS_ATTRIBUTE. */ + unsafeCSS?: string; } diff --git a/src/features/ExplorerTree/view/ExplorerTree.tsx b/src/features/ExplorerTree/view/ExplorerTree.tsx index da7cfa64c6..5d7f3d46aa 100644 --- a/src/features/ExplorerTree/view/ExplorerTree.tsx +++ b/src/features/ExplorerTree/view/ExplorerTree.tsx @@ -160,6 +160,7 @@ function ExplorerTreeInner( colored: props.iconsColored ?? true, set: props.iconSet ?? 'standard', }, + gitStatus: props.gitStatus, initialExpandedPaths, initialSelectedPaths, itemHeight: props.itemHeight, @@ -214,6 +215,7 @@ function ExplorerTreeInner( if (!node) return null; return (fn({ node }) as FileTreeRowDecoration | null) ?? null; }, + unsafeCSS: props.unsafeCSS, }; // we build options ONCE; callbacks read propsRef to stay fresh // eslint-disable-next-line react-hooks/exhaustive-deps @@ -225,6 +227,10 @@ function ExplorerTreeInner( // Observe selection changes so external consumers see updates without needing to pass a selection listener. useFileTreeSelection(model); + useLayoutEffect(() => { + model.setGitStatus(props.gitStatus); + }, [model, props.gitStatus]); + // Track expansion by subscribing to mutation events (expansion isn't a mutation — use subscribe). // We read expanded paths on demand from the visible rows via getItem; emit when defaultExpanded or nodes changes. useLayoutEffect(() => { diff --git a/src/features/OpenInAppButton/apps.ts b/src/features/OpenInAppButton/apps.ts new file mode 100644 index 0000000000..a96fcf34fe --- /dev/null +++ b/src/features/OpenInAppButton/apps.ts @@ -0,0 +1,65 @@ +import type { OpenInAppId } from '@lobechat/electron-client-ipc'; +import { Cursor } from '@lobehub/icons'; +import { + AppleIcon, + CodeIcon, + CodeXmlIcon, + FolderIcon, + FolderOpenIcon, + GhostIcon, + HammerIcon, + SquareTerminalIcon, + TerminalIcon, +} from 'lucide-react'; +import type { FC } from 'react'; + +// Renderer-side mapping from AppId → icon component. The displayName comes from +// the main-process detectApps result (the source of truth), so we only map icons here. +// `FC` is the widest shape accepted by `@lobehub/ui`'s `Icon` component and +// covers both lucide-react icons and `@lobehub/icons` brand icons. +type IconLike = FC; + +export const APP_ICONS: Record = { + cursor: Cursor as IconLike, + explorer: FolderIcon, + files: FolderOpenIcon, + finder: FolderIcon, + ghostty: GhostIcon, + iterm2: SquareTerminalIcon, + terminal: TerminalIcon, + vscode: CodeIcon, + webstorm: HammerIcon, + xcode: AppleIcon, + zed: CodeXmlIcon, +}; + +// Platform fallback when no user pref or user's pref is uninstalled. +export const PLATFORM_DEFAULT_APP: Record = { + aix: 'files', + android: 'files', + cygwin: 'files', + darwin: 'finder', + freebsd: 'files', + haiku: 'files', + linux: 'files', + netbsd: 'files', + openbsd: 'files', + sunos: 'files', + win32: 'explorer', +}; + +export const resolveDefaultApp = ( + userDefault: string | null | undefined, + installedIds: ReadonlySet, + platform: NodeJS.Platform, +): OpenInAppId => { + if (userDefault && installedIds.has(userDefault)) return userDefault as OpenInAppId; + + const fallback = PLATFORM_DEFAULT_APP[platform] ?? 'finder'; + if (installedIds.has(fallback)) return fallback; + + // Last resort: first installed app, else the platform fallback (the main-process + // not-installed guard will surface a localized error toast if invoked). + const first = [...installedIds][0] as OpenInAppId | undefined; + return first ?? fallback; +}; diff --git a/src/features/OpenInAppButton/index.test.tsx b/src/features/OpenInAppButton/index.test.tsx new file mode 100644 index 0000000000..da570035dd --- /dev/null +++ b/src/features/OpenInAppButton/index.test.tsx @@ -0,0 +1,230 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import OpenInAppButton from './index'; + +const launchMock = vi.fn(); +let hookReturn: { + defaultApp: string; + installedApps: { displayName: string; icon?: string; id: string; installed: boolean }[]; + launch: typeof launchMock; + ready: boolean; +}; +let isDesktopValue = true; + +vi.mock('@lobechat/const', () => ({ + get isDesktop() { + return isDesktopValue; + }, +})); + +vi.mock('./useOpenInApp', () => ({ + useOpenInApp: () => hookReturn, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts ? `${key}::${JSON.stringify(opts)}` : key, + }), +})); + +vi.mock('@lobehub/ui', () => ({ + DropdownMenu: ({ + children, + items, + }: { + children: ReactNode; + items: { icon?: ReactNode; key: string; label: ReactNode; onClick?: () => void }[]; + }) => ( +
+
{children}
+
    + {items.map((item) => ( +
  • item.onClick?.()} + > + {item.icon} + {item.label} +
  • + ))} +
+
+ ), + Icon: ({ icon: IconComp, size }: { icon: unknown; size?: number }) => ( + + {typeof IconComp === 'function' + ? ((IconComp as { displayName?: string; name?: string }).displayName ?? + (IconComp as { displayName?: string; name?: string }).name ?? + 'icon') + : 'icon'} + + ), + Tooltip: ({ children, title }: { children: ReactNode; title?: ReactNode }) => ( +
+ {children} +
+ ), +})); + +vi.mock('antd-style', () => ({ + createStaticStyles: () => ({ + dropdownItem: 'dropdownItem', + leftButton: 'leftButton', + rightButton: 'rightButton', + root: 'root', + }), + cssVar: new Proxy({}, { get: () => 'var(--placeholder)' }), +})); + +vi.mock('lucide-react', () => { + const Stub = () => null; + return { + AppleIcon: Stub, + ChevronDownIcon: Stub, + CodeIcon: Stub, + CodeXmlIcon: Stub, + FolderIcon: Stub, + FolderOpenIcon: Stub, + GhostIcon: Stub, + HammerIcon: Stub, + SquareTerminalIcon: Stub, + TerminalIcon: Stub, + }; +}); + +vi.mock('@lobehub/icons', () => ({ + Cursor: function Cursor() { + return null; + }, +})); + +beforeEach(() => { + isDesktopValue = true; + launchMock.mockReset(); + hookReturn = { + defaultApp: 'vscode', + installedApps: [ + { displayName: 'VS Code', id: 'vscode', installed: true }, + { displayName: 'Finder', id: 'finder', installed: true }, + ], + launch: launchMock, + ready: true, + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('', () => { + it('returns null on web build (isDesktop=false)', () => { + isDesktopValue = false; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('returns null when workingDirectory is empty', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('returns null while detection is pending', () => { + hookReturn = { ...hookReturn, ready: false }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the split button with the default app icon when ready', () => { + render(); + + expect(screen.getByTestId('tooltip')).toBeInTheDocument(); + expect(screen.getByTestId('dropdown-root')).toBeInTheDocument(); + }); + + it('calls launch(defaultApp) when the left half is clicked', () => { + render(); + + const leftButton = screen.getByLabelText(/tooltip/); + fireEvent.click(leftButton); + + expect(launchMock).toHaveBeenCalledWith('vscode'); + }); + + it('lists only installed apps in the dropdown', () => { + render(); + + const items = screen.getAllByTestId(/dropdown-item-/); + expect(items).toHaveLength(2); + expect(items.map((el) => el.getAttribute('data-item-id'))).toEqual(['vscode', 'finder']); + }); + + it('invokes launch(appId) when a dropdown item is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('dropdown-item-finder')); + + expect(launchMock).toHaveBeenCalledWith('finder'); + }); + + it('renders extracted base64 icon as an for the default app', () => { + hookReturn = { + ...hookReturn, + installedApps: [ + { + displayName: 'VS Code', + icon: 'data:image/png;base64,VSCODE', + id: 'vscode', + installed: true, + }, + { displayName: 'Finder', id: 'finder', installed: true }, + ], + }; + + render(); + + const leftButton = screen.getByLabelText(/tooltip/); + const img = leftButton.querySelector('img'); + expect(img).not.toBeNull(); + expect(img?.getAttribute('src')).toBe('data:image/png;base64,VSCODE'); + }); + + it('falls back to the lucide icon when no base64 icon is available', () => { + // default hookReturn has no icon fields + render(); + + const leftButton = screen.getByLabelText(/tooltip/); + expect(leftButton.querySelector('img')).toBeNull(); + expect(leftButton.querySelector('[data-testid="ui-icon"]')).not.toBeNull(); + }); + + it('renders base64 icon for dropdown items when available', () => { + hookReturn = { + ...hookReturn, + installedApps: [ + { + displayName: 'VS Code', + icon: 'data:image/png;base64,VSCODE', + id: 'vscode', + installed: true, + }, + { displayName: 'Finder', id: 'finder', installed: true }, + ], + }; + + render(); + + const vscodeItem = screen.getByTestId('dropdown-item-vscode'); + const finderItem = screen.getByTestId('dropdown-item-finder'); + + expect(vscodeItem.querySelector('img')?.getAttribute('src')).toBe( + 'data:image/png;base64,VSCODE', + ); + expect(finderItem.querySelector('img')).toBeNull(); + expect(finderItem.querySelector('[data-testid="ui-icon"]')).not.toBeNull(); + }); +}); diff --git a/src/features/OpenInAppButton/index.tsx b/src/features/OpenInAppButton/index.tsx new file mode 100644 index 0000000000..079a3a773f --- /dev/null +++ b/src/features/OpenInAppButton/index.tsx @@ -0,0 +1,148 @@ +import { isDesktop } from '@lobechat/const'; +import type { OpenInAppId } from '@lobechat/electron-client-ipc'; +import { DropdownMenu, type DropdownMenuProps, Icon, Tooltip } from '@lobehub/ui'; +import { createStaticStyles, cssVar } from 'antd-style'; +import { ChevronDownIcon } from 'lucide-react'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { APP_ICONS } from './apps'; +import { useOpenInApp } from './useOpenInApp'; + +interface AppIconProps { + icon?: string; + id: OpenInAppId; + size?: number; +} + +const AppIcon = ({ id, icon, size = 16 }: AppIconProps) => { + if (icon) { + return ( + + ); + } + const Fallback = APP_ICONS[id]; + return ; +}; + +const styles = createStaticStyles(({ css }) => ({ + dropdownItem: css` + display: flex; + gap: 8px; + align-items: center; + `, + leftButton: css` + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + padding-inline: 8px; + + color: ${cssVar.colorTextSecondary}; + + transition: all 0.2s; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillSecondary}; + } + `, + rightButton: css` + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + padding-inline: 4px; + border-inline-start: 1px solid ${cssVar.colorBorderSecondary}; + + color: ${cssVar.colorTextSecondary}; + + transition: all 0.2s; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillSecondary}; + } + `, + root: css` + overflow: hidden; + display: inline-flex; + align-items: stretch; + + height: 24px; + border: 1px solid ${cssVar.colorBorderSecondary}; + border-radius: 6px; + `, +})); + +export interface OpenInAppButtonProps { + className?: string; + workingDirectory: string; +} + +const OpenInAppButton = memo(({ workingDirectory, className }) => { + const { t } = useTranslation('openInApp'); + const { defaultApp, installedApps, launch, ready } = useOpenInApp(workingDirectory); + + const defaultAppEntry = useMemo( + () => installedApps.find((app) => app.id === defaultApp), + [installedApps, defaultApp], + ); + + const defaultDisplayName = defaultAppEntry?.displayName ?? defaultApp; + const defaultIconSrc = defaultAppEntry?.icon; + + const dropdownItems = useMemo( + () => + installedApps.map((app) => ({ + icon: , + key: app.id, + label: app.displayName, + onClick: () => { + void launch(app.id); + }, + })), + [installedApps, launch], + ); + + if (!isDesktop || !workingDirectory) return null; + if (!ready) return null; + + const wrapperClassName = [styles.root, className].filter(Boolean).join(' '); + + return ( +
+ +
{ + void launch(defaultApp); + }} + > + +
+
+ +
+ +
+
+
+ ); +}); + +OpenInAppButton.displayName = 'OpenInAppButton'; + +export default OpenInAppButton; diff --git a/src/features/OpenInAppButton/useOpenInApp.test.tsx b/src/features/OpenInAppButton/useOpenInApp.test.tsx new file mode 100644 index 0000000000..9950e97fab --- /dev/null +++ b/src/features/OpenInAppButton/useOpenInApp.test.tsx @@ -0,0 +1,241 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { SWRConfig } from 'swr'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveDefaultApp } from './apps'; + +const wrapper = ({ children }: { children: ReactNode }) => ( + new Map() }}>{children} +); + +vi.mock('@lobechat/const', () => ({ + isDesktop: true, +})); + +vi.mock('@/services/electron/openInApp', () => ({ + electronOpenInAppService: { + detectApps: vi.fn(), + openInApp: vi.fn(), + }, +})); + +vi.mock('@/components/AntdStaticMethods', () => ({ + message: { + error: vi.fn(), + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts ? `${key}::${JSON.stringify(opts)}` : key, + }), +})); + +const updatePreferenceMock = vi.fn(); +let mockUserDefault: string | undefined; + +vi.mock('@/store/user', () => ({ + useUserStore: (selector: (state: unknown) => unknown) => { + const state = { + preference: { defaultOpenInApp: mockUserDefault }, + updatePreference: updatePreferenceMock, + }; + return selector(state); + }, +})); + +vi.mock('@/store/user/selectors', () => ({ + preferenceSelectors: { + defaultOpenInApp: (s: { preference: { defaultOpenInApp?: string } }) => + s.preference.defaultOpenInApp, + }, +})); + +// Use a fresh SWR cache per test via a wrapper to avoid cross-test pollution. +beforeEach(() => { + vi.clearAllMocks(); + mockUserDefault = undefined; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('resolveDefaultApp', () => { + it('returns user preference when installed', () => { + const installed = new Set(['vscode', 'finder']); + expect(resolveDefaultApp('vscode', installed, 'darwin')).toBe('vscode'); + }); + + it('falls back to platform default when user preference is not installed', () => { + const installed = new Set(['finder']); + expect(resolveDefaultApp('vscode', installed, 'darwin')).toBe('finder'); + }); + + it('returns platform default when user preference is null', () => { + const installed = new Set(['finder', 'vscode']); + expect(resolveDefaultApp(null, installed, 'darwin')).toBe('finder'); + }); + + it('returns first installed when platform default is not installed', () => { + const installed = new Set(['vscode', 'cursor']); + expect(resolveDefaultApp(undefined, installed, 'darwin')).toBe('vscode'); + }); + + it('falls back to platform fallback id when no apps are installed', () => { + const installed = new Set(); + expect(resolveDefaultApp(undefined, installed, 'darwin')).toBe('finder'); + expect(resolveDefaultApp(undefined, installed, 'win32')).toBe('explorer'); + expect(resolveDefaultApp(undefined, installed, 'linux')).toBe('files'); + }); +}); + +describe('useOpenInApp', () => { + const importModules = async () => { + const hookMod = await import('./useOpenInApp'); + const svc = await import('@/services/electron/openInApp'); + const msg = await import('@/components/AntdStaticMethods'); + return { + message: msg.message, + service: svc.electronOpenInAppService, + useOpenInApp: hookMod.useOpenInApp, + }; + }; + + it('returns ready=false and empty installedApps while detection pending', async () => { + const { service, useOpenInApp } = await importModules(); + // Never-resolving promise to simulate "in flight". + (service.detectApps as ReturnType).mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useOpenInApp('/tmp/proj'), { wrapper }); + + expect(result.current.ready).toBe(false); + expect(result.current.installedApps).toEqual([]); + }); + + it('filters out apps with installed=false', async () => { + const { service, useOpenInApp } = await importModules(); + (service.detectApps as ReturnType).mockResolvedValue({ + apps: [ + { displayName: 'Finder', id: 'finder', installed: true }, + { displayName: 'VS Code', id: 'vscode', installed: true }, + { displayName: 'Cursor', id: 'cursor', installed: false }, + ], + }); + + const { result } = renderHook(() => useOpenInApp('/tmp/proj'), { wrapper }); + + await waitFor(() => expect(result.current.ready).toBe(true)); + expect(result.current.installedApps.map((a) => a.id)).toEqual(['finder', 'vscode']); + }); + + it('persists user preference when launching a non-default app succeeds', async () => { + mockUserDefault = 'finder'; + const { service, useOpenInApp } = await importModules(); + (service.detectApps as ReturnType).mockResolvedValue({ + apps: [ + { displayName: 'Finder', id: 'finder', installed: true }, + { displayName: 'VS Code', id: 'vscode', installed: true }, + ], + }); + (service.openInApp as ReturnType).mockResolvedValue({ success: true }); + + const { result } = renderHook(() => useOpenInApp('/tmp/proj'), { wrapper }); + await waitFor(() => expect(result.current.ready).toBe(true)); + + await act(async () => { + await result.current.launch('vscode'); + }); + + expect(service.openInApp).toHaveBeenCalledWith({ + appId: 'vscode', + path: '/tmp/proj', + }); + expect(updatePreferenceMock).toHaveBeenCalledWith({ defaultOpenInApp: 'vscode' }); + }); + + it('does not update preference when launching the current default succeeds', async () => { + mockUserDefault = 'vscode'; + const { service, useOpenInApp } = await importModules(); + (service.detectApps as ReturnType).mockResolvedValue({ + apps: [{ displayName: 'VS Code', id: 'vscode', installed: true }], + }); + (service.openInApp as ReturnType).mockResolvedValue({ success: true }); + + const { result } = renderHook(() => useOpenInApp('/tmp/proj'), { wrapper }); + await waitFor(() => expect(result.current.ready).toBe(true)); + + await act(async () => { + await result.current.launch('vscode'); + }); + + expect(updatePreferenceMock).not.toHaveBeenCalled(); + }); + + it('surfaces a pathNotFound toast when main reports Path not found', async () => { + const { message, service, useOpenInApp } = await importModules(); + (service.detectApps as ReturnType).mockResolvedValue({ + apps: [{ displayName: 'VS Code', id: 'vscode', installed: true }], + }); + (service.openInApp as ReturnType).mockResolvedValue({ + error: 'Path not found: /tmp/proj', + success: false, + }); + + const { result } = renderHook(() => useOpenInApp('/tmp/proj'), { wrapper }); + await waitFor(() => expect(result.current.ready).toBe(true)); + + await act(async () => { + await result.current.launch('vscode'); + }); + + expect(message.error).toHaveBeenCalledWith(expect.stringContaining('errors.pathNotFound')); + expect(message.error).toHaveBeenCalledWith(expect.stringContaining('/tmp/proj')); + }); + + it('surfaces an appNotInstalled toast when main reports X is not installed', async () => { + const { message, service, useOpenInApp } = await importModules(); + (service.detectApps as ReturnType).mockResolvedValue({ + apps: [{ displayName: 'VS Code', id: 'vscode', installed: true }], + }); + // Match the actual main-process controller contract: `${appId} is not installed` + // (see apps/desktop/src/main/controllers/OpenInAppCtr.ts). + (service.openInApp as ReturnType).mockResolvedValue({ + error: 'vscode is not installed', + success: false, + }); + + const { result } = renderHook(() => useOpenInApp('/tmp/proj'), { wrapper }); + await waitFor(() => expect(result.current.ready).toBe(true)); + + await act(async () => { + await result.current.launch('vscode'); + }); + + expect(message.error).toHaveBeenCalledWith(expect.stringContaining('errors.appNotInstalled')); + expect(message.error).toHaveBeenCalledWith(expect.stringContaining('VS Code')); + }); + + it('surfaces a generic launchFailed toast for unknown errors', async () => { + const { message, service, useOpenInApp } = await importModules(); + (service.detectApps as ReturnType).mockResolvedValue({ + apps: [{ displayName: 'VS Code', id: 'vscode', installed: true }], + }); + (service.openInApp as ReturnType).mockResolvedValue({ + error: 'spawn ENOENT', + success: false, + }); + + const { result } = renderHook(() => useOpenInApp('/tmp/proj'), { wrapper }); + await waitFor(() => expect(result.current.ready).toBe(true)); + + await act(async () => { + await result.current.launch('vscode'); + }); + + expect(message.error).toHaveBeenCalledWith(expect.stringContaining('errors.launchFailed')); + expect(message.error).toHaveBeenCalledWith(expect.stringContaining('spawn ENOENT')); + }); +}); diff --git a/src/features/OpenInAppButton/useOpenInApp.ts b/src/features/OpenInAppButton/useOpenInApp.ts new file mode 100644 index 0000000000..985e15a19b --- /dev/null +++ b/src/features/OpenInAppButton/useOpenInApp.ts @@ -0,0 +1,74 @@ +import { isDesktop } from '@lobechat/const'; +import type { DetectedApp, OpenInAppId } from '@lobechat/electron-client-ipc'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import useSWR from 'swr'; + +import { message } from '@/components/AntdStaticMethods'; +import { electronOpenInAppService } from '@/services/electron/openInApp'; +import { useUserStore } from '@/store/user'; +import { preferenceSelectors } from '@/store/user/selectors'; + +import { resolveDefaultApp } from './apps'; + +export interface UseOpenInAppResult { + defaultApp: OpenInAppId; + installedApps: DetectedApp[]; + launch: (appId: OpenInAppId) => Promise; + ready: boolean; +} + +export const useOpenInApp = (workingDirectory: string): UseOpenInAppResult => { + const { t } = useTranslation('openInApp'); + + // SWR fetch detection once per session; main caches anyway. + const { data } = useSWR( + isDesktop ? 'open-in-app/detect' : null, + () => electronOpenInAppService.detectApps(), + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ); + + const installedApps = useMemo(() => data?.apps.filter((app) => app.installed) ?? [], [data]); + const installedIds = useMemo(() => new Set(installedApps.map((app) => app.id)), [installedApps]); + const displayNameMap = useMemo( + () => new Map(installedApps.map((app) => [app.id, app.displayName])), + [installedApps], + ); + + const userDefault = useUserStore(preferenceSelectors.defaultOpenInApp); + const updatePreference = useUserStore((s) => s.updatePreference); + + const defaultApp = useMemo( + () => resolveDefaultApp(userDefault, installedIds, window.lobeEnv?.platform ?? 'darwin'), + [userDefault, installedIds], + ); + + const launch = useCallback( + async (appId: OpenInAppId): Promise => { + const appName = displayNameMap.get(appId) ?? appId; + const result = await electronOpenInAppService.openInApp({ + appId, + path: workingDirectory, + }); + + if (result.success) { + if (appId !== userDefault) { + await updatePreference({ defaultOpenInApp: appId }); + } + return; + } + + const err = result.error ?? ''; + if (err.startsWith('Path not found')) { + message.error(t('errors.pathNotFound', { path: workingDirectory })); + } else if (err.includes('is not installed')) { + message.error(t('errors.appNotInstalled', { appName })); + } else { + message.error(t('errors.launchFailed', { appName, error: err || t('errors.unknown') })); + } + }, + [displayNameMap, workingDirectory, userDefault, updatePreference, t], + ); + + return { defaultApp, installedApps, launch, ready: !!data }; +}; diff --git a/src/features/Portal/LocalFile/Body.helpers.ts b/src/features/Portal/LocalFile/Body.helpers.ts new file mode 100644 index 0000000000..662f7b0d4c --- /dev/null +++ b/src/features/Portal/LocalFile/Body.helpers.ts @@ -0,0 +1,53 @@ +const EXT_TO_LANG: Record = { + bash: 'bash', + c: 'c', + cpp: 'cpp', + cs: 'csharp', + css: 'css', + dockerfile: 'dockerfile', + fish: 'fish', + go: 'go', + graphql: 'graphql', + html: 'html', + java: 'java', + js: 'javascript', + json: 'json', + jsx: 'jsx', + kt: 'kotlin', + lua: 'lua', + md: 'markdown', + mdx: 'mdx', + php: 'php', + prisma: 'prisma', + proto: 'protobuf', + py: 'python', + rb: 'ruby', + rs: 'rust', + scala: 'scala', + scss: 'scss', + sh: 'bash', + sql: 'sql', + swift: 'swift', + tf: 'hcl', + toml: 'toml', + ts: 'typescript', + tsx: 'tsx', + txt: 'txt', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', + zsh: 'bash', +}; + +export const extensionToLanguage = (ext: string): string => { + if (!ext) return 'txt'; + return EXT_TO_LANG[ext.toLowerCase()] ?? 'txt'; +}; + +export const getFileExtension = (filename: string): string => { + const base = filename.split('/').at(-1) ?? filename; + if (base.startsWith('.') && !base.slice(1).includes('.')) return ''; + const dotIdx = base.lastIndexOf('.'); + if (dotIdx < 0) return ''; + return base.slice(dotIdx + 1); +}; diff --git a/src/features/Portal/LocalFile/Body.tsx b/src/features/Portal/LocalFile/Body.tsx new file mode 100644 index 0000000000..5e4ee58be5 --- /dev/null +++ b/src/features/Portal/LocalFile/Body.tsx @@ -0,0 +1,206 @@ +import { buildLocalFileUrl, isDesktop } from '@lobechat/const'; +import { Center, Empty, Flexbox, Highlighter } from '@lobehub/ui'; +import { memo, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Loading from '@/components/Loading/CircleLoading'; +import { useClientDataSWR } from '@/libs/swr'; +import { useChatStore } from '@/store/chat'; +import { chatPortalSelectors } from '@/store/chat/selectors'; + +import { extensionToLanguage, getFileExtension } from './Body.helpers'; + +const MAX_PREVIEW_CHARS = 500_000; + +const TEXT_PREVIEW_MIME_TYPES = new Set([ + 'application/graphql', + 'application/javascript', + 'application/json', + 'application/markdown', + 'application/toml', + 'application/xml', + 'application/yaml', + 'text/markdown', + 'text/x-markdown', +]); + +interface BinaryLocalFilePreview { + contentType: string; + type: 'binary'; +} + +interface ImageLocalFilePreview { + blob: Blob; + contentType: string; + type: 'image'; +} + +interface TextLocalFilePreview { + content: string; + contentType: string; + type: 'text'; +} + +type LocalFilePreview = BinaryLocalFilePreview | ImageLocalFilePreview | TextLocalFilePreview; + +const normalizeContentType = (contentType: string | null): string => + contentType?.split(';')[0].trim().toLowerCase() ?? ''; + +const isTextPreviewMimeType = (mimeType: string): boolean => + mimeType.startsWith('text/') || TEXT_PREVIEW_MIME_TYPES.has(mimeType); + +const fetchLocalFilePreview = async (url: string): Promise => { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to load local file: ${response.status}`); + } + + const contentType = normalizeContentType(response.headers.get('content-type')); + + if (contentType.startsWith('image/')) { + return { blob: await response.blob(), contentType, type: 'image' }; + } + + if (isTextPreviewMimeType(contentType)) { + return { content: await response.text(), contentType, type: 'text' }; + } + + return { contentType, type: 'binary' }; +}; + +interface ImagePreviewProps { + blob: Blob; + filename: string; +} + +const ImagePreview = memo(({ blob, filename }) => { + const [imageSrc, setImageSrc] = useState(); + + useEffect(() => { + const objectUrl = URL.createObjectURL(blob); + setImageSrc(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [blob]); + + if (!imageSrc) return ; + + return ( +
+ {filename} +
+ ); +}); + +ImagePreview.displayName = 'ImagePreview'; + +// ============== ActiveFileView ============== + +interface ActiveFileViewProps { + filePath: string; + workingDirectory: string; +} + +const ActiveFileView = memo(({ filePath }) => { + const { t } = useTranslation('chat'); + + const filename = filePath.split('/').at(-1) ?? ''; + const localFileUrl = isDesktop ? buildLocalFileUrl(filePath) : null; + const { + data: preview, + error, + isLoading, + } = useClientDataSWR( + localFileUrl ? ['local-file-preview', localFileUrl] : null, + async () => { + if (!localFileUrl) throw new Error('Missing local file URL'); + return fetchLocalFilePreview(localFileUrl); + }, + { revalidateOnFocus: false }, + ); + + // Chromium blocks `file://` from a non-file origin. The desktop main process + // exposes local disk files through `localfile://`; the renderer fetches that + // URL for every file type and keeps rendering inside our own components. + if (!localFileUrl) { + return ( +
+ +
+ ); + } + + if (isLoading) return ; + + if (error || !preview) { + return ( +
+ +
+ ); + } + + if (preview.type === 'binary') { + return ( +
+ +
+ ); + } + + if (preview.type === 'image') { + return ; + } + + const ext = getFileExtension(filename); + const truncated = preview.content.length > MAX_PREVIEW_CHARS; + const displayContent = truncated ? preview.content.slice(0, MAX_PREVIEW_CHARS) : preview.content; + + return ( + + {truncated && ( +
+ + {t('workingPanel.localFile.truncated', { limit: MAX_PREVIEW_CHARS.toLocaleString() })} + +
+ )} + + + {displayContent} + + +
+ ); +}); + +ActiveFileView.displayName = 'ActiveFileView'; + +// ============== Body ============== + +const Body = memo(() => { + const openLocalFiles = useChatStore(chatPortalSelectors.openLocalFiles); + const activeFile = useChatStore(chatPortalSelectors.currentLocalFile); + + if (openLocalFiles.length === 0) return null; + if (!activeFile) return null; + + return ( + + + + ); +}); + +Body.displayName = 'LocalFileBody'; + +export default Body; diff --git a/src/features/Portal/LocalFile/Header.tsx b/src/features/Portal/LocalFile/Header.tsx new file mode 100644 index 0000000000..c471a76a83 --- /dev/null +++ b/src/features/Portal/LocalFile/Header.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@lobechat/const'; +import { ActionIcon } from '@lobehub/ui'; +import { ArrowLeft, X } from 'lucide-react'; +import { Fragment, memo } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; + +import { SESSION_CHAT_TOPIC_PAGE_URL, SESSION_CHAT_TOPIC_URL } from '@/const/url'; +import NavHeader from '@/features/NavHeader'; +import { useChatStore } from '@/store/chat'; +import { chatPortalSelectors } from '@/store/chat/selectors'; + +import TabStrip from './TabStrip'; + +const Header = memo(() => { + const location = useLocation(); + const navigate = useNavigate(); + const params = useParams<{ aid?: string; topicId?: string }>(); + const [canGoBack, goBack, clearPortalStack] = useChatStore((s) => [ + chatPortalSelectors.canGoBack(s), + s.goBack, + s.clearPortalStack, + ]); + const isTopicPageRoute = + !!params.aid && + !!params.topicId && + location.pathname.startsWith(SESSION_CHAT_TOPIC_PAGE_URL(params.aid, params.topicId)); + + return ( + + {canGoBack && ( + + )} + + + } + right={ + + { + if (params.aid && params.topicId && isTopicPageRoute) { + navigate(SESSION_CHAT_TOPIC_URL(params.aid, params.topicId)); + return; + } + + clearPortalStack(); + }} + /> + + } + styles={{ + left: { + flex: 1, + minWidth: 0, + }, + }} + /> + ); +}); + +Header.displayName = 'LocalFileHeader'; + +export default Header; diff --git a/src/features/Portal/LocalFile/TabStrip.tsx b/src/features/Portal/LocalFile/TabStrip.tsx new file mode 100644 index 0000000000..02a6732780 --- /dev/null +++ b/src/features/Portal/LocalFile/TabStrip.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { ContextMenuTrigger, type GenericItemType } from '@lobehub/ui'; +import { ScrollArea } from '@lobehub/ui/base-ui'; +import { createStaticStyles } from 'antd-style'; +import { XIcon } from 'lucide-react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useChatStore } from '@/store/chat'; +import { chatPortalSelectors } from '@/store/chat/selectors'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + tabClose: css` + cursor: pointer; + + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 3px; + + color: inherit; + + opacity: 0.6; + background: transparent; + + &:hover { + opacity: 1; + background: ${cssVar.colorFillSecondary}; + } + `, + tabItem: css` + cursor: pointer; + user-select: none; + + display: flex; + flex-shrink: 0; + gap: 4px; + align-items: center; + + max-width: 160px; + padding-block: 4px; + padding-inline: 8px; + border-radius: 6px; + + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + + transition: + color 0.15s, + background 0.15s; + + &:hover { + color: ${cssVar.colorText}; + } + `, + tabItemActive: css` + color: ${cssVar.colorText}; + background: ${cssVar.colorFillTertiary}; + `, + tabLabel: css` + overflow: hidden; + flex: 1; + + min-width: 0; + + text-overflow: ellipsis; + white-space: nowrap; + `, +})); + +const SCROLL_AREA_STYLE = { + background: 'transparent', + borderRadius: 0, + flex: 1, + minWidth: 0, +}; + +const SCROLL_AREA_CONTENT_STYLE = { + alignItems: 'center', + display: 'flex', + flexDirection: 'row' as const, + gap: 4, + paddingBlock: 8, + paddingInlineStart: 8, + width: 'max-content', +}; + +const SCROLL_AREA_SCROLLBAR_STYLE = { + margin: 0, +}; + +const TabStrip = memo(() => { + const { t } = useTranslation('chat'); + const openLocalFiles = useChatStore(chatPortalSelectors.openLocalFiles); + const activeLocalFilePath = useChatStore(chatPortalSelectors.activeLocalFilePath); + const setActiveLocalFile = useChatStore((s) => s.setActiveLocalFile); + const closeLocalFileTab = useChatStore((s) => s.closeLocalFileTab); + const closeLeftLocalFileTabs = useChatStore((s) => s.closeLeftLocalFileTabs); + const closeOtherLocalFileTabs = useChatStore((s) => s.closeOtherLocalFileTabs); + const closeRightLocalFileTabs = useChatStore((s) => s.closeRightLocalFileTabs); + + const getContextMenuItems = useCallback( + (filePath: string, index: number): GenericItemType[] => [ + { + disabled: index === 0, + key: 'closeLeft', + label: t('workingPanel.localFile.closeLeft'), + onClick: () => closeLeftLocalFileTabs(filePath), + }, + { + disabled: index === openLocalFiles.length - 1, + key: 'closeRight', + label: t('workingPanel.localFile.closeRight'), + onClick: () => closeRightLocalFileTabs(filePath), + }, + { + disabled: openLocalFiles.length <= 1, + key: 'closeOther', + label: t('workingPanel.localFile.closeOther'), + onClick: () => closeOtherLocalFileTabs(filePath), + }, + { type: 'divider' }, + { + key: 'close', + label: t('workingPanel.localFile.close'), + onClick: () => closeLocalFileTab(filePath), + }, + ], + [ + closeLeftLocalFileTabs, + closeLocalFileTab, + closeOtherLocalFileTabs, + closeRightLocalFileTabs, + openLocalFiles.length, + t, + ], + ); + + if (openLocalFiles.length === 0) return null; + + return ( + + {openLocalFiles.map(({ filePath }, index) => { + const filename = filePath.split('/').at(-1) ?? filePath; + const isActive = filePath === activeLocalFilePath; + + return ( + getContextMenuItems(filePath, index)} key={filePath}> +
setActiveLocalFile(filePath)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setActiveLocalFile(filePath); + } + }} + > + {filename} + +
+
+ ); + })} +
+ ); +}); + +TabStrip.displayName = 'LocalFileTabStrip'; + +export default TabStrip; diff --git a/src/features/Portal/LocalFile/Title.tsx b/src/features/Portal/LocalFile/Title.tsx new file mode 100644 index 0000000000..62ca3996f7 --- /dev/null +++ b/src/features/Portal/LocalFile/Title.tsx @@ -0,0 +1,3 @@ +const Title = () => null; + +export default Title; diff --git a/src/features/Portal/LocalFile/index.ts b/src/features/Portal/LocalFile/index.ts new file mode 100644 index 0000000000..925446ac3b --- /dev/null +++ b/src/features/Portal/LocalFile/index.ts @@ -0,0 +1,10 @@ +import { type PortalImpl } from '../type'; +import Body from './Body'; +import Header from './Header'; +import Title from './Title'; + +export const LocalFile: PortalImpl = { + Body, + Header, + Title, +}; diff --git a/src/features/Portal/components/Header.tsx b/src/features/Portal/components/Header.tsx index 648b5dd1fc..150fd98535 100644 --- a/src/features/Portal/components/Header.tsx +++ b/src/features/Portal/components/Header.tsx @@ -3,7 +3,7 @@ import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@lobechat/const'; import { ActionIcon, Flexbox } from '@lobehub/ui'; import { ArrowLeft, X } from 'lucide-react'; -import { type ReactNode } from 'react'; +import { Fragment, type ReactNode } from 'react'; import { memo } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; @@ -12,7 +12,7 @@ import NavHeader from '@/features/NavHeader'; import { useChatStore } from '@/store/chat'; import { chatPortalSelectors } from '@/store/chat/selectors'; -const Header = memo<{ title: ReactNode }>(({ title }) => { +const Header = memo<{ rightExtra?: ReactNode; title: ReactNode }>(({ title, rightExtra }) => { const location = useLocation(); const navigate = useNavigate(); const params = useParams<{ aid?: string; topicId?: string }>(); @@ -39,18 +39,21 @@ const Header = memo<{ title: ReactNode }>(({ title }) => { } right={ - { - if (params.aid && params.topicId && isTopicPageRoute) { - navigate(SESSION_CHAT_TOPIC_URL(params.aid, params.topicId)); - return; - } + + {rightExtra} + { + if (params.aid && params.topicId && isTopicPageRoute) { + navigate(SESSION_CHAT_TOPIC_URL(params.aid, params.topicId)); + return; + } - clearPortalStack(); - }} - /> + clearPortalStack(); + }} + /> + } styles={{ left: { diff --git a/src/features/Portal/router.tsx b/src/features/Portal/router.tsx index d9dc96bc65..fdd99869e2 100644 --- a/src/features/Portal/router.tsx +++ b/src/features/Portal/router.tsx @@ -12,6 +12,7 @@ import { Document } from './Document'; import { FilePreview } from './FilePreview'; import { GroupThread } from './GroupThread'; import { HomeBody, HomeTitle } from './Home'; +import { LocalFile } from './LocalFile'; import { MessageDetail } from './MessageDetail'; import { Notebook } from './Notebook'; import { Plugins } from './Plugins'; @@ -28,6 +29,7 @@ const VIEW_COMPONENTS: Record = { [PortalViewType.Document]: Document, [PortalViewType.Notebook]: Notebook, [PortalViewType.FilePreview]: FilePreview, + [PortalViewType.LocalFile]: LocalFile, [PortalViewType.MessageDetail]: MessageDetail, [PortalViewType.ToolUI]: Plugins, [PortalViewType.Thread]: Thread, diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index cbb0564624..241400ca21 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -932,6 +932,23 @@ export default { 'workingPanel.resources.updatedAt': 'Updated {{time}}', 'workingPanel.resources.viewMode.list': 'List view', 'workingPanel.resources.viewMode.tree': 'Tree view', + 'workingPanel.localFile.binary': 'Binary file — preview unavailable', + 'workingPanel.localFile.close': 'Close', + 'workingPanel.localFile.closeLeft': 'Close to the Left', + 'workingPanel.localFile.closeOther': 'Close Others', + 'workingPanel.localFile.closeRight': 'Close to the Right', + 'workingPanel.localFile.error': "Couldn't load this file", + 'workingPanel.localFile.truncated': 'File preview truncated to {{limit}} characters', + 'workingPanel.files.count_one': '{{count}} file', + 'workingPanel.files.count_other': '{{count}} files', + 'workingPanel.files.copyAbsolutePath': 'Copy Path', + 'workingPanel.files.copyRelativePath': 'Copy Relative Path', + 'workingPanel.files.empty': 'No files in this workspace', + 'workingPanel.files.open': 'Open File', + 'workingPanel.files.refresh': 'Refresh', + 'workingPanel.files.showInReview': 'Show in Review', + 'workingPanel.files.showInSystem': 'Reveal in Folder', + 'workingPanel.files.title': 'Files', 'workingPanel.review.baseRef.default': 'default', 'workingPanel.review.baseRef.loading': 'Loading branches…', 'workingPanel.review.baseRef.reset': 'Reset to default branch', @@ -940,6 +957,8 @@ export default { 'workingPanel.review.collapseAll': 'Collapse all', 'workingPanel.review.copied': 'Path copied', 'workingPanel.review.copyPath': 'Copy file path', + 'workingPanel.review.revealInTree': 'Reveal in tree', + 'workingPanel.review.revealNotFound': 'File not found in project index', 'workingPanel.review.empty': 'No working tree changes', 'workingPanel.review.empty.branch': 'No changes vs {{baseRef}}', 'workingPanel.review.empty.noBaseRef': diff --git a/src/locales/default/index.ts b/src/locales/default/index.ts index c02bb2c1c6..6a96815e89 100644 --- a/src/locales/default/index.ts +++ b/src/locales/default/index.ts @@ -29,6 +29,7 @@ import models from './models'; import notification from './notification'; import oauth from './oauth'; import onboarding from './onboarding'; +import openInApp from './openInApp'; import plugin from './plugin'; import portal from './portal'; import providers from './providers'; @@ -77,6 +78,7 @@ const resources = { notification, oauth, onboarding, + openInApp, plugin, portal, providers, diff --git a/src/locales/default/openInApp.ts b/src/locales/default/openInApp.ts new file mode 100644 index 0000000000..f6fbc41554 --- /dev/null +++ b/src/locales/default/openInApp.ts @@ -0,0 +1,8 @@ +export default { + 'dropdownLabel': 'Open working directory in', + 'errors.appNotInstalled': '{{appName}} is not installed', + 'errors.launchFailed': 'Failed to open in {{appName}}: {{error}}', + 'errors.pathNotFound': 'Path not found: {{path}}', + 'errors.unknown': 'unknown error', + 'tooltip': 'Open in {{appName}}', +}; diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index acc723d73a..aff70d9015 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -259,6 +259,7 @@ const TopicItem = memo(({ id, title, fav, active, threadId, meta return ; } } + if (isHeterogeneousAgent) return null; return ( ({ })); const Header = memo(() => { + const agentId = useChatStore((s) => s.activeAgentId); + const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); + const agentWorkingDirectory = useAgentStore((s) => + agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined, + ); + const isLocalSystemEnabled = useAgentStore((s) => + agentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(agentId)(s) : false, + ); + const effectiveWorkingDirectory = topicWorkingDirectory || agentWorkingDirectory || ''; + return (
{ gap={4} style={{ backgroundColor: cssVar.colorBgContainer }} > + {isLocalSystemEnabled && ( + + )} diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/__tests__/index.test.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/__tests__/index.test.tsx new file mode 100644 index 0000000000..858c782d2d --- /dev/null +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/Files/__tests__/index.test.tsx @@ -0,0 +1,273 @@ +import { render } from '@testing-library/react'; +import type { ReactNode, Ref } from 'react'; +import { useImperativeHandle } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useGlobalStore } from '@/store/global'; +import { initialState } from '@/store/global/initialState'; + +import Files from '../index'; + +// ─── shared mutable handle spies ────────────────────────────────────────────── + +const handleSpies = { + focus: vi.fn(), + select: vi.fn(), + setExpanded: vi.fn(), +}; + +const explorerTreeProps = vi.hoisted(() => ({ + current: undefined as Record | undefined, +})); +const gitFilesMock = vi.hoisted(() => ({ + data: { + added: ['root.ts'], + deleted: ['deleted.ts'], + modified: ['src/foo/bar.ts'], + }, +})); + +// ─── mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@/features/ExplorerTree', () => { + const MockExplorerTree = ({ ref, ...props }: { ref?: Ref; [key: string]: unknown }) => { + explorerTreeProps.current = props; + useImperativeHandle(ref, () => ({ + focus: handleSpies.focus, + getSelectedIds: vi.fn(() => []), + deselect: vi.fn(), + select: handleSpies.select, + setExpanded: handleSpies.setExpanded, + startRenaming: vi.fn(), + })); + return
; + }; + MockExplorerTree.displayName = 'MockExplorerTree'; + return { + ExplorerTree: MockExplorerTree, + FOLDER_ICON_CSS: '', + }; +}); + +vi.mock('../useGitWorkingTreeFiles', () => ({ + buildGitStatusEntries: (files?: { added: string[]; deleted: string[]; modified: string[] }) => + files + ? [ + ...files.added.map((path) => ({ path, status: 'added' })), + ...files.modified.map((path) => ({ path, status: 'modified' })), + ...files.deleted.map((path) => ({ path, status: 'deleted' })), + ] + : [], + useGitWorkingTreeFiles: () => ({ data: gitFilesMock.data }), +})); + +vi.mock('../useProjectFiles', () => ({ + useProjectFiles: () => ({ + data: { + entries: [ + { isDirectory: true, name: 'src', path: '/repo/src', relativePath: 'src/' }, + { isDirectory: true, name: 'foo', path: '/repo/src/foo', relativePath: 'src/foo/' }, + { + isDirectory: false, + name: 'bar.ts', + path: '/repo/src/foo/bar.ts', + relativePath: 'src/foo/bar.ts', + }, + { + isDirectory: false, + name: 'root.ts', + path: '/repo/root.ts', + relativePath: 'root.ts', + }, + ], + indexedAt: '2026-01-01', + root: '/repo', + source: 'git', + totalCount: 2, + }, + isLoading: false, + isValidating: false, + mutate: vi.fn(), + }), +})); + +vi.mock('@/store/chat', () => ({ + useChatStore: (selector: (s: Record) => unknown) => + selector({ openLocalFile: vi.fn() }), +})); + +const messageSpy = vi.hoisted(() => ({ warning: vi.fn() })); + +vi.mock('antd', () => ({ + message: messageSpy, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@lobehub/ui', () => ({ + ActionIcon: ({ onClick }: { onClick?: () => void }) => - + {reviewAvailable && ( + + )} + {filesAvailable && ( + + )}
) : ( - {t('workingPanel.space')} + + {t('workingPanel.space')} + )} { )} + {filesAvailable && ( + + + + )} { + return this.ipc.openInApp.detectApps(); + } + + /** + * Launch the given app with `path` as its target (typically the agent + * working directory). + */ + async openInApp(params: OpenInAppParams): Promise { + return this.ipc.openInApp.openInApp(params); + } +} + +// Export a singleton instance of the service +export const electronOpenInAppService = new ElectronOpenInAppService(); diff --git a/src/store/chat/slices/portal/action.test.ts b/src/store/chat/slices/portal/action.test.ts index 9141c9c694..e246a50bfd 100644 --- a/src/store/chat/slices/portal/action.test.ts +++ b/src/store/chat/slices/portal/action.test.ts @@ -321,6 +321,312 @@ describe('chatDockSlice', () => { }); }); + describe('openLocalFile', () => { + it('should add entry to openLocalFiles, set active, and push LocalFile view', () => { + const { result } = renderHook(() => useChatStore()); + + expect(result.current.showPortal).toBe(false); + + act(() => { + result.current.openLocalFile({ + filePath: '/path/to/file.ts', + workingDirectory: '/path/to', + }); + }); + + expect(result.current.openLocalFiles).toEqual([ + { filePath: '/path/to/file.ts', workingDirectory: '/path/to' }, + ]); + expect(result.current.activeLocalFilePath).toBe('/path/to/file.ts'); + expect(result.current.portalStack).toHaveLength(1); + expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.LocalFile }); + expect(result.current.showPortal).toBe(true); + }); + + it('should not duplicate entry when opening same filePath twice', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + }); + + expect(result.current.openLocalFiles).toHaveLength(1); + expect(result.current.activeLocalFilePath).toBe('/path/a.ts'); + }); + + it('should add multiple files as separate tabs and keep portal as single entry', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + }); + + expect(result.current.openLocalFiles).toHaveLength(2); + expect(result.current.activeLocalFilePath).toBe('/path/b.ts'); + // pushPortalView replaces same type, so stack stays length 1 + expect(result.current.portalStack).toHaveLength(1); + expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.LocalFile }); + }); + }); + + describe('closeLocalFile', () => { + it('should pop LocalFile view from stack', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ + filePath: '/path/to/file.ts', + workingDirectory: '/path/to', + }); + }); + + expect(result.current.portalStack).toHaveLength(1); + + act(() => { + result.current.closeLocalFile(); + }); + + expect(result.current.portalStack).toHaveLength(0); + expect(result.current.showPortal).toBe(false); + }); + + it('should not pop when LocalFile is not the top view', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ + filePath: '/path/to/file.ts', + workingDirectory: '/path/to', + }); + }); + + act(() => { + result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' }); + }); + + expect(result.current.portalStack).toHaveLength(2); + + act(() => { + result.current.closeLocalFile(); + }); + + // Document is on top, LocalFile should not be popped + expect(result.current.portalStack).toHaveLength(2); + }); + }); + + describe('closeLocalFileTab', () => { + it('should remove the entry from openLocalFiles', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.closeLocalFileTab('/path/a.ts'); + }); + + expect(result.current.openLocalFiles).toHaveLength(1); + expect(result.current.openLocalFiles[0].filePath).toBe('/path/b.ts'); + }); + + it('should set active to right neighbor when closing active tab', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/c.ts', workingDirectory: '/path' }); + }); + + // Set active to first tab + act(() => { + result.current.setActiveLocalFile('/path/a.ts'); + }); + + act(() => { + result.current.closeLocalFileTab('/path/a.ts'); + }); + + // After removing index 0, index 0 is now b.ts + expect(result.current.activeLocalFilePath).toBe('/path/b.ts'); + }); + + it('should set active to left neighbor when closing last tab', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + }); + + // active is b.ts (last opened) + act(() => { + result.current.closeLocalFileTab('/path/b.ts'); + }); + + expect(result.current.activeLocalFilePath).toBe('/path/a.ts'); + }); + + it('should pop portal view when last tab is closed', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.closeLocalFileTab('/path/a.ts'); + }); + + expect(result.current.openLocalFiles).toHaveLength(0); + expect(result.current.showPortal).toBe(false); + }); + + it('should do nothing when filePath not in openLocalFiles', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.closeLocalFileTab('/path/nonexistent.ts'); + }); + + expect(result.current.openLocalFiles).toHaveLength(1); + }); + }); + + describe('closeLeftLocalFileTabs', () => { + it('should close tabs to the left and keep active when active remains open', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/c.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.closeLeftLocalFileTabs('/path/b.ts'); + }); + + expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual([ + '/path/b.ts', + '/path/c.ts', + ]); + expect(result.current.activeLocalFilePath).toBe('/path/c.ts'); + }); + + it('should activate target tab when closing the active tab on the left', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/c.ts', workingDirectory: '/path' }); + result.current.setActiveLocalFile('/path/a.ts'); + }); + + act(() => { + result.current.closeLeftLocalFileTabs('/path/c.ts'); + }); + + expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual(['/path/c.ts']); + expect(result.current.activeLocalFilePath).toBe('/path/c.ts'); + }); + }); + + describe('closeRightLocalFileTabs', () => { + it('should close tabs to the right and keep active when active remains open', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/c.ts', workingDirectory: '/path' }); + result.current.setActiveLocalFile('/path/a.ts'); + }); + + act(() => { + result.current.closeRightLocalFileTabs('/path/b.ts'); + }); + + expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual([ + '/path/a.ts', + '/path/b.ts', + ]); + expect(result.current.activeLocalFilePath).toBe('/path/a.ts'); + }); + + it('should activate target tab when closing the active tab on the right', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/c.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.closeRightLocalFileTabs('/path/a.ts'); + }); + + expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual(['/path/a.ts']); + expect(result.current.activeLocalFilePath).toBe('/path/a.ts'); + }); + }); + + describe('closeOtherLocalFileTabs', () => { + it('should close every tab except the target and activate it', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/c.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.closeOtherLocalFileTabs('/path/b.ts'); + }); + + expect(result.current.openLocalFiles).toEqual([ + { filePath: '/path/b.ts', workingDirectory: '/path' }, + ]); + expect(result.current.activeLocalFilePath).toBe('/path/b.ts'); + expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.LocalFile }); + }); + }); + + describe('setActiveLocalFile', () => { + it('should update activeLocalFilePath', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' }); + result.current.openLocalFile({ filePath: '/path/b.ts', workingDirectory: '/path' }); + }); + + act(() => { + result.current.setActiveLocalFile('/path/a.ts'); + }); + + expect(result.current.activeLocalFilePath).toBe('/path/a.ts'); + }); + }); + describe('openToolUI', () => { it('should push ToolUI view and open portal', () => { const { result } = renderHook(() => useChatStore()); diff --git a/src/store/chat/slices/portal/action.ts b/src/store/chat/slices/portal/action.ts index 5099d10b60..fd3938cbaa 100644 --- a/src/store/chat/slices/portal/action.ts +++ b/src/store/chat/slices/portal/action.ts @@ -50,6 +50,85 @@ export class ChatPortalActionImpl { } }; + closeLocalFile = (): void => { + const { portalStack } = this.#get(); + if (getCurrentViewType(portalStack) === PortalViewType.LocalFile) { + this.#get().popPortalView(); + } + }; + + closeLocalFileTab = (filePath: string): void => { + const { openLocalFiles, activeLocalFilePath } = this.#get(); + const idx = openLocalFiles.findIndex((f) => f.filePath === filePath); + if (idx === -1) return; + + const nextFiles = openLocalFiles.filter((_, i) => i !== idx); + + let nextActive: string | undefined; + if (activeLocalFilePath === filePath) { + const neighbor = nextFiles[idx] ?? nextFiles[idx - 1]; + nextActive = neighbor?.filePath; + } else { + nextActive = activeLocalFilePath; + } + + this.#set( + { activeLocalFilePath: nextActive, openLocalFiles: nextFiles }, + false, + 'closeLocalFileTab', + ); + + if (nextFiles.length === 0) { + this.#get().closeLocalFile(); + } + }; + + closeLeftLocalFileTabs = (filePath: string): void => { + const { openLocalFiles, activeLocalFilePath } = this.#get(); + const idx = openLocalFiles.findIndex((f) => f.filePath === filePath); + if (idx <= 0) return; + + const nextFiles = openLocalFiles.slice(idx); + const nextActive = nextFiles.some((f) => f.filePath === activeLocalFilePath) + ? activeLocalFilePath + : filePath; + + this.#set( + { activeLocalFilePath: nextActive, openLocalFiles: nextFiles }, + false, + 'closeLeftLocalFileTabs', + ); + }; + + closeOtherLocalFileTabs = (filePath: string): void => { + const { openLocalFiles } = this.#get(); + const target = openLocalFiles.find((f) => f.filePath === filePath); + if (!target) return; + + this.#set( + { activeLocalFilePath: filePath, openLocalFiles: [target] }, + false, + 'closeOtherLocalFileTabs', + ); + }; + + closeRightLocalFileTabs = (filePath: string): void => { + const { openLocalFiles, activeLocalFilePath } = this.#get(); + const idx = openLocalFiles.findIndex((f) => f.filePath === filePath); + if (idx < 0 || idx >= openLocalFiles.length - 1) return; + + const nextFiles = openLocalFiles.slice(0, idx + 1); + const nextActive = nextFiles.some((f) => f.filePath === activeLocalFilePath) + ? activeLocalFilePath + : filePath; + + this.#set( + { activeLocalFilePath: nextActive, openLocalFiles: nextFiles }, + false, + 'closeRightLocalFileTabs', + ); + }; + closeMessageDetail = (): void => { const { portalStack } = this.#get(); if (getCurrentViewType(portalStack) === PortalViewType.MessageDetail) { @@ -98,6 +177,24 @@ export class ChatPortalActionImpl { this.#get().pushPortalView({ file, type: PortalViewType.FilePreview }); }; + openLocalFile = ({ + filePath, + workingDirectory, + }: { + filePath: string; + workingDirectory: string; + }): void => { + const { openLocalFiles } = this.#get(); + const exists = openLocalFiles.some((f) => f.filePath === filePath); + const nextFiles = exists ? openLocalFiles : [...openLocalFiles, { filePath, workingDirectory }]; + this.#set({ activeLocalFilePath: filePath, openLocalFiles: nextFiles }, false, 'openLocalFile'); + this.#get().pushPortalView({ type: PortalViewType.LocalFile }); + }; + + setActiveLocalFile = (filePath: string): void => { + this.#set({ activeLocalFilePath: filePath }, false, 'setActiveLocalFile'); + }; + openMessageDetail = (messageId: string): void => { this.#get().pushPortalView({ messageId, type: PortalViewType.MessageDetail }); }; diff --git a/src/store/chat/slices/portal/initialState.ts b/src/store/chat/slices/portal/initialState.ts index de27590399..27723d34dd 100644 --- a/src/store/chat/slices/portal/initialState.ts +++ b/src/store/chat/slices/portal/initialState.ts @@ -13,6 +13,7 @@ export enum PortalViewType { FilePreview = 'filePreview', GroupThread = 'groupThread', Home = 'home', + LocalFile = 'localFile', MessageDetail = 'messageDetail', Notebook = 'notebook', Thread = 'thread', @@ -31,6 +32,7 @@ export type PortalViewData = | { documentId: string; type: PortalViewType.Document } | { type: PortalViewType.Notebook } | { file: PortalFile; type: PortalViewType.FilePreview } + | { type: PortalViewType.LocalFile } | { messageId: string; type: PortalViewType.MessageDetail } | { identifier: string; messageId: string; type: PortalViewType.ToolUI } | { startMessageId?: string; threadId?: string; type: PortalViewType.Thread } @@ -39,8 +41,13 @@ export type PortalViewData = // ============== Portal State ============== export interface ChatPortalState { + /** Path of the currently active tab; undefined when no tabs open. */ + activeLocalFilePath?: string; + // Legacy fields (kept for backward compatibility during migration) // TODO: Remove after Phase 3 migration complete + /** Open file tabs in the LocalFile portal. */ + openLocalFiles: Array<{ filePath: string; workingDirectory: string }>; /** @deprecated Use portalStack instead */ portalArtifact?: PortalArtifact; portalArtifactDisplayMode: ArtifactDisplayMode; @@ -62,6 +69,7 @@ export interface ChatPortalState { } export const initialChatPortalState: ChatPortalState = { + openLocalFiles: [], portalArtifactDisplayMode: ArtifactDisplayMode.Preview, portalStack: [], showPortal: false, diff --git a/src/store/chat/slices/portal/selectors.test.ts b/src/store/chat/slices/portal/selectors.test.ts index 1c4861ff7e..e14dc28e8f 100644 --- a/src/store/chat/slices/portal/selectors.test.ts +++ b/src/store/chat/slices/portal/selectors.test.ts @@ -192,6 +192,106 @@ describe('chatDockSelectors', () => { }); }); + describe('showLocalFile', () => { + it('should return false when no LocalFile view on stack', () => { + expect(chatPortalSelectors.showLocalFile(createState())).toBe(false); + expect( + chatPortalSelectors.showLocalFile( + createState({ portalStack: [{ type: PortalViewType.Notebook }] }), + ), + ).toBe(false); + }); + + it('should return true when LocalFile view is on top of stack', () => { + const state = createState({ + portalStack: [{ type: PortalViewType.LocalFile }], + }); + expect(chatPortalSelectors.showLocalFile(state)).toBe(true); + }); + }); + + describe('currentLocalFile', () => { + it('should return undefined when activeLocalFilePath is undefined', () => { + expect(chatPortalSelectors.currentLocalFile(createState())).toBeUndefined(); + }); + + it('should return the active file entry from openLocalFiles', () => { + const state = createState({ + activeLocalFilePath: '/path/to/file.ts', + openLocalFiles: [{ filePath: '/path/to/file.ts', workingDirectory: '/path/to' }], + } as Partial); + expect(chatPortalSelectors.currentLocalFile(state)).toEqual({ + filePath: '/path/to/file.ts', + workingDirectory: '/path/to', + }); + }); + + it('should return undefined when activeLocalFilePath is not in openLocalFiles', () => { + const state = createState({ + activeLocalFilePath: '/path/to/other.ts', + openLocalFiles: [{ filePath: '/path/to/file.ts', workingDirectory: '/path/to' }], + } as Partial); + expect(chatPortalSelectors.currentLocalFile(state)).toBeUndefined(); + }); + }); + + describe('localFilePath', () => { + it('should return undefined when no active file', () => { + expect(chatPortalSelectors.localFilePath(createState())).toBeUndefined(); + }); + + it('should return the filePath of the active tab', () => { + const state = createState({ + activeLocalFilePath: '/path/to/file.ts', + openLocalFiles: [{ filePath: '/path/to/file.ts', workingDirectory: '/path/to' }], + } as Partial); + expect(chatPortalSelectors.localFilePath(state)).toBe('/path/to/file.ts'); + }); + }); + + describe('localFileWorkingDirectory', () => { + it('should return undefined when no active file', () => { + expect(chatPortalSelectors.localFileWorkingDirectory(createState())).toBeUndefined(); + }); + + it('should return the workingDirectory of the active tab', () => { + const state = createState({ + activeLocalFilePath: '/path/to/file.ts', + openLocalFiles: [{ filePath: '/path/to/file.ts', workingDirectory: '/path/to' }], + } as Partial); + expect(chatPortalSelectors.localFileWorkingDirectory(state)).toBe('/path/to'); + }); + }); + + describe('openLocalFiles', () => { + it('should return empty array when openLocalFiles is empty', () => { + const state = createState({ openLocalFiles: [] } as Partial); + expect(chatPortalSelectors.openLocalFiles(state)).toEqual([]); + }); + + it('should return the openLocalFiles array', () => { + const files = [ + { filePath: '/path/a.ts', workingDirectory: '/path' }, + { filePath: '/path/b.ts', workingDirectory: '/path' }, + ]; + const state = createState({ openLocalFiles: files } as Partial); + expect(chatPortalSelectors.openLocalFiles(state)).toEqual(files); + }); + }); + + describe('activeLocalFilePath', () => { + it('should return undefined when no active file', () => { + expect(chatPortalSelectors.activeLocalFilePath(createState())).toBeUndefined(); + }); + + it('should return the activeLocalFilePath', () => { + const state = createState({ + activeLocalFilePath: '/path/a.ts', + } as Partial); + expect(chatPortalSelectors.activeLocalFilePath(state)).toBe('/path/a.ts'); + }); + }); + describe('artifactMessageContent', () => { it('should return empty string when message not found', () => { const state = createState(); diff --git a/src/store/chat/slices/portal/selectors.ts b/src/store/chat/slices/portal/selectors.ts index d94489c636..a8d02a01e6 100644 --- a/src/store/chat/slices/portal/selectors.ts +++ b/src/store/chat/slices/portal/selectors.ts @@ -33,6 +33,7 @@ const showArtifactUI = (s: ChatStoreState) => currentViewType(s) === PortalViewT const showDocument = (s: ChatStoreState) => currentViewType(s) === PortalViewType.Document; const showNotebook = (s: ChatStoreState) => currentViewType(s) === PortalViewType.Notebook; const showFilePreview = (s: ChatStoreState) => currentViewType(s) === PortalViewType.FilePreview; +const showLocalFile = (s: ChatStoreState) => currentViewType(s) === PortalViewType.LocalFile; const showMessageDetail = (s: ChatStoreState) => currentViewType(s) === PortalViewType.MessageDetail; const showPluginUI = (s: ChatStoreState) => currentViewType(s) === PortalViewType.ToolUI; @@ -118,6 +119,23 @@ const currentFile = (s: ChatStoreState): PortalFile | undefined => { const previewFileId = (s: ChatStoreState) => currentFile(s)?.fileId; const chunkText = (s: ChatStoreState) => currentFile(s)?.chunkText; +// Local File selectors +const activeLocalFilePath = (s: ChatStoreState): string | undefined => s.activeLocalFilePath; + +const openLocalFiles = (s: ChatStoreState): Array<{ filePath: string; workingDirectory: string }> => + s.openLocalFiles; + +const currentLocalFile = ( + s: ChatStoreState, +): { filePath: string; workingDirectory: string } | undefined => { + const active = s.activeLocalFilePath; + if (!active) return undefined; + return s.openLocalFiles.find((f) => f.filePath === active); +}; + +const localFilePath = (s: ChatStoreState) => currentLocalFile(s)?.filePath; +const localFileWorkingDirectory = (s: ChatStoreState) => currentLocalFile(s)?.workingDirectory; + // Message Detail selectors const messageDetailId = (s: ChatStoreState): string | undefined => { const view = getViewData(s, PortalViewType.MessageDetail); @@ -153,6 +171,7 @@ export const chatPortalSelectors = { showDocument, showNotebook, showFilePreview, + showLocalFile, showMessageDetail, showPluginUI, @@ -175,6 +194,13 @@ export const chatPortalSelectors = { previewFileId, chunkText, + // Local file data + activeLocalFilePath, + currentLocalFile, + localFilePath, + localFileWorkingDirectory, + openLocalFiles, + // Message detail data messageDetailId, diff --git a/src/store/global/action.test.ts b/src/store/global/action.test.ts index 416d7f296a..ee8b1cec98 100644 --- a/src/store/global/action.test.ts +++ b/src/store/global/action.test.ts @@ -408,4 +408,67 @@ describe('createPreferenceSlice', () => { expect(result.current.status.noWideScreen).toEqual(false); }); }); + + describe('revealInFilesTab', () => { + it('should set workingSidebarTab to files', () => { + const { result } = renderHook(() => useGlobalStore()); + + act(() => { + useGlobalStore.setState({ isStatusInit: true }); + result.current.updateSystemStatus({ workingSidebarTab: 'review' }); + result.current.revealInFilesTab('src/foo/bar.ts'); + }); + + expect(result.current.status.workingSidebarTab).toBe('files'); + }); + + it('should set workingSidebarRevealRequest with the given path and a positive nonce', () => { + const { result } = renderHook(() => useGlobalStore()); + + act(() => { + useGlobalStore.setState({ isStatusInit: true }); + result.current.revealInFilesTab('src/foo/bar.ts'); + }); + + expect(result.current.status.workingSidebarRevealRequest?.path).toBe('src/foo/bar.ts'); + expect(result.current.status.workingSidebarRevealRequest?.nonce).toBeGreaterThan(0); + }); + + it('should produce a different nonce when called twice with the same path', async () => { + const { result } = renderHook(() => useGlobalStore()); + + let firstNonce: number | undefined; + + act(() => { + useGlobalStore.setState({ isStatusInit: true }); + result.current.revealInFilesTab('src/foo/bar.ts'); + firstNonce = useGlobalStore.getState().status.workingSidebarRevealRequest?.nonce; + }); + + await new Promise((r) => setTimeout(r, 2)); + + act(() => { + result.current.revealInFilesTab('src/foo/bar.ts'); + }); + + const secondNonce = result.current.status.workingSidebarRevealRequest?.nonce; + expect(secondNonce).not.toBe(firstNonce); + }); + + it('should reset workingSidebarRevealRequest to undefined on initSystemStatus', async () => { + vi.spyOn(useGlobalStore.getState().statusStorage, 'getFromLocalStorage').mockReturnValueOnce({ + workingSidebarRevealRequest: { nonce: 12345, path: 'src/old.ts' }, + } as any); + + const { result } = renderHook(() => useGlobalStore().useInitSystemStatus(), { + wrapper: withSWR, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(useGlobalStore.getState().status.workingSidebarRevealRequest).toBeUndefined(); + }); + }); }); diff --git a/src/store/global/actions/general.ts b/src/store/global/actions/general.ts index 0aaafff631..d0c9d650c4 100644 --- a/src/store/global/actions/general.ts +++ b/src/store/global/actions/general.ts @@ -273,6 +273,7 @@ export class GlobalGeneralActionImpl { ...status, showCommandMenu: false, showHotkeyHelper: false, + workingSidebarRevealRequest: undefined, }; this.#get().updateSystemStatus(statusWithResetTransientStates, 'initSystemStatus'); diff --git a/src/store/global/actions/workspacePane.ts b/src/store/global/actions/workspacePane.ts index 553348c6de..4a1e52f79d 100644 --- a/src/store/global/actions/workspacePane.ts +++ b/src/store/global/actions/workspacePane.ts @@ -123,11 +123,19 @@ export class GlobalWorkspacePaneActionImpl { this.#get().updateSystemStatus({ showSystemRole }, n('toggleMobileTopic', newValue)); }; - setWorkingSidebarTab = (tab: 'resources' | 'review'): void => { + setWorkingSidebarTab = (tab: 'resources' | 'review' | 'files'): void => { if (this.#get().status.workingSidebarTab === tab) return; this.#get().updateSystemStatus({ workingSidebarTab: tab }, n('setWorkingSidebarTab', tab)); }; + revealInFilesTab = (relativePath: string): void => { + this.#get().setWorkingSidebarTab('files'); + this.#get().updateSystemStatus( + { workingSidebarRevealRequest: { nonce: Date.now(), path: relativePath } }, + n('revealInFilesTab'), + ); + }; + toggleWideScreen = (newValue?: boolean): void => { const noWideScreen = typeof newValue === 'boolean' ? !newValue : !this.#get().status.noWideScreen; diff --git a/src/store/global/initialState.ts b/src/store/global/initialState.ts index 6ed2afa666..6780620aca 100644 --- a/src/store/global/initialState.ts +++ b/src/store/global/initialState.ts @@ -268,12 +268,13 @@ export interface SystemStatus { videoPanelWidth: number; videoTopicPanelWidth?: number; videoTopicViewMode?: 'grid' | 'list'; + workingSidebarRevealRequest?: { nonce: number; path: string }; /** * Active tab inside the agent chat right-side WorkingSidebar. * Lifted to global so external triggers (e.g. the diff badge in the input bar) * can switch the panel to "review" when revealing the right panel. */ - workingSidebarTab?: 'resources' | 'review'; + workingSidebarTab?: 'resources' | 'review' | 'files'; zenMode?: boolean; } diff --git a/src/store/user/slices/preference/selectors/preference.ts b/src/store/user/slices/preference/selectors/preference.ts index b6c5ab10e5..0ab6bd8ba3 100644 --- a/src/store/user/slices/preference/selectors/preference.ts +++ b/src/store/user/slices/preference/selectors/preference.ts @@ -3,6 +3,7 @@ import { DEFAULT_PREFERENCE } from '@lobechat/const'; import { type UserStore } from '@/store/user'; const useCmdEnterToSend = (s: UserStore): boolean => s.preference.useCmdEnterToSend || false; +const defaultOpenInApp = (s: UserStore): string | undefined => s.preference.defaultOpenInApp; const topicGroupMode = (s: UserStore) => s.preference.topicGroupMode || DEFAULT_PREFERENCE.topicGroupMode!; const topicSortBy = (s: UserStore) => s.preference.topicSortBy || DEFAULT_PREFERENCE.topicSortBy!; @@ -22,6 +23,7 @@ const shouldTriggerFileInKnowledgeBaseTip = (s: UserStore) => const isPreferenceInit = (s: UserStore) => s.isUserStateInit; export const preferenceSelectors = { + defaultOpenInApp, hideSettingsMoveGuide, hideSyncAlert, isPreferenceInit,