Compare commits

...

10 Commits

Author SHA1 Message Date
Innei 36b842bd5f 🐛 fix: remove unnecessary revealNotFound toast and rely on effect re-run for retry 2026-05-14 01:23:08 +08:00
Innei 3a2b118039 🐛 fix(test): add missing chatConfigByIdSelectors mock to WorkingSidebar test 2026-05-14 01:04:04 +08:00
Innei 0b1ffe29e9 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.
2026-05-14 00:37:06 +08:00
Innei 0dcbc51884 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
2026-05-13 23:08:22 +08:00
Innei 4ad8c565e9 🐛 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.
2026-05-13 15:20:56 +08:00
Innei 59cda17510 🐛 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.
2026-05-13 15:20:47 +08:00
Innei 6a9df1c864 Merge branch 'canary' into worktree-hetero-agent-files-tab-clean 2026-05-13 15:13:13 +08:00
Innei 598102181d 🐛 fix: address codex review feedback (P1 reveal retry + P2 WebStorm Windows detection) 2026-05-13 02:11:20 +08:00
Innei e584b11f93 🐛 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.
2026-05-13 01:45:24 +08:00
Innei 9dbbf4c9c2 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.
2026-05-13 01:26:32 +08:00
77 changed files with 5328 additions and 253 deletions
+70 -70
View File
@@ -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) |
+3
View File
@@ -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,
+8
View File
@@ -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',
};
+24
View File
@@ -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];
+7
View File
@@ -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.",
+8
View File
@@ -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}}"
}
+7
View File
@@ -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}} 的工作区变更将被永久丢弃,未跟踪的新文件会从磁盘删除。",
+8
View File
@@ -0,0 +1,8 @@
{
"dropdownLabel": "用以下应用打开当前目录",
"errors.appNotInstalled": "未检测到 {{appName}}",
"errors.launchFailed": "{{appName}} 打开失败:{{error}}",
"errors.pathNotFound": "路径不存在:{{path}}",
"errors.unknown": "未知错误",
"tooltip": "用 {{appName}} 打开"
}
+1 -1
View File
@@ -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",
+1
View File
@@ -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';
+41
View File
@@ -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);
});
});
+38
View File
@@ -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;
}
+3
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export { FOLDER_ICON_CSS } from './folderIconStyle';
export type {
ExplorerTreeCanDropCtx,
ExplorerTreeHandle,
+4 -1
View File
@@ -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(() => {
+65
View File
@@ -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;
};
+230
View File
@@ -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();
});
});
+148
View File
@@ -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);
};
+206
View File
@@ -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;
+70
View File
@@ -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;
+197
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
const Title = () => null;
export default Title;
+10
View File
@@ -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,
};
+16 -13
View File
@@ -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: {
+2
View File
@@ -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,
+19
View File
@@ -931,6 +931,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',
@@ -939,6 +956,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':
+2
View File
@@ -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,
+8
View File
@@ -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>
@@ -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();
});
});
@@ -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,276 @@
'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 — the effect re-runs when nodes/isLoading
// change, so the reveal will be retried automatically once data arrives.
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;
@@ -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,
},
);
};
@@ -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>
);
},
);
@@ -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}
+37
View File
@@ -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();
+306
View File
@@ -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());
+97
View File
@@ -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();
+26
View File
@@ -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,
+63
View File
@@ -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();
});
});
});
+1
View File
@@ -273,6 +273,7 @@ export class GlobalGeneralActionImpl {
...status,
showCommandMenu: false,
showHotkeyHelper: false,
workingSidebarRevealRequest: undefined,
};
this.#get().updateSystemStatus(statusWithResetTransientStates, 'initSystemStatus');
+9 -1
View File
@@ -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;
+2 -1
View File
@@ -267,12 +267,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,