mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
refactor: merge agent marketplace into web onboarding
* ✨ feat(desktop): open-in-app + agent files tab + localfile protocol Bundle three related desktop features: - Open-in-app: IPC contract, main-process detector/launcher/icon-extractor, renderer service, OpenInAppButton + hook, agent header / portal / files-tab integration, user preference (defaultOpenInApp). - Agent files tab: working sidebar files tab with file tracking, store wiring, i18n, reveal-in-tree action in Review/FileItem. - LocalFile protocol: serve binary images via localfile:// for inline preview in the review panel. * 🐛 fix: add explicit type annotation for ref parameter in Files test Fix TS7031: Binding element 'ref' implicitly has an 'any' type. This error was caught by tsgo type-check in CI. * 🐛 fix: address codex review feedback (P1 reveal retry + P2 WebStorm Windows detection) * 🐛 fix(open-in-app): avoid process.platform reference in renderer The Electron renderer sandbox does not expose `process`, so reading `process.platform` in the useOpenInApp hook crashes with a ReferenceError on app launch. Use the `window.lobeEnv.platform` value already exposed via preload contextBridge instead. * 🐛 fix(conversation): keep assistant runtime errors outside workflow collapse When an assistant block carries a runtime error, render the error in the answer segment instead of letting it fold into the workflow collapse with the surrounding tool calls. * ✨ feat(portal): add file viewer tab strip and local file protocol improvements - Add tabbed interface for local file portal viewer - Extend LocalFileProtocolManager with audio MIME type support - Add portal actions for file navigation and tab management - Improve OpenInAppButton and conversation header integration - Update working sidebar resources section - Add comprehensive portal action tests * ✨ feat(agent-sidebar): redesign Review panel and refine Files explorer - Review: drop antd Collapse, replace with a linear disclosure list (hairline dividers, no rounded cards, chevron-left, role=button rows). Add motion height/opacity expand animation. Compact row spacing. Move hover-revealed copy/reveal/revert into an absolute Flexbox with a gradient mask so they overlay the right edge without taking layout. - Files: extract useGitWorkingTreeFiles hook + tests; surface git status entries in the working tree explorer. - ExplorerTree: share folder icon style; minor type tweak. - Locales: new chat strings for the above. * 🐛 fix(test): add missing chatConfigByIdSelectors mock to WorkingSidebar test
This commit is contained in:
@@ -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 `<filename>.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 `<filename>.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: <ErrorBoundary />;
|
||||
```
|
||||
|
||||
### Navigation
|
||||
## Common Mistakes
|
||||
|
||||
**Important**: For SPA pages, use `Link` from `react-router-dom`, NOT `next/link`.
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
import Link from 'next/link';
|
||||
<Link href="/">Home</Link>;
|
||||
|
||||
// ✅ Correct
|
||||
import { Link } from 'react-router-dom';
|
||||
<Link to="/">Home</Link>;
|
||||
|
||||
// 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) |
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<DetectAppsResult> {
|
||||
const apps = await getCachedDetection();
|
||||
return { apps };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openInApp({ appId, path }: OpenInAppParams): Promise<OpenInAppResult> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, (event: any, ...args: any[]) => 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 <T = any>(
|
||||
channel: string,
|
||||
payload?: any,
|
||||
context?: Partial<IpcContext>,
|
||||
): Promise<T> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'.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/<percent-encoded-absolute-path>`
|
||||
* - 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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<any>((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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>()),
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
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 <appName> <path>', 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 <path>', 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 <binary> <path>', 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DetectedApp } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectAllApps } from './detectors';
|
||||
|
||||
let cachedPromise: Promise<DetectedApp[]> | null = null;
|
||||
|
||||
export const getCachedDetection = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<DetectedApp[]> => {
|
||||
if (!cachedPromise) {
|
||||
cachedPromise = detectAllApps(platform);
|
||||
}
|
||||
return cachedPromise;
|
||||
};
|
||||
|
||||
export const clearDetectionCache = (): void => {
|
||||
cachedPromise = null;
|
||||
};
|
||||
@@ -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<boolean> => {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const probeCommandV = async (binary: string): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<DetectedApp[]> => {
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -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<string> =>
|
||||
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<string | undefined> | undefined;
|
||||
|
||||
const ensureTmpDir = async (): Promise<string | undefined> => {
|
||||
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<boolean> | 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<boolean> => {
|
||||
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<string | undefined> => {
|
||||
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<string | undefined> => {
|
||||
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<string | undefined> => {
|
||||
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<string | undefined> => {
|
||||
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<Map<OpenInAppId, string>> => {
|
||||
const map = new Map<OpenInAppId, string>();
|
||||
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;
|
||||
};
|
||||
@@ -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<OpenInAppResult> => {
|
||||
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<OpenInAppResult> => {
|
||||
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;
|
||||
};
|
||||
@@ -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<Record<NodeJS.Platform, DetectStrategy>>;
|
||||
displayName: string;
|
||||
launch: Partial<Record<NodeJS.Platform, LaunchStrategy>>;
|
||||
}
|
||||
|
||||
export const APP_REGISTRY: Record<OpenInAppId, AppDescriptor> = {
|
||||
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<Record<NodeJS.Platform, OpenInAppId>> = {
|
||||
darwin: 'finder',
|
||||
linux: 'files',
|
||||
win32: 'explorer',
|
||||
};
|
||||
@@ -4,22 +4,46 @@ export const getExportMimeType = (filePath: string) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'.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];
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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}} 的工作区变更将被永久丢弃,未跟踪的新文件会从磁盘删除。",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"dropdownLabel": "用以下应用打开当前目录",
|
||||
"errors.appNotInstalled": "未检测到 {{appName}}",
|
||||
"errors.launchFailed": "{{appName}} 打开失败:{{error}}",
|
||||
"errors.pathNotFound": "路径不存在:{{path}}",
|
||||
"errors.unknown": "未知错误",
|
||||
"tooltip": "用 {{appName}} 打开"
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/<abs-path>` 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}`;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,13 @@ export type MainBroadcastParams<T extends MainBroadcastEventKey> = Parameters<
|
||||
>[0];
|
||||
|
||||
export type { GatewayConnectionStatus } from './gatewayConnection';
|
||||
export type {
|
||||
DetectAppsResult,
|
||||
DetectedApp,
|
||||
OpenInAppId,
|
||||
OpenInAppParams,
|
||||
OpenInAppResult,
|
||||
} from './openInApp';
|
||||
export type {
|
||||
AuthorizationPhase,
|
||||
AuthorizationProgress,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -63,6 +63,8 @@ export const UserLabSchema = z.object({
|
||||
export type UserLab = z.infer<typeof UserLabSchema>;
|
||||
|
||||
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(),
|
||||
|
||||
@@ -132,7 +132,7 @@ vi.mock('@/features/ExplorerTree', () => {
|
||||
);
|
||||
};
|
||||
|
||||
return { ExplorerTree };
|
||||
return { ExplorerTree, FOLDER_ICON_CSS: '' };
|
||||
});
|
||||
|
||||
const createDocument = (overrides: Partial<AgentDocumentItem>): AgentDocumentItem =>
|
||||
|
||||
@@ -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<Props>(({ 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<string, string | null>();
|
||||
@@ -233,14 +237,18 @@ const DocumentExplorerTree = memo<Props>(({ agentId, data, mutate, style }) => {
|
||||
return (
|
||||
<div className={styles.tree} style={style}>
|
||||
<ExplorerTree<AgentDocumentItem>
|
||||
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={
|
||||
<DocumentExplorerToolbar
|
||||
onCreateDocument={() => handleCreateDocument(null)}
|
||||
|
||||
@@ -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(
|
||||
<Group
|
||||
id="assistant-1"
|
||||
messageIndex={0}
|
||||
blocks={[
|
||||
blk({
|
||||
content: '',
|
||||
error: {
|
||||
body: { code: 'rate_limit' },
|
||||
message: 'rate limit',
|
||||
type: 'ProviderBizError',
|
||||
} as any,
|
||||
id: 'block-1',
|
||||
tools: [
|
||||
{ apiName: 'bash', id: 'tool-1' } as any,
|
||||
{ apiName: 'bash', id: 'tool-2' } as any,
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Group
|
||||
@@ -279,6 +343,7 @@ describe('Group', () => {
|
||||
content: '',
|
||||
disableMarkdownStreaming: true,
|
||||
domId: undefined,
|
||||
hasError: false,
|
||||
id: 'block-1',
|
||||
isFirstBlock: false,
|
||||
toolCount: 1,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const folderClosedSvg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'/></svg>`;
|
||||
const folderOpenSvg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.69.9H18a2 2 0 0 1 2 2v2'/></svg>`;
|
||||
|
||||
// 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}");
|
||||
}
|
||||
`;
|
||||
@@ -1,3 +1,4 @@
|
||||
export { FOLDER_ICON_CSS } from './folderIconStyle';
|
||||
export type {
|
||||
ExplorerTreeCanDropCtx,
|
||||
ExplorerTreeHandle,
|
||||
|
||||
@@ -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<TData = unknown> {
|
||||
getRowDecoration?: (
|
||||
ctx: ExplorerTreeRowDecorationCtx<TData>,
|
||||
) => FileTreeRowDecoration | null | undefined;
|
||||
gitStatus?: readonly GitStatusEntry[];
|
||||
header?: ReactNode;
|
||||
iconsColored?: boolean;
|
||||
iconSet?: 'minimal' | 'standard' | 'complete' | 'none';
|
||||
@@ -79,4 +80,6 @@ export interface ExplorerTreeProps<TData = unknown> {
|
||||
overscan?: number;
|
||||
selectedIds?: string[];
|
||||
style?: CSSProperties;
|
||||
/** Raw CSS injected into the pierre/trees shadow DOM via FILE_TREE_UNSAFE_CSS_ATTRIBUTE. */
|
||||
unsafeCSS?: string;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ function ExplorerTreeInner<TData>(
|
||||
colored: props.iconsColored ?? true,
|
||||
set: props.iconSet ?? 'standard',
|
||||
},
|
||||
gitStatus: props.gitStatus,
|
||||
initialExpandedPaths,
|
||||
initialSelectedPaths,
|
||||
itemHeight: props.itemHeight,
|
||||
@@ -214,6 +215,7 @@ function ExplorerTreeInner<TData>(
|
||||
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<TData>(
|
||||
// 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(() => {
|
||||
|
||||
@@ -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<any>` 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<any>;
|
||||
|
||||
export const APP_ICONS: Record<OpenInAppId, IconLike> = {
|
||||
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<NodeJS.Platform, OpenInAppId> = {
|
||||
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<string>,
|
||||
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;
|
||||
};
|
||||
@@ -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<string, unknown>) =>
|
||||
opts ? `${key}::${JSON.stringify(opts)}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
DropdownMenu: ({
|
||||
children,
|
||||
items,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
items: { icon?: ReactNode; key: string; label: ReactNode; onClick?: () => void }[];
|
||||
}) => (
|
||||
<div data-testid="dropdown-root">
|
||||
<div data-testid="dropdown-trigger">{children}</div>
|
||||
<ul data-testid="dropdown-items">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
data-item-id={item.key}
|
||||
data-testid={`dropdown-item-${item.key}`}
|
||||
key={item.key}
|
||||
onClick={() => item.onClick?.()}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
Icon: ({ icon: IconComp, size }: { icon: unknown; size?: number }) => (
|
||||
<span data-icon-size={size} data-testid="ui-icon">
|
||||
{typeof IconComp === 'function'
|
||||
? ((IconComp as { displayName?: string; name?: string }).displayName ??
|
||||
(IconComp as { displayName?: string; name?: string }).name ??
|
||||
'icon')
|
||||
: 'icon'}
|
||||
</span>
|
||||
),
|
||||
Tooltip: ({ children, title }: { children: ReactNode; title?: ReactNode }) => (
|
||||
<div data-testid="tooltip" data-title={typeof title === 'string' ? title : ''}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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('<OpenInAppButton />', () => {
|
||||
it('returns null on web build (isDesktop=false)', () => {
|
||||
isDesktopValue = false;
|
||||
const { container } = render(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when workingDirectory is empty', () => {
|
||||
const { container } = render(<OpenInAppButton workingDirectory="" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null while detection is pending', () => {
|
||||
hookReturn = { ...hookReturn, ready: false };
|
||||
const { container } = render(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the split button with the default app icon when ready', () => {
|
||||
render(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dropdown-root')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls launch(defaultApp) when the left half is clicked', () => {
|
||||
render(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
|
||||
const leftButton = screen.getByLabelText(/tooltip/);
|
||||
fireEvent.click(leftButton);
|
||||
|
||||
expect(launchMock).toHaveBeenCalledWith('vscode');
|
||||
});
|
||||
|
||||
it('lists only installed apps in the dropdown', () => {
|
||||
render(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
|
||||
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(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-item-finder'));
|
||||
|
||||
expect(launchMock).toHaveBeenCalledWith('finder');
|
||||
});
|
||||
|
||||
it('renders extracted base64 icon as an <img> 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(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
|
||||
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(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
|
||||
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(<OpenInAppButton workingDirectory="/tmp/proj" />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<img
|
||||
alt=""
|
||||
height={size}
|
||||
src={icon}
|
||||
style={{ display: 'block', objectFit: 'contain' }}
|
||||
width={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Fallback = APP_ICONS[id];
|
||||
return <Icon icon={Fallback} size={size} />;
|
||||
};
|
||||
|
||||
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<OpenInAppButtonProps>(({ 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<DropdownMenuProps['items']>(
|
||||
() =>
|
||||
installedApps.map((app) => ({
|
||||
icon: <AppIcon icon={app.icon} id={app.id} size={14} />,
|
||||
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 (
|
||||
<div className={wrapperClassName}>
|
||||
<Tooltip title={t('tooltip', { appName: defaultDisplayName })}>
|
||||
<div
|
||||
aria-label={t('tooltip', { appName: defaultDisplayName })}
|
||||
className={styles.leftButton}
|
||||
role="button"
|
||||
onClick={() => {
|
||||
void launch(defaultApp);
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={defaultIconSrc} id={defaultApp} size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<DropdownMenu items={dropdownItems} trigger={['click']}>
|
||||
<div aria-label={t('dropdownLabel')} className={styles.rightButton} role="button">
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
OpenInAppButton.displayName = 'OpenInAppButton';
|
||||
|
||||
export default OpenInAppButton;
|
||||
@@ -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 }) => (
|
||||
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>{children}</SWRConfig>
|
||||
);
|
||||
|
||||
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<string, unknown>) =>
|
||||
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<string>();
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
|
||||
apps: [
|
||||
{ displayName: 'Finder', id: 'finder', installed: true },
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
],
|
||||
});
|
||||
(service.openInApp as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
|
||||
apps: [{ displayName: 'VS Code', id: 'vscode', installed: true }],
|
||||
});
|
||||
(service.openInApp as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
|
||||
apps: [{ displayName: 'VS Code', id: 'vscode', installed: true }],
|
||||
});
|
||||
(service.openInApp as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
|
||||
apps: [{ displayName: 'VS Code', id: 'vscode', installed: true }],
|
||||
});
|
||||
(service.openInApp as ReturnType<typeof vi.fn>).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'));
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
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<void> => {
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
const EXT_TO_LANG: Record<string, string> = {
|
||||
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);
|
||||
};
|
||||
@@ -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<LocalFilePreview> => {
|
||||
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<ImagePreviewProps>(({ blob, filename }) => {
|
||||
const [imageSrc, setImageSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [blob]);
|
||||
|
||||
if (!imageSrc) return <Loading />;
|
||||
|
||||
return (
|
||||
<Center height={'100%'} style={{ overflow: 'auto' }} width={'100%'}>
|
||||
<img alt={filename} src={imageSrc} style={{ maxWidth: '100%', objectFit: 'contain' }} />
|
||||
</Center>
|
||||
);
|
||||
});
|
||||
|
||||
ImagePreview.displayName = 'ImagePreview';
|
||||
|
||||
// ============== ActiveFileView ==============
|
||||
|
||||
interface ActiveFileViewProps {
|
||||
filePath: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const ActiveFileView = memo<ActiveFileViewProps>(({ 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 (
|
||||
<Center height={'100%'} width={'100%'}>
|
||||
<Empty description={t('workingPanel.localFile.binary')} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
if (error || !preview) {
|
||||
return (
|
||||
<Center height={'100%'} width={'100%'}>
|
||||
<Empty description={t('workingPanel.localFile.error')} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.type === 'binary') {
|
||||
return (
|
||||
<Center height={'100%'} width={'100%'}>
|
||||
<Empty description={t('workingPanel.localFile.binary')} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.type === 'image') {
|
||||
return <ImagePreview blob={preview.blob} filename={filename} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0, overflow: 'auto' }}>
|
||||
{truncated && (
|
||||
<Center paddingBlock={4} style={{ flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, opacity: 0.65 }}>
|
||||
{t('workingPanel.localFile.truncated', { limit: MAX_PREVIEW_CHARS.toLocaleString() })}
|
||||
</span>
|
||||
</Center>
|
||||
)}
|
||||
<Flexbox flex={1} style={{ minHeight: 0, overflow: 'auto' }}>
|
||||
<Highlighter
|
||||
language={extensionToLanguage(ext)}
|
||||
style={{ fontSize: 12, minHeight: '100%', overflow: 'visible' }}
|
||||
>
|
||||
{displayContent}
|
||||
</Highlighter>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<ActiveFileView
|
||||
filePath={activeFile.filePath}
|
||||
workingDirectory={activeFile.workingDirectory}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
Body.displayName = 'LocalFileBody';
|
||||
|
||||
export default Body;
|
||||
@@ -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 (
|
||||
<NavHeader
|
||||
showTogglePanelButton={false}
|
||||
style={{ padding: '0 8px 0 0' }}
|
||||
left={
|
||||
<Fragment>
|
||||
{canGoBack && (
|
||||
<ActionIcon icon={ArrowLeft} size={DESKTOP_HEADER_ICON_SMALL_SIZE} onClick={goBack} />
|
||||
)}
|
||||
<TabStrip />
|
||||
</Fragment>
|
||||
}
|
||||
right={
|
||||
<Fragment>
|
||||
<ActionIcon
|
||||
icon={X}
|
||||
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
|
||||
onClick={() => {
|
||||
if (params.aid && params.topicId && isTopicPageRoute) {
|
||||
navigate(SESSION_CHAT_TOPIC_URL(params.aid, params.topicId));
|
||||
return;
|
||||
}
|
||||
|
||||
clearPortalStack();
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
styles={{
|
||||
left: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Header.displayName = 'LocalFileHeader';
|
||||
|
||||
export default Header;
|
||||
@@ -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 (
|
||||
<ScrollArea
|
||||
scrollFade
|
||||
contentProps={{ style: SCROLL_AREA_CONTENT_STYLE }}
|
||||
scrollbarProps={{ orientation: 'horizontal', style: SCROLL_AREA_SCROLLBAR_STYLE }}
|
||||
style={SCROLL_AREA_STYLE}
|
||||
>
|
||||
{openLocalFiles.map(({ filePath }, index) => {
|
||||
const filename = filePath.split('/').at(-1) ?? filePath;
|
||||
const isActive = filePath === activeLocalFilePath;
|
||||
|
||||
return (
|
||||
<ContextMenuTrigger items={() => getContextMenuItems(filePath, index)} key={filePath}>
|
||||
<div
|
||||
aria-selected={isActive}
|
||||
className={`${styles.tabItem} ${isActive ? styles.tabItemActive : ''}`}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
title={filePath}
|
||||
onClick={() => setActiveLocalFile(filePath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setActiveLocalFile(filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.tabLabel}>{filename}</span>
|
||||
<button
|
||||
aria-label={`Close ${filename}`}
|
||||
className={styles.tabClose}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeLocalFileTab(filePath);
|
||||
}}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
);
|
||||
});
|
||||
|
||||
TabStrip.displayName = 'LocalFileTabStrip';
|
||||
|
||||
export default TabStrip;
|
||||
@@ -0,0 +1,3 @@
|
||||
const Title = () => null;
|
||||
|
||||
export default Title;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 }) => {
|
||||
</Flexbox>
|
||||
}
|
||||
right={
|
||||
<ActionIcon
|
||||
icon={X}
|
||||
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
|
||||
onClick={() => {
|
||||
if (params.aid && params.topicId && isTopicPageRoute) {
|
||||
navigate(SESSION_CHAT_TOPIC_URL(params.aid, params.topicId));
|
||||
return;
|
||||
}
|
||||
<Fragment>
|
||||
{rightExtra}
|
||||
<ActionIcon
|
||||
icon={X}
|
||||
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
|
||||
onClick={() => {
|
||||
if (params.aid && params.topicId && isTopicPageRoute) {
|
||||
navigate(SESSION_CHAT_TOPIC_URL(params.aid, params.topicId));
|
||||
return;
|
||||
}
|
||||
|
||||
clearPortalStack();
|
||||
}}
|
||||
/>
|
||||
clearPortalStack();
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
styles={{
|
||||
left: {
|
||||
|
||||
@@ -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, PortalImpl> = {
|
||||
[PortalViewType.Document]: Document,
|
||||
[PortalViewType.Notebook]: Notebook,
|
||||
[PortalViewType.FilePreview]: FilePreview,
|
||||
[PortalViewType.LocalFile]: LocalFile,
|
||||
[PortalViewType.MessageDetail]: MessageDetail,
|
||||
[PortalViewType.ToolUI]: Plugins,
|
||||
[PortalViewType.Thread]: Thread,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}',
|
||||
};
|
||||
@@ -259,6 +259,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
||||
return <ProviderIcon color={cssVar.colorTextDescription} size={16} />;
|
||||
}
|
||||
}
|
||||
if (isHeterogeneousAgent) return null;
|
||||
return (
|
||||
<Icon
|
||||
icon={HashIcon}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import NavHeader from '@/features/NavHeader';
|
||||
import OpenInAppButton from '@/features/OpenInAppButton';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import HeaderActions from './HeaderActions';
|
||||
import ShareButton from './ShareButton';
|
||||
@@ -34,6 +39,16 @@ const headerStyles = createStaticStyles(({ css }) => ({
|
||||
}));
|
||||
|
||||
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 (
|
||||
<div className={headerStyles.container}>
|
||||
<NavHeader
|
||||
@@ -57,6 +72,9 @@ const Header = memo(() => {
|
||||
gap={4}
|
||||
style={{ backgroundColor: cssVar.colorBgContainer }}
|
||||
>
|
||||
{isLocalSystemEnabled && (
|
||||
<OpenInAppButton workingDirectory={effectiveWorkingDirectory} />
|
||||
)}
|
||||
<ShareButton />
|
||||
<WorkingPanelToggle />
|
||||
</Flexbox>
|
||||
|
||||
+273
@@ -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<string, unknown> | 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<unknown>; [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 <div data-testid="explorer-tree" />;
|
||||
};
|
||||
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<string, unknown>) => 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 }) => <button type="button" onClick={onClick} />,
|
||||
Center: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
copyToClipboard: vi.fn(),
|
||||
Empty: ({ description }: { description?: ReactNode }) => <div>{description}</div>,
|
||||
Flexbox: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('antd-style', () => ({
|
||||
createStaticStyles: () => () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/NeuralNetworkLoading', () => ({
|
||||
default: () => <div />,
|
||||
}));
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const setReveal = (path: string, nonce: number) => {
|
||||
useGlobalStore.setState({
|
||||
status: {
|
||||
...useGlobalStore.getState().status,
|
||||
workingSidebarRevealRequest: { nonce, path },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
explorerTreeProps.current = undefined;
|
||||
handleSpies.focus.mockClear();
|
||||
handleSpies.select.mockClear();
|
||||
handleSpies.setExpanded.mockClear();
|
||||
messageSpy.warning.mockClear();
|
||||
useGlobalStore.setState({
|
||||
...initialState,
|
||||
status: { ...initialState.status, workingSidebarRevealRequest: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Files — reveal request integration', () => {
|
||||
it('passes git working tree status and per-item context menu items into ExplorerTree', () => {
|
||||
render(<Files workingDirectory="/repo" />);
|
||||
|
||||
expect(explorerTreeProps.current?.gitStatus).toEqual([
|
||||
{ path: 'root.ts', status: 'added' },
|
||||
{ path: 'src/foo/bar.ts', status: 'modified' },
|
||||
{ path: 'deleted.ts', status: 'deleted' },
|
||||
]);
|
||||
|
||||
const nodes = explorerTreeProps.current?.nodes as { id: string }[];
|
||||
const dirtyNode = nodes.find((node) => node.id === 'src/foo/bar.ts');
|
||||
const cleanFolderNode = nodes.find((node) => node.id === 'src/');
|
||||
|
||||
const getContextMenuItems = explorerTreeProps.current?.getContextMenuItems as (
|
||||
node: unknown,
|
||||
) => { key: string }[];
|
||||
|
||||
expect(getContextMenuItems(dirtyNode).map((item) => item.key)).toEqual([
|
||||
'open',
|
||||
'divider-reveal',
|
||||
'show-in-system',
|
||||
'show-in-review',
|
||||
'divider-copy',
|
||||
'copy-absolute-path',
|
||||
'copy-relative-path',
|
||||
]);
|
||||
expect(getContextMenuItems(cleanFolderNode).map((item) => item.key)).toEqual([
|
||||
'open',
|
||||
'divider-reveal',
|
||||
'show-in-system',
|
||||
'divider-copy',
|
||||
'copy-absolute-path',
|
||||
'copy-relative-path',
|
||||
]);
|
||||
});
|
||||
|
||||
it('(a) reveals existing path: calls setExpanded with ancestors, then select and focus', async () => {
|
||||
render(<Files workingDirectory="/repo" />);
|
||||
|
||||
setReveal('src/foo/bar.ts', 1);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handleSpies.setExpanded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const expandedArg: string[] = handleSpies.setExpanded.mock.calls[0][0];
|
||||
expect(expandedArg).toContain('src/');
|
||||
expect(expandedArg).toContain('src/foo/');
|
||||
|
||||
expect(handleSpies.select).toHaveBeenCalledWith('src/foo/bar.ts');
|
||||
expect(handleSpies.focus).toHaveBeenCalledWith('src/foo/bar.ts');
|
||||
expect(messageSpy.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('(a-root) reveals root-level file: no ancestor dirs, only select+focus', async () => {
|
||||
render(<Files workingDirectory="/repo" />);
|
||||
|
||||
setReveal('root.ts', 1);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handleSpies.select).toHaveBeenCalledWith('root.ts');
|
||||
});
|
||||
|
||||
expect(handleSpies.focus).toHaveBeenCalledWith('root.ts');
|
||||
// root.ts has no ancestor dirs; setExpanded is still called but must not include 'root.ts'
|
||||
expect(handleSpies.setExpanded).toHaveBeenCalled();
|
||||
const expandedArg: string[] = handleSpies.setExpanded.mock.calls[0][0];
|
||||
expect(expandedArg).not.toContain('root.ts');
|
||||
expect(messageSpy.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('(b) missing path triggers message.warning with localized key', async () => {
|
||||
render(<Files workingDirectory="/repo" />);
|
||||
|
||||
setReveal('nonexistent/deep/file.ts', 1);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(messageSpy.warning).toHaveBeenCalledWith('workingPanel.review.revealNotFound');
|
||||
});
|
||||
|
||||
expect(handleSpies.setExpanded).not.toHaveBeenCalled();
|
||||
expect(handleSpies.select).not.toHaveBeenCalled();
|
||||
expect(handleSpies.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('(c) bumping nonce with same path retriggers reveal', async () => {
|
||||
render(<Files workingDirectory="/repo" />);
|
||||
|
||||
setReveal('src/foo/bar.ts', 1);
|
||||
await vi.waitFor(() => {
|
||||
expect(handleSpies.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
handleSpies.select.mockClear();
|
||||
handleSpies.focus.mockClear();
|
||||
handleSpies.setExpanded.mockClear();
|
||||
|
||||
// Same path, new nonce → should fire again
|
||||
setReveal('src/foo/bar.ts', 2);
|
||||
await vi.waitFor(() => {
|
||||
expect(handleSpies.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(handleSpies.focus).toHaveBeenCalledWith('src/foo/bar.ts');
|
||||
expect(handleSpies.setExpanded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no-op when revealRequest is null/undefined (initial state)', () => {
|
||||
// revealRequest is already undefined from beforeEach
|
||||
render(<Files workingDirectory="/repo" />);
|
||||
|
||||
expect(handleSpies.setExpanded).not.toHaveBeenCalled();
|
||||
expect(handleSpies.select).not.toHaveBeenCalled();
|
||||
expect(handleSpies.focus).not.toHaveBeenCalled();
|
||||
expect(messageSpy.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildGitStatusEntries } from '../useGitWorkingTreeFiles';
|
||||
|
||||
describe('buildGitStatusEntries', () => {
|
||||
it('maps working-tree file buckets to pierre/trees git status entries', () => {
|
||||
expect(
|
||||
buildGitStatusEntries({
|
||||
added: ['new.ts'],
|
||||
deleted: ['old.ts'],
|
||||
modified: ['changed.ts'],
|
||||
}),
|
||||
).toEqual([
|
||||
{ path: 'new.ts', status: 'added' },
|
||||
{ path: 'changed.ts', status: 'modified' },
|
||||
{ path: 'old.ts', status: 'deleted' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import type { ProjectFileIndexEntry } from '@lobechat/electron-client-ipc';
|
||||
import { ActionIcon, Center, copyToClipboard, Empty, Flexbox } from '@lobehub/ui';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { message } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { FileIcon, RefreshCwIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
|
||||
import type { ExplorerTreeNode } from '@/features/ExplorerTree';
|
||||
import { ExplorerTree, FOLDER_ICON_CSS } from '@/features/ExplorerTree';
|
||||
import type { ExplorerTreeHandle } from '@/features/ExplorerTree/types';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
import { buildGitStatusEntries, useGitWorkingTreeFiles } from './useGitWorkingTreeFiles';
|
||||
import { useProjectFiles } from './useProjectFiles';
|
||||
|
||||
interface FilesProps {
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
tree: css`
|
||||
--trees-bg-override: transparent;
|
||||
--trees-border-color-override: transparent;
|
||||
--trees-selected-bg-override: ${cssVar.colorFillSecondary};
|
||||
--trees-bg-muted-override: ${cssVar.colorFillTertiary};
|
||||
--trees-fg-override: ${cssVar.colorText};
|
||||
--trees-fg-muted-override: ${cssVar.colorTextSecondary};
|
||||
--trees-accent-override: ${cssVar.colorPrimary};
|
||||
--trees-padding-inline-override: 0px;
|
||||
--trees-font-size-override: 12px;
|
||||
--trees-border-radius-override: 6px;
|
||||
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`,
|
||||
subheader: css`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding-block: 4px 8px;
|
||||
padding-inline: 14px 6px;
|
||||
`,
|
||||
count: css`
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const stripTrailingSlash = (value: string) => (value.endsWith('/') ? value.slice(0, -1) : value);
|
||||
|
||||
const getParentRelativePath = (relativePath: string): string | null => {
|
||||
const cleaned = stripTrailingSlash(relativePath);
|
||||
const idx = cleaned.lastIndexOf('/');
|
||||
if (idx < 0) return null;
|
||||
return `${cleaned.slice(0, idx)}/`;
|
||||
};
|
||||
|
||||
const buildTreeNodes = (
|
||||
entries: ProjectFileIndexEntry[],
|
||||
): ExplorerTreeNode<ProjectFileIndexEntry>[] => {
|
||||
// The index gives every file plus the chain of containing directories, each
|
||||
// with a unique relativePath (directories end with "/"). Use that string as
|
||||
// the stable node id and derive parentId from the path itself.
|
||||
const ids = new Set(entries.map((entry) => entry.relativePath));
|
||||
return entries.map((entry) => {
|
||||
const parentRel = getParentRelativePath(entry.relativePath);
|
||||
const parentId = parentRel && ids.has(parentRel) ? parentRel : null;
|
||||
return {
|
||||
data: entry,
|
||||
id: entry.relativePath,
|
||||
isFolder: entry.isDirectory,
|
||||
name: entry.name,
|
||||
parentId,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getAncestorIds = (filePath: string): string[] => {
|
||||
const segments = filePath.split('/');
|
||||
const ancestors: string[] = [];
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
ancestors.push(segments.slice(0, i).join('/') + '/');
|
||||
}
|
||||
return ancestors;
|
||||
};
|
||||
|
||||
const Files = memo<FilesProps>(({ workingDirectory }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { data, isLoading, isValidating, mutate } = useProjectFiles(workingDirectory);
|
||||
const { data: gitFiles } = useGitWorkingTreeFiles(workingDirectory, data?.source === 'git');
|
||||
|
||||
const entries = useMemo(() => data?.entries ?? [], [data]);
|
||||
const nodes = useMemo(() => buildTreeNodes(entries), [entries]);
|
||||
const gitStatus = useMemo(() => buildGitStatusEntries(gitFiles), [gitFiles]);
|
||||
const dirtyFilePaths = useMemo(() => new Set(gitStatus.map((entry) => entry.path)), [gitStatus]);
|
||||
// Pre-expand top-level directories so the user sees something useful on first
|
||||
// paint without having to click through every folder.
|
||||
const defaultExpandedIds = useMemo(
|
||||
() => nodes.filter((node) => node.isFolder && node.parentId == null).map((node) => node.id),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const [expandedIds, setExpandedIds] = useState<string[]>([]);
|
||||
|
||||
// Skip resyncs when defaultExpandedIds is structurally unchanged so the user's expansions survive re-renders.
|
||||
const prevDefaultRef = useRef<string[]>([]);
|
||||
useEffect(() => {
|
||||
const next = defaultExpandedIds.join('\0');
|
||||
const prev = prevDefaultRef.current.join('\0');
|
||||
if (next === prev) return;
|
||||
prevDefaultRef.current = defaultExpandedIds;
|
||||
setExpandedIds(defaultExpandedIds);
|
||||
}, [defaultExpandedIds]);
|
||||
|
||||
const treeRef = useRef<ExplorerTreeHandle>(null);
|
||||
|
||||
const revealRequest = useGlobalStore((s) => s.status.workingSidebarRevealRequest);
|
||||
const setWorkingSidebarTab = useGlobalStore((s) => s.setWorkingSidebarTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealRequest) return;
|
||||
const { path, nonce: _nonce } = revealRequest;
|
||||
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
if (!nodeIds.has(path)) {
|
||||
// Data may still be loading — retry silently instead of showing a warning.
|
||||
if (!isLoading) {
|
||||
void message.warning(t('workingPanel.review.revealNotFound'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ancestors = getAncestorIds(path);
|
||||
const nextExpanded = Array.from(new Set([...expandedIds, ...ancestors]));
|
||||
treeRef.current?.setExpanded(nextExpanded);
|
||||
treeRef.current?.select(path);
|
||||
treeRef.current?.focus(path);
|
||||
// Re-run when nonce changes (user re-triggers) or when nodes/isLoading
|
||||
// update (data arrives after initial reveal attempt).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [revealRequest?.nonce, nodes, isLoading]);
|
||||
|
||||
const openLocalFile = useChatStore((s) => s.openLocalFile);
|
||||
|
||||
const openNode = useCallback(
|
||||
(node: ExplorerTreeNode<ProjectFileIndexEntry>) => {
|
||||
if (!node.data) return;
|
||||
if (node.isFolder) {
|
||||
void localFileService.openLocalFileOrFolder(node.data.path, true);
|
||||
return;
|
||||
}
|
||||
openLocalFile({ filePath: node.data.path, workingDirectory });
|
||||
},
|
||||
[openLocalFile, workingDirectory],
|
||||
);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(node: ExplorerTreeNode<ProjectFileIndexEntry>) => {
|
||||
if (node.isFolder) return;
|
||||
openNode(node);
|
||||
},
|
||||
[openNode],
|
||||
);
|
||||
|
||||
const getContextMenuItems = useCallback(
|
||||
(node: ExplorerTreeNode<ProjectFileIndexEntry>): MenuProps['items'] => {
|
||||
if (!node.data) return [];
|
||||
|
||||
const { path, relativePath } = node.data;
|
||||
const isDirty = dirtyFilePaths.has(relativePath);
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'open',
|
||||
label: t('workingPanel.files.open'),
|
||||
onClick: () => openNode(node),
|
||||
},
|
||||
{ key: 'divider-reveal', type: 'divider' as const },
|
||||
{
|
||||
key: 'show-in-system',
|
||||
label: t('workingPanel.files.showInSystem'),
|
||||
onClick: () => void localFileService.openFileFolder(path),
|
||||
},
|
||||
...(isDirty
|
||||
? [
|
||||
{
|
||||
key: 'show-in-review',
|
||||
label: t('workingPanel.files.showInReview'),
|
||||
onClick: () => setWorkingSidebarTab('review'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: 'divider-copy', type: 'divider' as const },
|
||||
{
|
||||
key: 'copy-absolute-path',
|
||||
label: t('workingPanel.files.copyAbsolutePath'),
|
||||
onClick: async () => {
|
||||
await copyToClipboard(path);
|
||||
message.success(t('workingPanel.review.copied'));
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'copy-relative-path',
|
||||
label: t('workingPanel.files.copyRelativePath'),
|
||||
onClick: async () => {
|
||||
await copyToClipboard(relativePath);
|
||||
message.success(t('workingPanel.review.copied'));
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
[dirtyFilePaths, openNode, setWorkingSidebarTab, t],
|
||||
);
|
||||
|
||||
const fileCount = data?.totalCount ?? entries.filter((e) => !e.isDirectory).length;
|
||||
const isEmpty = nodes.length === 0;
|
||||
|
||||
if (!data && isLoading) {
|
||||
return (
|
||||
<Center flex={1}>
|
||||
<NeuralNetworkLoading size={48} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox height={'100%'} style={{ overflow: 'hidden' }} width={'100%'}>
|
||||
<div className={styles.subheader}>
|
||||
<span className={styles.count}>{t('workingPanel.files.count', { count: fileCount })}</span>
|
||||
<ActionIcon
|
||||
icon={RefreshCwIcon}
|
||||
loading={isValidating}
|
||||
size={'small'}
|
||||
title={t('workingPanel.files.refresh')}
|
||||
onClick={() => void mutate()}
|
||||
/>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty description={t('workingPanel.files.empty')} icon={FileIcon} />
|
||||
</Center>
|
||||
) : (
|
||||
<div className={styles.tree}>
|
||||
<ExplorerTree<ProjectFileIndexEntry>
|
||||
iconsColored
|
||||
defaultExpandedIds={defaultExpandedIds}
|
||||
density="compact"
|
||||
getContextMenuItems={getContextMenuItems}
|
||||
gitStatus={gitStatus}
|
||||
iconSet="complete"
|
||||
nodes={nodes}
|
||||
ref={treeRef}
|
||||
style={{ height: '100%' }}
|
||||
unsafeCSS={FOLDER_ICON_CSS}
|
||||
onExpandedChange={setExpandedIds}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
Files.displayName = 'AgentWorkingSidebarFiles';
|
||||
|
||||
export default Files;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import type { GitWorkingTreeFiles } from '@lobechat/electron-client-ipc';
|
||||
import type { GitStatusEntry } from '@pierre/trees';
|
||||
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { electronGitService } from '@/services/electron/git';
|
||||
|
||||
export const buildGitStatusEntries = (files: GitWorkingTreeFiles | undefined): GitStatusEntry[] => {
|
||||
if (!files) return [];
|
||||
|
||||
return [
|
||||
...files.added.map((path) => ({ path, status: 'added' }) as const),
|
||||
...files.modified.map((path) => ({ path, status: 'modified' }) as const),
|
||||
...files.deleted.map((path) => ({ path, status: 'deleted' }) as const),
|
||||
];
|
||||
};
|
||||
|
||||
export const useGitWorkingTreeFiles = (dirPath: string | undefined, enabled: boolean) => {
|
||||
const key = isDesktop && dirPath && enabled ? ['git-working-tree-files', dirPath] : null;
|
||||
|
||||
return useClientDataSWR<GitWorkingTreeFiles>(
|
||||
key,
|
||||
() => electronGitService.getGitWorkingTreeFiles(dirPath!),
|
||||
{
|
||||
focusThrottleInterval: 5 * 1000,
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import type { ProjectFileIndexResult } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
|
||||
export const useProjectFiles = (dirPath: string | undefined) => {
|
||||
const enabled = isDesktop && Boolean(dirPath);
|
||||
const key = enabled ? ['project-file-index', dirPath] : null;
|
||||
|
||||
return useClientDataSWR<ProjectFileIndexResult>(
|
||||
key,
|
||||
() => localFileService.getProjectFileIndex({ scope: dirPath! }),
|
||||
{
|
||||
focusThrottleInterval: 30 * 1000,
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -11,7 +11,7 @@ const ResourcesSection = memo(() => {
|
||||
data-testid="workspace-resources"
|
||||
flex={1}
|
||||
paddingBlock={8}
|
||||
paddingInline={16}
|
||||
paddingInline={'8px 12px'}
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<AgentDocumentsGroup style={{ flex: 1, minHeight: 0 }} viewMode={'list'} />
|
||||
|
||||
@@ -1,51 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import type { GitFileDiffStatus } from '@lobechat/electron-client-ipc';
|
||||
import { ActionIcon, copyToClipboard, PatchDiff } from '@lobehub/ui';
|
||||
import { ActionIcon, copyToClipboard, Flexbox, PatchDiff } from '@lobehub/ui';
|
||||
import { Popconfirm } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { CopyIcon, Undo2Icon } from 'lucide-react';
|
||||
import { CopyIcon, LocateFixedIcon, Undo2Icon } from 'lucide-react';
|
||||
import path from 'path-browserify-esm';
|
||||
import { memo, type MouseEvent, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { message } from '@/components/AntdStaticMethods';
|
||||
import { electronGitService } from '@/services/electron/git';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
additions: css`
|
||||
color: ${cssVar.colorSuccess};
|
||||
`,
|
||||
// Hover-revealed row actions (copy / revert). Hidden until the row is
|
||||
// hovered so the long file list stays visually quiet. While the revert
|
||||
// Popconfirm is open we force-keep them visible — otherwise moving the
|
||||
// cursor over the popover collapses the icons and the trigger jumps.
|
||||
// Hover-revealed row actions, anchored to the right edge with a gradient
|
||||
// mask that fades in from transparent → row hover-bg so any path/stats
|
||||
// text behind the icons softly disappears instead of being abruptly
|
||||
// overlapped. `:has(data-force-visible='true')` keeps the actions
|
||||
// up while a revert Popconfirm is open — otherwise the trigger would
|
||||
// collapse as soon as the cursor entered the popover.
|
||||
actions: css`
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-end: -8px;
|
||||
|
||||
align-items: center;
|
||||
|
||||
padding-inline: 28px 0;
|
||||
|
||||
opacity: 0;
|
||||
background:
|
||||
linear-gradient(to right, transparent 0, ${cssVar.colorFillTertiary} 28px),
|
||||
linear-gradient(to right, transparent 0, ${cssVar.colorBgContainer} 28px);
|
||||
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:has([data-force-visible='true']),
|
||||
[data-review-row]:hover & {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
rowAction: css`
|
||||
flex: none;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&[data-force-visible='true'],
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ant-collapse-header:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
revertDanger: css`
|
||||
&:hover {
|
||||
color: ${cssVar.colorError};
|
||||
}
|
||||
`,
|
||||
// Pushes the revert trigger to the right edge of the header so it sits
|
||||
// next to the Collapse chevron, visually separating the destructive action
|
||||
// from the path-related copy icon.
|
||||
revertWrapper: css`
|
||||
margin-inline-start: auto;
|
||||
`,
|
||||
deletions: css`
|
||||
color: ${cssVar.colorError};
|
||||
`,
|
||||
@@ -76,6 +86,8 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
white-space: nowrap;
|
||||
`,
|
||||
header: css`
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
@@ -119,6 +131,7 @@ interface FileItemHeaderProps {
|
||||
export const FileItemHeader = memo<FileItemHeaderProps>(
|
||||
({ filePath, additions, deletions, revertContext, onReverted }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const revealInFilesTab = useGlobalStore((s) => s.revealInFilesTab);
|
||||
|
||||
const lastSlash = filePath.lastIndexOf('/');
|
||||
const dir = lastSlash >= 0 ? filePath.slice(0, lastSlash + 1) : '';
|
||||
@@ -134,6 +147,14 @@ export const FileItemHeader = memo<FileItemHeaderProps>(
|
||||
[filePath, t],
|
||||
);
|
||||
|
||||
const handleReveal = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
revealInFilesTab(filePath);
|
||||
},
|
||||
[filePath, revealInFilesTab],
|
||||
);
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [reverting, setReverting] = useState(false);
|
||||
|
||||
@@ -168,7 +189,7 @@ export const FileItemHeader = memo<FileItemHeaderProps>(
|
||||
}, [filePath, onReverted, revertContext, t]);
|
||||
|
||||
return (
|
||||
<span className={styles.header}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.pathWrapper} title={filePath}>
|
||||
{dir && (
|
||||
// bdi keeps the dir's visual order LTR while the span is
|
||||
@@ -184,42 +205,49 @@ export const FileItemHeader = memo<FileItemHeaderProps>(
|
||||
{additions > 0 && deletions > 0 && ' '}
|
||||
{deletions > 0 && <span className={styles.deletions}>-{deletions}</span>}
|
||||
</span>
|
||||
<ActionIcon
|
||||
className={styles.rowAction}
|
||||
icon={CopyIcon}
|
||||
size={'small'}
|
||||
title={t('workingPanel.review.copyPath')}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
{revertContext && (
|
||||
<Popconfirm
|
||||
arrow={false}
|
||||
cancelText={t('workingPanel.review.revert.confirm.cancel')}
|
||||
description={t('workingPanel.review.revert.confirm.description', { filePath })}
|
||||
okButtonProps={{ danger: true, loading: reverting, type: 'primary' }}
|
||||
okText={t('workingPanel.review.revert.confirm.ok')}
|
||||
// Controlled open + onOpenChange lets antd handle outside-click /
|
||||
// Esc to close while we still drive the click-to-open via the
|
||||
// wrapper's stopPropagation (so the Collapse row doesn't toggle).
|
||||
open={confirmOpen}
|
||||
placement={'bottomRight'}
|
||||
title={t('workingPanel.review.revert.confirm.title')}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={handleConfirmRevert}
|
||||
onOpenChange={setConfirmOpen}
|
||||
>
|
||||
<span className={styles.revertWrapper} onClick={(event) => event.stopPropagation()}>
|
||||
<ActionIcon
|
||||
className={`${styles.rowAction} ${styles.revertDanger}`}
|
||||
data-force-visible={confirmOpen}
|
||||
icon={Undo2Icon}
|
||||
size={'small'}
|
||||
title={t('workingPanel.review.revert')}
|
||||
/>
|
||||
</span>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</span>
|
||||
<Flexbox horizontal align={'center'} className={styles.actions} gap={2}>
|
||||
<ActionIcon
|
||||
className={styles.rowAction}
|
||||
icon={CopyIcon}
|
||||
size={'small'}
|
||||
title={t('workingPanel.review.copyPath')}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<ActionIcon
|
||||
className={styles.rowAction}
|
||||
data-testid="reveal-in-tree"
|
||||
icon={LocateFixedIcon}
|
||||
size={'small'}
|
||||
title={t('workingPanel.review.revealInTree')}
|
||||
onClick={handleReveal}
|
||||
/>
|
||||
{revertContext && (
|
||||
<Popconfirm
|
||||
arrow={false}
|
||||
cancelText={t('workingPanel.review.revert.confirm.cancel')}
|
||||
description={t('workingPanel.review.revert.confirm.description', { filePath })}
|
||||
okButtonProps={{ danger: true, loading: reverting, type: 'primary' }}
|
||||
okText={t('workingPanel.review.revert.confirm.ok')}
|
||||
open={confirmOpen}
|
||||
placement={'bottomRight'}
|
||||
title={t('workingPanel.review.revert.confirm.title')}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={handleConfirmRevert}
|
||||
onOpenChange={setConfirmOpen}
|
||||
>
|
||||
<span onClick={(event) => event.stopPropagation()}>
|
||||
<ActionIcon
|
||||
className={`${styles.rowAction} ${styles.revertDanger}`}
|
||||
data-force-visible={confirmOpen}
|
||||
icon={Undo2Icon}
|
||||
size={'small'}
|
||||
title={t('workingPanel.review.revert')}
|
||||
/>
|
||||
</span>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Flexbox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FileItemHeader } from '../FileItem';
|
||||
|
||||
const mockRevealInFilesTab = vi.fn();
|
||||
|
||||
vi.mock('@/store/global', () => ({
|
||||
useGlobalStore: (selector: (s: any) => any) =>
|
||||
selector({ revealInFilesTab: mockRevealInFilesTab }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/AntdStaticMethods', () => ({
|
||||
message: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/services/electron/git', () => ({
|
||||
electronGitService: { revertGitFile: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('FileItemHeader — reveal in tree', () => {
|
||||
it('renders the reveal button', () => {
|
||||
render(<FileItemHeader additions={1} deletions={0} filePath="src/foo.ts" status="modified" />);
|
||||
|
||||
const btn = screen.getByTestId('reveal-in-tree');
|
||||
expect(btn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls revealInFilesTab with the file path on click', () => {
|
||||
render(<FileItemHeader additions={1} deletions={0} filePath="src/foo.ts" status="modified" />);
|
||||
|
||||
const btn = screen.getByTestId('reveal-in-tree');
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(mockRevealInFilesTab).toHaveBeenCalledWith('src/foo.ts');
|
||||
});
|
||||
|
||||
it('stops event propagation so parent onClick is not triggered', () => {
|
||||
const parentSpy = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={parentSpy}>
|
||||
<FileItemHeader additions={1} deletions={0} filePath="src/foo.ts" status="modified" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const btn = screen.getByTestId('reveal-in-tree');
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(parentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Center,
|
||||
Collapse,
|
||||
type DropdownItem,
|
||||
DropdownMenu,
|
||||
Empty,
|
||||
Flexbox,
|
||||
} from '@lobehub/ui';
|
||||
import { ActionIcon, Center, type DropdownItem, DropdownMenu, Empty, Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
@@ -25,7 +17,8 @@ import {
|
||||
WholeWordIcon,
|
||||
WrapTextIcon,
|
||||
} from 'lucide-react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { type KeyboardEvent, memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
|
||||
@@ -73,30 +66,49 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
`,
|
||||
// Skip layout/paint of off-screen file panels. Each panel still mounts
|
||||
// (so React/Shiki state is preserved across scroll), but the browser
|
||||
// short-circuits its layout & paint until it scrolls near the viewport.
|
||||
// Crucial for repos with many large diffs where ~38+ panels were
|
||||
// previously locking the scroll thread on every frame.
|
||||
list: css`
|
||||
& :where(.ant-collapse-item) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 56px;
|
||||
border-block: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
item: css`
|
||||
/* Skip layout/paint of off-screen rows. Preserved from the previous
|
||||
implementation. */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 32px;
|
||||
|
||||
& + & {
|
||||
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
|
||||
}
|
||||
`,
|
||||
row: css`
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
padding-block: 5px;
|
||||
padding-inline: 10px;
|
||||
|
||||
transition: background 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
|
||||
/* antd v6 renders the label slot as .ant-collapse-title (was
|
||||
.ant-collapse-header-text in v4/v5). When collapsible is 'header'
|
||||
(the @lobehub/ui Collapse default), antd applies a (0,4,0) rule
|
||||
on .ant-collapse .ant-item .ant-collapsible-header .ant-title
|
||||
that locks flex to 0 0 auto — long paths then push stats and
|
||||
chevron off-screen instead of triggering ellipsis on .path. Our
|
||||
parent-className selector is only (0,3,0), so we !important to win.
|
||||
Verified via getComputedStyle on a real row: without !important the
|
||||
title resolves to flex: 0 0 auto; with it, flex: 1 1 0%. */
|
||||
& .ant-collapse-collapsible-header .ant-collapse-title {
|
||||
overflow: hidden !important;
|
||||
flex: 1 1 0 !important;
|
||||
min-width: 0 !important;
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${cssVar.colorPrimary};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
`,
|
||||
chevron: css`
|
||||
flex: none;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
transition: transform 0.2s;
|
||||
|
||||
&[data-expanded='true'] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
`,
|
||||
arrow: css`
|
||||
@@ -269,37 +281,6 @@ const Review = memo<ReviewProps>(({ workingDirectory }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const items = patches.map((entry) => {
|
||||
const key = itemKey(entry);
|
||||
return {
|
||||
children: (
|
||||
<FileItemBody
|
||||
expanded={activeKeys.includes(key)}
|
||||
filePath={entry.filePath}
|
||||
isBinary={entry.isBinary}
|
||||
patch={entry.patch}
|
||||
textDiff={textDiff}
|
||||
truncated={entry.truncated}
|
||||
viewMode={viewMode}
|
||||
wordWrap={wordWrap}
|
||||
/>
|
||||
),
|
||||
key,
|
||||
label: (
|
||||
<FileItemHeader
|
||||
additions={entry.additions}
|
||||
deletions={entry.deletions}
|
||||
filePath={entry.filePath}
|
||||
onReverted={() => void mutate()}
|
||||
// Revert is only meaningful for working-tree changes; in branch-diff
|
||||
// mode there's nothing to "discard" on the file system.
|
||||
revertContext={mode === 'unstaged' ? { workingDirectory } : undefined}
|
||||
status={entry.status}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const allExpanded = patches.length > 0 && activeKeys.length === patches.length;
|
||||
const handleToggleAll = () => {
|
||||
setActiveKeys(allExpanded ? [] : patches.map(itemKey));
|
||||
@@ -481,31 +462,74 @@ const Review = memo<ReviewProps>(({ workingDirectory }) => {
|
||||
<Empty description={emptyText} icon={GitCompareIcon} />
|
||||
</Center>
|
||||
) : (
|
||||
<Flexbox
|
||||
className={styles.list}
|
||||
gap={6}
|
||||
paddingInline={8}
|
||||
style={{ overflow: 'auto' }}
|
||||
width={'100%'}
|
||||
>
|
||||
<Collapse
|
||||
activeKey={activeKeys}
|
||||
expandIconPlacement={'end'}
|
||||
items={items}
|
||||
padding={{ body: 0, header: '6px 12px' }}
|
||||
variant={'outlined'}
|
||||
expandIcon={({ isActive }) => (
|
||||
<ChevronRightIcon
|
||||
size={14}
|
||||
style={{
|
||||
color: 'var(--ant-color-text-tertiary)',
|
||||
transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onChange={(next) => setActiveKeys(Array.isArray(next) ? next : [next])}
|
||||
/>
|
||||
<Flexbox className={styles.list} style={{ overflow: 'auto' }} width={'100%'}>
|
||||
{patches.map((entry) => {
|
||||
const key = itemKey(entry);
|
||||
const expanded = activeKeys.includes(key);
|
||||
const toggle = () =>
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.item} key={key}>
|
||||
<div
|
||||
data-review-row
|
||||
aria-expanded={expanded}
|
||||
className={styles.row}
|
||||
role={'button'}
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={styles.chevron}
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
size={14}
|
||||
/>
|
||||
<FileItemHeader
|
||||
additions={entry.additions}
|
||||
deletions={entry.deletions}
|
||||
filePath={entry.filePath}
|
||||
revertContext={mode === 'unstaged' ? { workingDirectory } : undefined}
|
||||
status={entry.status}
|
||||
onReverted={() => void mutate()}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<m.div
|
||||
animate={'open'}
|
||||
exit={'collapsed'}
|
||||
initial={'collapsed'}
|
||||
style={{ overflow: 'hidden' }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
variants={{
|
||||
collapsed: { height: 0, opacity: 0 },
|
||||
open: { height: 'auto', opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<FileItemBody
|
||||
expanded
|
||||
filePath={entry.filePath}
|
||||
isBinary={entry.isBinary}
|
||||
patch={entry.patch}
|
||||
textDiff={textDiff}
|
||||
truncated={entry.truncated}
|
||||
viewMode={viewMode}
|
||||
wordWrap={wordWrap}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
@@ -30,6 +30,12 @@ vi.mock('./Review', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./Files', () => ({
|
||||
default: ({ workingDirectory }: { workingDirectory: string }) => (
|
||||
<div data-testid="files-panel">{workingDirectory}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ChatInput/RuntimeConfig/useRepoType', () => ({
|
||||
useRepoType: (path?: string) => (path ? mocks.repoType : undefined),
|
||||
}));
|
||||
@@ -155,6 +161,12 @@ vi.mock('@/store/agent/selectors', () => ({
|
||||
(state: { agentWorkingDirectoryById?: Record<string, string | undefined> }) =>
|
||||
state.agentWorkingDirectoryById?.[agentId],
|
||||
},
|
||||
chatConfigByIdSelectors: {
|
||||
isLocalSystemEnabledById:
|
||||
(_agentId: string) =>
|
||||
(_state: Record<string, unknown>) =>
|
||||
true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/chat', () => ({
|
||||
|
||||
@@ -8,11 +8,12 @@ import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@/const/layoutTokens';
|
||||
import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType';
|
||||
import RightPanel from '@/features/RightPanel';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
import Files from './Files';
|
||||
import ProgressSection from './ProgressSection';
|
||||
import ResourcesSection from './ResourcesSection';
|
||||
import Review from './Review';
|
||||
@@ -66,7 +67,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
`,
|
||||
}));
|
||||
|
||||
type Tab = 'review' | 'resources';
|
||||
type Tab = 'review' | 'resources' | 'files';
|
||||
|
||||
const AgentWorkingSidebar = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
@@ -78,13 +79,26 @@ const AgentWorkingSidebar = memo(() => {
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
activeAgentId ? agentByIdSelectors.getAgentWorkingDirectoryById(activeAgentId)(s) : undefined,
|
||||
);
|
||||
const isLocalSystemEnabled = useAgentStore((s) =>
|
||||
activeAgentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(activeAgentId)(s) : false,
|
||||
);
|
||||
const workingDirectory = topicWorkingDirectory || agentWorkingDirectory;
|
||||
const repoType = useRepoType(workingDirectory);
|
||||
|
||||
const reviewAvailable = !!workingDirectory && !!repoType;
|
||||
// Topic metadata is preferred for resuming a coding session, but Review is
|
||||
// project-scoped and should also work before a topic has bound metadata.
|
||||
const activeTab: Tab = reviewAvailable ? (storedTab ?? 'review') : 'resources';
|
||||
const filesAvailable = isLocalSystemEnabled && !!workingDirectory;
|
||||
const reviewAvailable = isLocalSystemEnabled && !!workingDirectory && !!repoType;
|
||||
// Topic metadata is preferred for resuming a coding session, but Review and
|
||||
// Files are project-scoped and should also work before a topic has bound
|
||||
// metadata. Fall back to a still-visible tab when the stored choice is gone.
|
||||
const resolveActiveTab = (): Tab => {
|
||||
if (storedTab === 'review' && reviewAvailable) return 'review';
|
||||
if (storedTab === 'files' && filesAvailable) return 'files';
|
||||
if (storedTab === 'resources') return 'resources';
|
||||
if (reviewAvailable) return 'review';
|
||||
if (filesAvailable) return 'files';
|
||||
return 'resources';
|
||||
};
|
||||
const activeTab: Tab = resolveActiveTab();
|
||||
|
||||
return (
|
||||
<RightPanel stableLayout defaultWidth={360} maxWidth={720} minWidth={300}>
|
||||
@@ -97,7 +111,7 @@ const AgentWorkingSidebar = memo(() => {
|
||||
justify={'space-between'}
|
||||
paddingInline={4}
|
||||
>
|
||||
{reviewAvailable ? (
|
||||
{reviewAvailable || filesAvailable ? (
|
||||
<div className={styles.tabs}>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'resources' ? styles.tabActive : ''}`}
|
||||
@@ -106,16 +120,29 @@ const AgentWorkingSidebar = memo(() => {
|
||||
>
|
||||
{t('workingPanel.space')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'review' ? styles.tabActive : ''}`}
|
||||
type="button"
|
||||
onClick={() => setWorkingSidebarTab('review')}
|
||||
>
|
||||
{t('workingPanel.review.title')}
|
||||
</button>
|
||||
{reviewAvailable && (
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'review' ? styles.tabActive : ''}`}
|
||||
type="button"
|
||||
onClick={() => setWorkingSidebarTab('review')}
|
||||
>
|
||||
{t('workingPanel.review.title')}
|
||||
</button>
|
||||
)}
|
||||
{filesAvailable && (
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'files' ? styles.tabActive : ''}`}
|
||||
type="button"
|
||||
onClick={() => setWorkingSidebarTab('files')}
|
||||
>
|
||||
{t('workingPanel.files.title')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Text strong>{t('workingPanel.space')}</Text>
|
||||
<Flexbox paddingInline={8}>
|
||||
<Text strong>{t('workingPanel.space')}</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
@@ -129,6 +156,11 @@ const AgentWorkingSidebar = memo(() => {
|
||||
<Review workingDirectory={workingDirectory} />
|
||||
</Flexbox>
|
||||
)}
|
||||
{filesAvailable && (
|
||||
<Flexbox className={activeTab === 'files' ? styles.pane : styles.paneHidden}>
|
||||
<Files workingDirectory={workingDirectory} />
|
||||
</Flexbox>
|
||||
)}
|
||||
<Flexbox
|
||||
className={activeTab === 'resources' ? styles.pane : styles.paneHidden}
|
||||
gap={8}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
type DetectAppsResult,
|
||||
type OpenInAppParams,
|
||||
type OpenInAppResult,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
/**
|
||||
* Service class for interacting with Electron's "Open in App" capabilities,
|
||||
* which detects installed editors / file managers / terminals and launches
|
||||
* them against a working directory.
|
||||
*/
|
||||
class ElectronOpenInAppService {
|
||||
private get ipc() {
|
||||
return ensureElectronIpc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which supported apps are installed on the current platform.
|
||||
* The main process caches results for the lifetime of the Electron main process.
|
||||
*/
|
||||
async detectApps(): Promise<DetectAppsResult> {
|
||||
return this.ipc.openInApp.detectApps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the given app with `path` as its target (typically the agent
|
||||
* working directory).
|
||||
*/
|
||||
async openInApp(params: OpenInAppParams): Promise<OpenInAppResult> {
|
||||
return this.ipc.openInApp.openInApp(params);
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance of the service
|
||||
export const electronOpenInAppService = new ElectronOpenInAppService();
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ChatStoreState>);
|
||||
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<ChatStoreState>);
|
||||
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<ChatStoreState>);
|
||||
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<ChatStoreState>);
|
||||
expect(chatPortalSelectors.localFileWorkingDirectory(state)).toBe('/path/to');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openLocalFiles', () => {
|
||||
it('should return empty array when openLocalFiles is empty', () => {
|
||||
const state = createState({ openLocalFiles: [] } as Partial<ChatStoreState>);
|
||||
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<ChatStoreState>);
|
||||
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<ChatStoreState>);
|
||||
expect(chatPortalSelectors.activeLocalFilePath(state)).toBe('/path/a.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifactMessageContent', () => {
|
||||
it('should return empty string when message not found', () => {
|
||||
const state = createState();
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,6 +273,7 @@ export class GlobalGeneralActionImpl {
|
||||
...status,
|
||||
showCommandMenu: false,
|
||||
showHotkeyHelper: false,
|
||||
workingSidebarRevealRequest: undefined,
|
||||
};
|
||||
|
||||
this.#get().updateSystemStatus(statusWithResetTransientStates, 'initSystemStatus');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user