feat(file-preview): support remote read-only local previews (#15673)

*  feat(file-preview): support remote read-only local previews

*  feat(local-file): identify tabs by context

* ♻️ refactor(file-preview): route previews through project file service

* 🐛 fix(desktop): clamp nav panel width

*  feat(file-preview): improve local preview controls

* 🐛 fix(file-preview): reload html after refresh completes
This commit is contained in:
Arvin Xu
2026-06-11 15:10:25 +08:00
committed by GitHub
parent 97e4e345d1
commit b76992e581
32 changed files with 1342 additions and 253 deletions
@@ -17,6 +17,7 @@ import type {
KillCommandParams,
ListLocalFileParams,
ListProjectSkillsParams,
LocalFilePreviewUrlParams,
LocalReadFileParams,
LocalReadFilesParams,
LocalSearchFilesParams,
@@ -408,6 +409,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
}
case 'getLocalFilePreview': {
return this.localFileCtr.getLocalFilePreview(params as LocalFilePreviewUrlParams);
}
case 'listProjectSkills': {
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
}
@@ -12,6 +12,7 @@ import {
type GrepContentParams,
type GrepContentResult,
type ListLocalFileParams,
type LocalFilePreviewResult,
type LocalFilePreviewUrlParams,
type LocalFilePreviewUrlResult,
type LocalMoveFilesResultItem,
@@ -65,6 +66,19 @@ const logger = createLogger('controllers:LocalFileCtr');
const SAFE_PATH_PREFIXES = ['/tmp', '/var/tmp'] as const;
const TEXT_PREVIEW_MIME_TYPES = new Set([
'application/graphql',
'application/javascript',
'application/json',
'application/markdown',
'application/toml',
'application/xml',
'application/yaml',
'text/markdown',
'text/mdx',
'text/x-markdown',
]);
const normalizeAbsolutePath = (inputPath: string): string =>
path.normalize(path.isAbsolute(inputPath) ? inputPath : `/${inputPath}`);
@@ -91,6 +105,48 @@ const resolveNearestExistingRealPath = async (targetPath: string): Promise<strin
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const normalizeContentType = (contentType: string): string =>
contentType.split(';')[0].trim().toLowerCase();
const isTextPreviewMimeType = (mimeType: string): boolean =>
mimeType.startsWith('text/') || TEXT_PREVIEW_MIME_TYPES.has(mimeType);
const serializePreviewFile = ({
buffer,
contentType,
}: {
buffer: Buffer;
contentType: string;
}): NonNullable<LocalFilePreviewResult['preview']> => {
const normalizedContentType = normalizeContentType(contentType);
if (normalizedContentType.startsWith('image/')) {
return {
base64: buffer.toString('base64'),
contentType: normalizedContentType,
type: 'image',
};
}
if (isTextPreviewMimeType(normalizedContentType)) {
return {
content: buffer.toString('utf8'),
contentType: normalizedContentType,
type: 'text',
};
}
if (normalizedContentType === 'application/pdf') {
return { contentType: normalizedContentType, type: 'pdf' };
}
if (normalizedContentType.startsWith('video/')) {
return { contentType: normalizedContentType, type: 'video' };
}
return { contentType: normalizedContentType, type: 'binary' };
};
const createProjectFileEntry = (
root: string,
absolutePath: string,
@@ -401,6 +457,31 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@IpcMethod()
async getLocalFilePreview({
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
try {
const preview = await this.app.localFileProtocolManager.readPreviewFile({
filePath,
workspaceRoot: workingDirectory,
});
if (!preview) {
return { error: 'File is outside the approved workspace', success: false };
}
return {
preview: serializePreviewFile(preview),
success: true,
};
} catch (error) {
logger.error('Failed to read local file preview:', error);
return { error: (error as Error).message, success: false };
}
}
@IpcMethod()
async handlePrepareSkillDirectory({
forceRefresh,
@@ -1,3 +1,5 @@
import path from 'node:path';
import { zipSync } from 'fflate';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -88,6 +90,7 @@ const mockLocalFileProtocolManager = {
approveIndexedProjectRoot: vi.fn(),
approveProjectRootFromScope: vi.fn(),
createPreviewUrl: vi.fn(),
readPreviewFile: vi.fn(),
};
// Mock makeSureDirExist
@@ -146,7 +149,6 @@ describe('LocalFileCtr', () => {
it('should expand a leading ~ to the user home directory', async () => {
const os = await import('node:os');
const path = await import('node:path');
vi.mocked(mockShell.openPath).mockResolvedValue('');
const result = await localFileCtr.handleOpenLocalFile({ path: '~/git/work/file.txt' });
@@ -171,7 +173,6 @@ describe('LocalFileCtr', () => {
it('should expand a leading ~ when opening a directory', async () => {
const os = await import('node:os');
const path = await import('node:path');
vi.mocked(mockShell.openPath).mockResolvedValue('');
const result = await localFileCtr.handleOpenLocalFolder({
@@ -248,6 +249,48 @@ describe('LocalFileCtr', () => {
});
});
describe('getLocalFilePreview', () => {
it('should return text preview content for an approved workspace file', async () => {
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
buffer: Buffer.from('const value = 1;'),
contentType: 'text/plain; charset=utf-8',
realPath: '/workspace/app.ts',
});
const result = await localFileCtr.getLocalFilePreview({
path: '/workspace/app.ts',
workingDirectory: '/workspace',
});
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
expect(result).toEqual({
preview: {
content: 'const value = 1;',
contentType: 'text/plain',
type: 'text',
},
success: true,
});
});
it('should reject preview payload creation outside an approved workspace', async () => {
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue(null);
const result = await localFileCtr.getLocalFilePreview({
path: '/Users/alice/.ssh/id_rsa',
workingDirectory: '/workspace',
});
expect(result).toEqual({
error: 'File is outside the approved workspace',
success: false,
});
});
});
describe('handleWriteFile', () => {
it('should write file successfully', async () => {
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
@@ -48,6 +48,12 @@ interface PreviewTokenRecord {
realPath: string;
}
export interface PreviewFileReadResult {
buffer: Buffer;
contentType: string;
realPath: string;
}
/**
* Custom `localfile://` protocol for project file previews.
*
@@ -214,36 +220,43 @@ export class LocalFileProtocolManager {
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
const normalizedWorkspaceRoot = normalizeAbsolutePath(workspaceRoot);
if (!normalizedFilePath || !normalizedWorkspaceRoot) return null;
if (!normalizedFilePath) return null;
const [realFilePath, realWorkspaceRoot] = await Promise.all([
realpath(normalizedFilePath),
realpath(normalizedWorkspaceRoot),
]);
const normalizedRealFilePath = normalizeAbsolutePath(realFilePath);
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
if (
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
) {
return null;
}
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
if (!realFilePath) return null;
this.cleanupExpiredTokens();
const token = randomUUID();
this.previewTokens.set(token, {
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
realPath: normalizedRealFilePath,
realPath: realFilePath,
});
return buildLocalFileUrl(normalizedFilePath, token);
}
async readPreviewFile({
filePath,
workspaceRoot,
}: {
filePath: string;
workspaceRoot: string;
}): Promise<PreviewFileReadResult | null> {
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
if (!realFilePath) return null;
const fileStat = await stat(realFilePath);
if (!fileStat.isFile()) return null;
const buffer = await readFile(realFilePath);
return {
buffer,
contentType: resolveLocalFileMimeType(realFilePath, buffer),
realPath: realFilePath,
};
}
/**
* Decode the URL pathname back into an absolute filesystem path.
*
@@ -283,6 +296,36 @@ export class LocalFileProtocolManager {
return normalized;
}
private async resolveApprovedPreviewPath({
filePath,
workspaceRoot,
}: {
filePath: string;
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
const normalizedWorkspaceRoot = normalizeAbsolutePath(workspaceRoot);
if (!normalizedFilePath || !normalizedWorkspaceRoot) return null;
const [realFilePath, realWorkspaceRoot] = await Promise.all([
realpath(normalizedFilePath),
realpath(normalizedWorkspaceRoot),
]);
const normalizedRealFilePath = normalizeAbsolutePath(realFilePath);
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
if (
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
) {
return null;
}
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
return normalizedRealFilePath;
}
private cleanupExpiredTokens() {
const now = Date.now();
for (const [token, record] of this.previewTokens) {
@@ -278,6 +278,37 @@ describe('LocalFileProtocolManager', () => {
expect(url).toContain('token=');
});
it('reads preview payloads only from approved project roots', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
mockReadFile.mockResolvedValue(Buffer.from('const value = 1;'));
const result = await manager.readPreviewFile({
filePath: '/Users/alice/project/App.tsx',
workspaceRoot: '/Users/alice/project',
});
expect(result).toEqual({
buffer: Buffer.from('const value = 1;'),
contentType: 'text/plain; charset=utf-8',
realPath: '/Users/alice/project/App.tsx',
});
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
});
it('does not read preview payloads outside the approved workspace root', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
const result = await manager.readPreviewFile({
filePath: '/Users/alice/.ssh/id_rsa',
workspaceRoot: '/Users/alice/project',
});
expect(result).toBeNull();
expect(mockReadFile).not.toHaveBeenCalled();
});
it('defers registration until app ready when not yet ready', async () => {
mockApp.isReady.mockReturnValue(false);
let resolveReady: () => void = () => undefined;
+15
View File
@@ -270,6 +270,21 @@ export const deviceRouter = router({
return result ?? null;
}),
/**
* Read-only local file preview for a file on a remote device. The web client
* receives render data, not a `localfile://` URL; saving remains unsupported.
*/
getLocalFilePreview: deviceProcedure
.input(z.object({ deviceId: z.string(), path: z.string(), workingDirectory: z.string() }))
.query(async ({ ctx, input }) =>
deviceGateway.getLocalFilePreview({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
}),
),
/**
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
* remote device, via the device's `listProjectSkills` RPC. Powers the
@@ -674,6 +674,71 @@ describe('DeviceGateway', () => {
});
});
describe('getLocalFilePreview', () => {
const configure = () => {
mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com';
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
};
it('returns an error result when not configured', async () => {
const proxy = new DeviceGateway();
const result = await proxy.getLocalFilePreview({
deviceId: 'dev-1',
path: '/proj/App.tsx',
userId: 'user-1',
workingDirectory: '/proj',
});
expect(result).toEqual({ error: 'Device gateway not configured', success: false });
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
});
it('passes the device preview result through and invokes the rpc', async () => {
configure();
const data = {
preview: {
content: 'const value = 1;',
contentType: 'text/plain',
type: 'text',
},
success: true,
};
mockClient.invokeRpc.mockResolvedValue({ data, success: true });
const proxy = new DeviceGateway();
const result = await proxy.getLocalFilePreview({
deviceId: 'dev-1',
path: '/proj/App.tsx',
userId: 'user-1',
workingDirectory: '/proj',
});
expect(result).toEqual(data);
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
{
method: 'getLocalFilePreview',
params: { path: '/proj/App.tsx', workingDirectory: '/proj' },
},
);
});
it('returns an error result when the rpc reports failure', async () => {
configure();
mockClient.invokeRpc.mockResolvedValue({ error: 'offline', success: false });
const proxy = new DeviceGateway();
const result = await proxy.getLocalFilePreview({
deviceId: 'dev-1',
path: '/proj/App.tsx',
userId: 'user-1',
workingDirectory: '/proj',
});
expect(result).toEqual({ error: 'offline', success: false });
});
});
describe('getClient (lazy initialization)', () => {
it('should return null when URL is missing', async () => {
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
@@ -22,6 +22,7 @@ import type {
DeviceGitWorkingTreePatches,
DeviceGitWorkingTreeStatus,
DeviceListProjectSkillsResult,
DeviceLocalFilePreviewResult,
DeviceProjectFileIndexResult,
ProjectSkillMeta,
WorkspaceInitResult,
@@ -466,6 +467,40 @@ export class DeviceGateway {
}
}
/**
* Read a preview payload for a file on a remote device. This is read-only and
* deliberately mirrors the desktop local-file preview contract without
* exposing a `localfile://` URL to web callers.
*/
async getLocalFilePreview(params: {
deviceId: string;
path: string;
timeout?: number;
userId: string;
workingDirectory: string;
}): Promise<DeviceLocalFilePreviewResult> {
const { userId, deviceId, path, workingDirectory, timeout = 30_000 } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceLocalFilePreviewResult>(
{ deviceId, timeout, userId },
{ method: 'getLocalFilePreview', params: { path, workingDirectory } },
);
if (!result.success || !result.data) {
log('getLocalFilePreview: failed for deviceId=%s — %s', deviceId, result.error);
return { error: result.error || 'Failed to load local file preview', success: false };
}
return result.data;
} catch (error) {
log('getLocalFilePreview: error for deviceId=%s — %O', deviceId, error);
return { error: (error as Error).message, success: false };
}
}
/**
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
* remote device via the `listProjectSkills` device RPC — the Resources tab's
+1
View File
@@ -1008,6 +1008,7 @@
"workingPanel.localFile.closeRight": "Close to the Right",
"workingPanel.localFile.error": "Couldn't load this file",
"workingPanel.localFile.preview.raw": "Raw",
"workingPanel.localFile.preview.reload": "Reload preview",
"workingPanel.localFile.preview.render": "Preview",
"workingPanel.localFile.preview.source": "Source",
"workingPanel.localFile.truncated": "File preview truncated to {{limit}} characters",
+1
View File
@@ -1008,6 +1008,7 @@
"workingPanel.localFile.closeRight": "关闭右侧",
"workingPanel.localFile.error": "无法加载此文件",
"workingPanel.localFile.preview.raw": "原文",
"workingPanel.localFile.preview.reload": "刷新预览",
"workingPanel.localFile.preview.render": "预览",
"workingPanel.localFile.preview.source": "源码",
"workingPanel.localFile.truncated": "文件预览被截断至 {{limit}} 个字符",
@@ -122,6 +122,34 @@ export interface LocalFilePreviewUrlResult {
url?: string;
}
export interface LocalFilePreviewText {
content: string;
contentType: string;
type: 'text';
}
export interface LocalFilePreviewImage {
base64: string;
contentType: string;
type: 'image';
}
export interface LocalFilePreviewUnsupported {
contentType: string;
type: 'binary' | 'pdf' | 'video';
}
export type LocalFilePreview =
| LocalFilePreviewImage
| LocalFilePreviewText
| LocalFilePreviewUnsupported;
export interface LocalFilePreviewResult {
error?: string;
preview?: LocalFilePreview;
success: boolean;
}
export interface LocalReadFileResult {
/**
* Character count of the content within the specified `loc` range.
+1
View File
@@ -1110,6 +1110,7 @@ export default {
'workingPanel.localFile.closeRight': 'Close to the Right',
'workingPanel.localFile.error': "Couldn't load this file",
'workingPanel.localFile.preview.raw': 'Raw',
'workingPanel.localFile.preview.reload': 'Reload preview',
'workingPanel.localFile.preview.render': 'Preview',
'workingPanel.localFile.preview.source': 'Source',
'workingPanel.localFile.truncated': 'File preview truncated to {{limit}} characters',
+33
View File
@@ -304,6 +304,39 @@ export interface DeviceProjectFileIndexResult {
totalCount: number;
}
export interface DeviceLocalFilePreviewText {
content: string;
contentType: string;
type: 'text';
}
export interface DeviceLocalFilePreviewImage {
base64: string;
contentType: string;
type: 'image';
}
export interface DeviceLocalFilePreviewUnsupported {
contentType: string;
type: 'binary' | 'pdf' | 'video';
}
export type DeviceLocalFilePreview =
| DeviceLocalFilePreviewImage
| DeviceLocalFilePreviewText
| DeviceLocalFilePreviewUnsupported;
/**
* File preview payload for a file on a remote device. Mirrors the desktop local
* file preview result but carries binary image content as base64 so it can cross
* the Gateway/RPC boundary.
*/
export interface DeviceLocalFilePreviewResult {
error?: string;
preview?: DeviceLocalFilePreview;
success: boolean;
}
/**
* A single project skill (`.agents/skills` / `.claude/skills`) discovered on a
* remote device, returned by the `listProjectSkills` device RPC. Mirrors the
@@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useChatStore } from '@/store/chat';
import { createLocalFileTabId } from '@/store/chat/slices/portal/helpers';
import type { MarkdownElementProps } from '../type';
import Render from './Render';
@@ -112,6 +113,10 @@ describe('LocalFileLink Render', () => {
expect(useChatStore.getState().openLocalFiles).toEqual([
{
filePath: '/Users/me/project/src/Group.tsx',
id: createLocalFileTabId({
filePath: '/Users/me/project/src/Group.tsx',
workingDirectory: '/Users/me/project',
}),
workingDirectory: '/Users/me/project',
},
]);
@@ -10,7 +10,11 @@ import { TOGGLE_BUTTON_ID } from '@/features/NavPanel/ToggleLeftPanelButton';
import Footer from '@/routes/(main)/home/_layout/Footer';
import { USER_DROPDOWN_ICON_ID } from '@/routes/(main)/home/_layout/Header/components/User';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import {
NAV_PANEL_MAX_WIDTH,
NAV_PANEL_MIN_WIDTH,
systemStatusSelectors,
} from '@/store/global/selectors';
import { isMacOS } from '@/utils/platform';
import { useNavPanelSizeChangeHandler } from '../hooks/useNavPanel';
@@ -142,8 +146,8 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
defaultSize={defaultSize}
expand={expand}
expandable={false}
maxWidth={400}
minWidth={240}
maxWidth={NAV_PANEL_MAX_WIDTH}
minWidth={NAV_PANEL_MIN_WIDTH}
placement="left"
showBorder={false}
style={styles}
+15 -3
View File
@@ -1,6 +1,6 @@
import { Flexbox, Icon, Markdown, Segmented } from '@lobehub/ui';
import { BoltIcon, FileIcon } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Loading from '@/components/Loading/CircleLoading';
@@ -14,15 +14,27 @@ enum FilePreviewTab {
File = 'file',
}
const NO_TOPIC_KEY = '__no_topic__';
const getDefaultTab = (chunkText?: string) =>
chunkText ? FilePreviewTab.Chunk : FilePreviewTab.File;
const FilePreview = () => {
const previewFileId = useChatStore(chatPortalSelectors.previewFileId);
const chunkText = useChatStore(chatPortalSelectors.chunkText);
const activeTopicId = useChatStore((s) => s.activeTopicId);
const useFetchFileItem = useFileStore((s) => s.useFetchKnowledgeItem);
const { t } = useTranslation('portal');
const [tab, setTab] = useState<FilePreviewTab>(FilePreviewTab.File);
const topicKey = activeTopicId ?? NO_TOPIC_KEY;
const [tabByTopic, setTabByTopic] = useState<Record<string, FilePreviewTab>>({});
const tab = tabByTopic[topicKey] ?? getDefaultTab(chunkText);
const { data, isLoading } = useFetchFileItem(previewFileId);
useEffect(() => {
setTabByTopic((prev) => ({ ...prev, [topicKey]: getDefaultTab(chunkText) }));
}, [chunkText, previewFileId, topicKey]);
if (isLoading) return <Loading />;
if (!data) return;
@@ -51,7 +63,7 @@ const FilePreview = () => {
value: FilePreviewTab.File,
},
]}
onChange={(v) => setTab(v as FilePreviewTab)}
onChange={(v) => setTabByTopic((prev) => ({ ...prev, [topicKey]: v as FilePreviewTab }))}
/>
)}
+126 -144
View File
@@ -1,7 +1,7 @@
import { isDesktop, MARKDOWN_MIME_TYPES } from '@lobechat/const';
import { Center, Empty, Flexbox, Icon, Markdown, Segmented, Text } from '@lobehub/ui';
import { isDesktop } from '@lobechat/const';
import { ActionIcon, Center, Empty, Flexbox, Icon, Markdown, Segmented, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { CodeIcon, EyeIcon } from 'lucide-react';
import { CodeIcon, EyeIcon, RefreshCwIcon } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,7 +9,7 @@ import CodeEditorPane from '@/components/CodeEditorPane';
import { InlineHtmlPreview, isHtmlFile } from '@/components/HtmlPreview';
import Loading from '@/components/Loading/CircleLoading';
import { useClientDataSWR } from '@/libs/swr';
import { localFileService } from '@/services/electron/localFileService';
import { type LocalFilePreview, projectFileService } from '@/services/projectFile';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import {
@@ -21,62 +21,6 @@ import {
import { extensionToLanguage, getFileExtension } from './Body.helpers';
const TEXT_PREVIEW_MIME_TYPES = new Set([
'application/graphql',
'application/javascript',
'application/json',
'application/markdown',
'application/toml',
'application/xml',
'application/yaml',
...MARKDOWN_MIME_TYPES,
]);
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;
@@ -165,16 +109,32 @@ SkillFrontmatterPreviewCard.displayName = 'SkillFrontmatterPreviewCard';
type TextPreviewMode = 'render' | 'raw';
const NO_TOPIC_KEY = '__no_topic__';
interface TextPreviewPaneProps {
activeTopicId?: string | null;
content: string;
contentType?: string;
ext: string;
filePath: string;
onReload?: () => Promise<unknown> | void;
onSaved?: (savedContent: string) => void;
readOnly?: boolean;
reloading?: boolean;
}
const TextPreviewPane = memo<TextPreviewPaneProps>(
({ content, contentType, ext, filePath, onSaved }) => {
({
activeTopicId,
content,
contentType,
ext,
filePath,
onReload,
onSaved,
readOnly = false,
reloading = false,
}) => {
const { t } = useTranslation('chat');
const isMarkdown = useMemo(() => MARKDOWN_EXTS.has(ext.toLowerCase()), [ext]);
const isHtml = useMemo(
@@ -186,20 +146,24 @@ const TextPreviewPane = memo<TextPreviewPaneProps>(
const setLocalFileBuffer = useChatStore((s) => s.setLocalFileBuffer);
const saveLocalFile = useChatStore((s) => s.saveLocalFile);
const editingValue = buffer ?? content;
const editingValue = readOnly ? content : (buffer ?? content);
const handleCodeChange = useCallback(
(next: string) => {
if (readOnly) return;
if (next === content) {
setLocalFileBuffer(filePath, undefined);
} else {
setLocalFileBuffer(filePath, next);
}
},
[content, filePath, setLocalFileBuffer],
[content, filePath, readOnly, setLocalFileBuffer],
);
const handleSave = useCallback(async () => {
if (readOnly) return;
try {
const saved = await saveLocalFile(filePath);
if (saved === undefined) return;
@@ -211,7 +175,7 @@ const TextPreviewPane = memo<TextPreviewPaneProps>(
} catch {
/* swallow — surfacing handled elsewhere if needed */
}
}, [filePath, onSaved, saveLocalFile, setLocalFileBuffer]);
}, [filePath, onSaved, readOnly, saveLocalFile, setLocalFileBuffer]);
const { body, frontmatter } = useMemo(
() => (isMarkdown ? parseSkillMarkdownFrontmatter(editingValue) : { body: editingValue }),
@@ -229,12 +193,21 @@ const TextPreviewPane = memo<TextPreviewPaneProps>(
? (frontmatterFields.name ?? '')
: (filePath.split('/').at(-1) ?? filePath);
const [mode, setMode] = useState<TextPreviewMode>(canRender ? 'render' : 'raw');
const [modeByScope, setModeByScope] = useState<Record<string, TextPreviewMode>>({});
const modeScopeKey = `${activeTopicId ?? NO_TOPIC_KEY}:${filePath}`;
const mode = canRender ? (modeByScope[modeScopeKey] ?? 'render') : 'raw';
const setMode = useCallback(
(next: TextPreviewMode) => {
setModeByScope((prev) => ({ ...prev, [modeScopeKey]: next }));
},
[modeScopeKey],
);
const showHtmlPreview = isHtml && mode === 'render';
useEffect(() => {
setMode(canRender ? 'render' : 'raw');
}, [canRender, filePath]);
const [htmlPreviewRevision, setHtmlPreviewRevision] = useState(0);
const handleReloadPreview = useCallback(async () => {
await onReload?.();
setHtmlPreviewRevision((prev) => prev + 1);
}, [onReload]);
return (
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0, overflow: 'hidden' }}>
@@ -250,6 +223,15 @@ const TextPreviewPane = memo<TextPreviewPaneProps>(
<Text ellipsis style={{ flex: 1, fontSize: 13, fontWeight: 500, minWidth: 0 }}>
{previewTitle}
</Text>
{isHtml && (
<ActionIcon
icon={RefreshCwIcon}
loading={reloading}
size={'small'}
title={t('workingPanel.localFile.preview.reload')}
onClick={handleReloadPreview}
/>
)}
<Segmented
size={'small'}
value={mode}
@@ -280,14 +262,15 @@ const TextPreviewPane = memo<TextPreviewPaneProps>(
<Markdown style={{ paddingBlock: 8, paddingInline: 12 }}>{body}</Markdown>
</>
) : showHtmlPreview ? (
<InlineHtmlPreview content={editingValue} />
<InlineHtmlPreview content={editingValue} key={`${filePath}:${htmlPreviewRevision}`} />
) : (
<CodeEditorPane
language={extensionToLanguage(ext)}
readOnly={readOnly}
style={{ fontSize: 12, minHeight: '100%' }}
value={editingValue}
onChange={handleCodeChange}
onSave={handleSave}
onChange={readOnly ? undefined : handleCodeChange}
onSave={readOnly ? undefined : handleSave}
/>
)}
</div>
@@ -301,89 +284,85 @@ TextPreviewPane.displayName = 'TextPreviewPane';
// ============== ActiveFileView ==============
interface ActiveFileViewProps {
activeTopicId?: string | null;
deviceId?: string;
filePath: string;
workingDirectory: string;
}
const ActiveFileView = memo<ActiveFileViewProps>(({ filePath, workingDirectory }) => {
const { t } = useTranslation('chat');
const ActiveFileView = memo<ActiveFileViewProps>(
({ activeTopicId, deviceId, filePath, workingDirectory }) => {
const { t } = useTranslation('chat');
const filename = filePath.split('/').at(-1) ?? '';
const {
data: preview,
error,
isLoading,
mutate,
} = useClientDataSWR<LocalFilePreview>(
isDesktop && workingDirectory ? ['local-file-preview', filePath, workingDirectory] : null,
async () => {
const result = await localFileService.getLocalFilePreviewUrl({
path: filePath,
workingDirectory,
});
if (!result.success || !result.url) {
throw new Error(result.error || 'Missing local file preview URL');
}
return fetchLocalFilePreview(result.url);
},
{ revalidateOnFocus: false },
);
const handleSavedContent = useCallback(
(saved: string) => {
mutate((prev) => (prev && prev.type === 'text' ? { ...prev, content: saved } : prev), {
revalidate: false,
});
},
[mutate],
);
// Chromium blocks `file://` from a non-file origin. The desktop main process
// mints short-lived `localfile://` preview URLs for approved workspace files.
if (!isDesktop) {
return (
<Center height={'100%'} width={'100%'}>
<Empty description={t('workingPanel.localFile.binary')} />
</Center>
const filename = filePath.split('/').at(-1) ?? '';
const enabled = Boolean(workingDirectory) && (!!deviceId || isDesktop);
const {
data: preview,
error,
isLoading,
isValidating,
mutate,
} = useClientDataSWR<LocalFilePreview>(
enabled ? ['local-file-preview', deviceId ?? 'local', filePath, workingDirectory] : null,
() =>
projectFileService.getLocalFilePreview({
deviceId,
path: filePath,
workingDirectory,
}),
{ revalidateOnFocus: false },
);
}
if (isLoading) return <Loading />;
if (error || !preview) {
return (
<Center height={'100%'} width={'100%'}>
<Empty description={t('workingPanel.localFile.error')} />
</Center>
const handleSavedContent = useCallback(
(saved: string) => {
mutate((prev) => (prev && prev.type === 'text' ? { ...prev, content: saved } : prev), {
revalidate: false,
});
},
[mutate],
);
}
if (preview.type === 'binary') {
const handleReload = useCallback(() => mutate(), [mutate]);
if (isLoading) return <Loading />;
if (error || !preview) {
return (
<Center height={'100%'} width={'100%'}>
<Empty description={t('workingPanel.localFile.error')} />
</Center>
);
}
if (preview.type === 'image') {
return <ImagePreview blob={preview.blob} filename={filename} />;
}
if (preview.type !== 'text') {
return (
<Center height={'100%'} width={'100%'}>
<Empty description={t('workingPanel.localFile.binary')} />
</Center>
);
}
const ext = getFileExtension(filename);
return (
<Center height={'100%'} width={'100%'}>
<Empty description={t('workingPanel.localFile.binary')} />
</Center>
<TextPreviewPane
activeTopicId={activeTopicId}
content={preview.content}
contentType={preview.contentType}
ext={ext}
filePath={filePath}
readOnly={!!deviceId}
reloading={isValidating}
onReload={handleReload}
onSaved={handleSavedContent}
/>
);
}
if (preview.type === 'image') {
return <ImagePreview blob={preview.blob} filename={filename} />;
}
const ext = getFileExtension(filename);
return (
<TextPreviewPane
content={preview.content}
contentType={preview.contentType}
ext={ext}
filePath={filePath}
onSaved={handleSavedContent}
/>
);
});
},
);
ActiveFileView.displayName = 'ActiveFileView';
@@ -392,6 +371,7 @@ ActiveFileView.displayName = 'ActiveFileView';
const Body = memo(() => {
const openLocalFiles = useChatStore(chatPortalSelectors.openLocalFiles);
const activeFile = useChatStore(chatPortalSelectors.currentLocalFile);
const activeTopicId = useChatStore((s) => s.activeTopicId);
if (openLocalFiles.length === 0) return null;
if (!activeFile) return null;
@@ -399,6 +379,8 @@ const Body = memo(() => {
return (
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0, overflow: 'hidden' }}>
<ActiveFileView
activeTopicId={activeTopicId}
deviceId={activeFile.deviceId}
filePath={activeFile.filePath}
workingDirectory={activeFile.workingDirectory}
/>
+20 -3
View File
@@ -1,23 +1,27 @@
'use client';
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@lobechat/const';
import { DESKTOP_HEADER_ICON_SMALL_SIZE, isDesktop } from '@lobechat/const';
import { ActionIcon } from '@lobehub/ui';
import { ArrowLeft, X } from 'lucide-react';
import { Fragment, memo } from 'react';
import { ArrowLeft, FolderOpen, X } from 'lucide-react';
import { Fragment, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router-dom';
import { SESSION_CHAT_TOPIC_PAGE_URL, SESSION_CHAT_TOPIC_URL } from '@/const/url';
import NavHeader from '@/features/NavHeader';
import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwareNavigate';
import { localFileService } from '@/services/electron/localFileService';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import TabStrip from './TabStrip';
const Header = memo(() => {
const { t } = useTranslation('chat');
const location = useLocation();
const navigate = useWorkspaceAwareNavigate();
const params = useParams<{ aid?: string; topicId?: string }>();
const activeLocalFilePath = useChatStore(chatPortalSelectors.activeLocalFilePath);
const [canGoBack, goBack, clearPortalStack] = useChatStore((s) => [
chatPortalSelectors.canGoBack(s),
s.goBack,
@@ -27,6 +31,11 @@ const Header = memo(() => {
!!params.aid &&
!!params.topicId &&
location.pathname.startsWith(SESSION_CHAT_TOPIC_PAGE_URL(params.aid, params.topicId));
const handleOpenFileFolder = useCallback(() => {
if (!activeLocalFilePath) return;
void localFileService.openFileFolder(activeLocalFilePath);
}, [activeLocalFilePath]);
return (
<NavHeader
@@ -42,6 +51,14 @@ const Header = memo(() => {
}
right={
<Fragment>
{isDesktop && activeLocalFilePath && (
<ActionIcon
icon={FolderOpen}
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
title={t('workingPanel.files.showInSystem')}
onClick={handleOpenFileFolder}
/>
)}
<ActionIcon
icon={X}
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
+31 -13
View File
@@ -1,5 +1,6 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { ContextMenuTrigger, type GenericItemType, Icon } from '@lobehub/ui';
import { confirmModal, ScrollArea } from '@lobehub/ui/base-ui';
import { SkillsIcon } from '@lobehub/ui/icons';
@@ -9,8 +10,10 @@ import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import FileIcon from '@/components/FileIcon';
import { localFileService } from '@/services/electron/localFileService';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { getLocalFileTabId } from '@/store/chat/slices/portal/helpers';
const SKILL_PATH_RE = /\/\.(?:agents|claude)\/skills\/([^/]+)\/SKILL\.md$/;
@@ -149,7 +152,7 @@ const SCROLL_AREA_SCROLLBAR_STYLE = {
const TabStrip = memo(() => {
const { t } = useTranslation('chat');
const openLocalFiles = useChatStore(chatPortalSelectors.openLocalFiles);
const activeLocalFilePath = useChatStore(chatPortalSelectors.activeLocalFilePath);
const activeLocalFileId = useChatStore(chatPortalSelectors.activeLocalFileId);
const dirtyContents = useChatStore(chatPortalSelectors.dirtyLocalFileContents);
const setActiveLocalFile = useChatStore((s) => s.setActiveLocalFile);
const closeLocalFileTab = useChatStore((s) => s.closeLocalFileTab);
@@ -184,30 +187,43 @@ const TabStrip = memo(() => {
);
const getContextMenuItems = useCallback(
(filePath: string, index: number): GenericItemType[] => [
(id: string, filePath: string, index: number): GenericItemType[] => [
{
disabled: index === 0,
key: 'closeLeft',
label: t('workingPanel.localFile.closeLeft'),
onClick: () => closeLeftLocalFileTabs(filePath),
onClick: () => closeLeftLocalFileTabs(id),
},
{
disabled: index === openLocalFiles.length - 1,
key: 'closeRight',
label: t('workingPanel.localFile.closeRight'),
onClick: () => closeRightLocalFileTabs(filePath),
onClick: () => closeRightLocalFileTabs(id),
},
{
disabled: openLocalFiles.length <= 1,
key: 'closeOther',
label: t('workingPanel.localFile.closeOther'),
onClick: () => closeOtherLocalFileTabs(filePath),
onClick: () => closeOtherLocalFileTabs(id),
},
...(isDesktop
? [
{
key: 'showInSystem',
label: t('workingPanel.files.showInSystem'),
onClick: () => void localFileService.openFileFolder(filePath),
},
]
: []),
{ type: 'divider' },
{
key: 'close',
label: t('workingPanel.localFile.close'),
onClick: () => confirmClose(filePath, () => closeLocalFileTab(filePath)),
onClick: () => {
const filePath =
openLocalFiles.find((file) => getLocalFileTabId(file) === id)?.filePath ?? id;
confirmClose(filePath, () => closeLocalFileTab(id));
},
},
],
[
@@ -216,7 +232,7 @@ const TabStrip = memo(() => {
closeOtherLocalFileTabs,
closeRightLocalFileTabs,
confirmClose,
openLocalFiles.length,
openLocalFiles,
t,
],
);
@@ -230,25 +246,27 @@ const TabStrip = memo(() => {
scrollbarProps={{ orientation: 'horizontal', style: SCROLL_AREA_SCROLLBAR_STYLE }}
style={SCROLL_AREA_STYLE}
>
{openLocalFiles.map(({ filePath }, index) => {
{openLocalFiles.map((file, index) => {
const { filePath } = file;
const id = getLocalFileTabId(file);
const filename = filePath.split('/').at(-1) ?? filePath;
const skillName = resolveSkillName(filePath);
const label = skillName ?? filename;
const isActive = filePath === activeLocalFilePath;
const isActive = id === activeLocalFileId;
return (
<ContextMenuTrigger items={() => getContextMenuItems(filePath, index)} key={filePath}>
<ContextMenuTrigger items={() => getContextMenuItems(id, filePath, index)} key={id}>
<div
aria-selected={isActive}
className={`${styles.tabItem} ${isActive ? styles.tabItemActive : ''}`}
role="tab"
tabIndex={0}
title={filePath}
onClick={() => setActiveLocalFile(filePath)}
onClick={() => setActiveLocalFile(id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setActiveLocalFile(filePath);
setActiveLocalFile(id);
}
}}
>
@@ -267,7 +285,7 @@ const TabStrip = memo(() => {
type="button"
onClick={(e) => {
e.stopPropagation();
confirmClose(filePath, () => closeLocalFileTab(filePath));
confirmClose(filePath, () => closeLocalFileTab(id));
}}
>
<span className={'cm-tab-close-x'}>
@@ -175,22 +175,23 @@ const Files = memo<FilesProps>(({ deviceId, workingDirectory }) => {
(node: ExplorerTreeNode<ProjectFileIndexEntry>) => {
if (!node.data) return;
if (node.isFolder) {
if (isRemote) return;
void localFileService.openLocalFileOrFolder(node.data.path, true);
return;
}
openLocalFile({ filePath: node.data.path, workingDirectory: projectRoot });
openLocalFile({ deviceId, filePath: node.data.path, workingDirectory: projectRoot });
},
[openLocalFile, projectRoot],
[deviceId, isRemote, openLocalFile, projectRoot],
);
const handleNodeClick = useCallback(
(node: ExplorerTreeNode<ProjectFileIndexEntry>) => {
// Folders expand via the tree; files open the local viewer (local only —
// a remote device has no filesystem to open here).
if (node.isFolder || isRemote) return;
// Folders expand via the tree; files open in the preview panel.
if (node.isFolder) return;
openNode(node);
},
[isRemote, openNode],
[openNode],
);
const getContextMenuItems = useCallback(
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockLocalSystem = vi.hoisted(() => ({
getLocalFilePreviewUrl: vi.fn(),
}));
vi.mock('@/utils/electron/ipc', () => ({
ensureElectronIpc: () => ({
localSystem: mockLocalSystem,
}),
}));
describe('localFileService', () => {
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('fetches text local-file preview from the preview URL', async () => {
const { localFileService } = await import('./localFileService');
mockLocalSystem.getLocalFilePreviewUrl.mockResolvedValue({
success: true,
url: 'localfile://preview/index.html',
});
const fetchMock = vi.fn(async () => {
return {
blob: vi.fn(),
headers: { get: vi.fn(() => 'text/html; charset=utf-8') },
ok: true,
text: vi.fn(async () => '<h1>Local</h1>'),
} as unknown as Response;
});
vi.stubGlobal('fetch', fetchMock);
const preview = await localFileService.getLocalFilePreview({
path: '/repo/index.html',
workingDirectory: '/repo',
});
expect(mockLocalSystem.getLocalFilePreviewUrl).toHaveBeenCalledWith({
path: '/repo/index.html',
workingDirectory: '/repo',
});
expect(fetchMock).toHaveBeenCalledWith('localfile://preview/index.html');
expect(preview).toEqual({
content: '<h1>Local</h1>',
contentType: 'text/html',
type: 'text',
});
});
it('throws when the preview URL cannot be created', async () => {
const { localFileService } = await import('./localFileService');
mockLocalSystem.getLocalFilePreviewUrl.mockResolvedValue({
error: 'outside safe path',
success: false,
});
await expect(
localFileService.getLocalFilePreview({
path: '/repo/index.html',
workingDirectory: '/repo',
}),
).rejects.toThrow('outside safe path');
});
});
+78
View File
@@ -1,3 +1,4 @@
import { MARKDOWN_MIME_TYPES } from '@lobechat/const';
import {
type AuditSafePathsParams,
type AuditSafePathsResult,
@@ -42,6 +43,73 @@ import {
import { ensureElectronIpc } from '@/utils/electron/ipc';
const TEXT_PREVIEW_MIME_TYPES = new Set([
'application/graphql',
'application/javascript',
'application/json',
'application/markdown',
'application/toml',
'application/xml',
'application/yaml',
...MARKDOWN_MIME_TYPES,
]);
export interface BinaryLocalFilePreview {
contentType: string;
type: 'binary' | 'pdf' | 'video';
}
export interface ImageLocalFilePreview {
blob: Blob;
contentType: string;
type: 'image';
}
export interface TextLocalFilePreview {
content: string;
contentType: string;
type: 'text';
}
export 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' };
}
if (contentType === 'application/pdf') {
return { contentType, type: 'pdf' };
}
if (contentType.startsWith('video/')) {
return { contentType, type: 'video' };
}
return { contentType, type: 'binary' };
};
class LocalFileService {
// File Operations
async listLocalFiles(params: ListLocalFileParams): Promise<ListLocalFilesResult> {
@@ -101,6 +169,16 @@ class LocalFileService {
return ensureElectronIpc().localSystem.getLocalFilePreviewUrl(params);
}
async getLocalFilePreview(params: LocalFilePreviewUrlParams): Promise<LocalFilePreview> {
const result = await this.getLocalFilePreviewUrl(params);
if (!result.success || !result.url) {
throw new Error(result.error || 'Missing local file preview URL');
}
return fetchLocalFilePreview(result.url);
}
async prepareSkillDirectory(
params: PrepareSkillDirectoryParams,
): Promise<PrepareSkillDirectoryResult> {
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockDeviceClient = vi.hoisted(() => ({
getLocalFilePreview: { query: vi.fn() },
getProjectFileIndex: { query: vi.fn() },
}));
const mockLocalFileService = vi.hoisted(() => ({
getLocalFilePreview: vi.fn(),
getProjectFileIndex: vi.fn(),
}));
vi.mock('@lobechat/const', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
isDesktop: true,
}));
vi.mock('@/libs/trpc/client', () => ({
lambdaClient: {
device: mockDeviceClient,
},
}));
vi.mock('@/services/electron/localFileService', () => ({
localFileService: mockLocalFileService,
}));
describe('projectFileService', () => {
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('gets remote local-file preview through device RPC', async () => {
const { projectFileService } = await import('./projectFile');
mockDeviceClient.getLocalFilePreview.query.mockResolvedValue({
preview: {
content: '<h1>Remote</h1>',
contentType: 'text/html',
type: 'text',
},
success: true,
});
const preview = await projectFileService.getLocalFilePreview({
deviceId: 'device-1',
path: '/repo/index.html',
workingDirectory: '/repo',
});
expect(mockDeviceClient.getLocalFilePreview.query).toHaveBeenCalledWith({
deviceId: 'device-1',
path: '/repo/index.html',
workingDirectory: '/repo',
});
expect(mockLocalFileService.getLocalFilePreview).not.toHaveBeenCalled();
expect(preview).toEqual({
content: '<h1>Remote</h1>',
contentType: 'text/html',
type: 'text',
});
});
it('delegates desktop local-file preview to localFileService', async () => {
const { projectFileService } = await import('./projectFile');
mockLocalFileService.getLocalFilePreview.mockResolvedValue({
content: '<h1>Local</h1>',
contentType: 'text/html',
type: 'text',
});
const preview = await projectFileService.getLocalFilePreview({
path: '/repo/index.html',
workingDirectory: '/repo',
});
expect(mockLocalFileService.getLocalFilePreview).toHaveBeenCalledWith({
path: '/repo/index.html',
workingDirectory: '/repo',
});
expect(mockDeviceClient.getLocalFilePreview.query).not.toHaveBeenCalled();
expect(preview).toEqual({
content: '<h1>Local</h1>',
contentType: 'text/html',
type: 'text',
});
});
});
+63 -6
View File
@@ -1,13 +1,48 @@
import type { ProjectFileIndexResult } from '@lobechat/electron-client-ipc';
import type {
LocalFilePreviewUrlParams,
ProjectFileIndexResult,
} from '@lobechat/electron-client-ipc';
import type { DeviceLocalFilePreview } from '@lobechat/types';
import { lambdaClient } from '@/libs/trpc/client';
import { localFileService } from '@/services/electron/localFileService';
import { type LocalFilePreview, localFileService } from '@/services/electron/localFileService';
export type { LocalFilePreview } from '@/services/electron/localFileService';
export interface GetLocalFilePreviewParams extends LocalFilePreviewUrlParams {
deviceId?: string;
}
const base64ToBlob = (base64: string, contentType: string): Blob => {
const bytes = Uint8Array.from(globalThis.atob(base64), (char) => char.charCodeAt(0));
return new Blob([bytes], { type: contentType });
};
const deserializeLocalFilePreview = (preview: DeviceLocalFilePreview): LocalFilePreview => {
switch (preview.type) {
case 'image': {
return {
blob: base64ToBlob(preview.base64, preview.contentType),
contentType: preview.contentType,
type: 'image',
};
}
case 'text': {
return preview;
}
default: {
return preview;
}
}
};
/**
* Project file tree chokepoint. Picks the transport per call from `deviceId`: a
* remote / web target goes through the `device.getProjectFileIndex` RPC; the
* local desktop talks to Electron over IPC. UI / store only see this service
* the electron-vs-lambda decision never leaks up. (Parallels `gitService`.)
* Project file chokepoint. Picks the transport per call from `deviceId`: a
* remote / web target goes through the device RPCs; the local desktop talks to
* Electron over IPC / preview URLs. UI / store only see this service the
* electron-vs-lambda decision never leaks up. (Parallels `gitService`.)
*/
class ProjectFileService {
/** Project file index (tree) for a working directory. */
@@ -22,6 +57,28 @@ class ProjectFileService {
? ((await lambdaClient.device.getProjectFileIndex.query({ deviceId, scope })) ?? undefined)
: localFileService.getProjectFileIndex({ scope });
}
/** File preview payload for a file in a project working directory. */
async getLocalFilePreview({
deviceId,
...params
}: GetLocalFilePreviewParams): Promise<LocalFilePreview> {
if (deviceId) {
const result = await lambdaClient.device.getLocalFilePreview.query({
deviceId,
path: params.path,
workingDirectory: params.workingDirectory,
});
if (!result.success || !result.preview) {
throw new Error(result.error || 'Missing local file preview');
}
return deserializeLocalFilePreview(result.preview);
}
return localFileService.getLocalFilePreview(params);
}
}
export const projectFileService = new ProjectFileService();
+117 -2
View File
@@ -3,8 +3,19 @@ import { describe, expect, it, vi } from 'vitest';
import { useChatStore } from '@/store/chat';
import { createLocalFileTabId } from './helpers';
import { PortalViewType } from './initialState';
const localFileTabId = ({
deviceId,
filePath,
workingDirectory,
}: {
deviceId?: string;
filePath: string;
workingDirectory: string;
}) => createLocalFileTabId({ deviceId, filePath, workingDirectory });
vi.mock('zustand/traditional');
describe('chatDockSlice', () => {
@@ -335,8 +346,15 @@ describe('chatDockSlice', () => {
});
expect(result.current.openLocalFiles).toEqual([
{ filePath: '/path/to/file.ts', workingDirectory: '/path/to' },
{
filePath: '/path/to/file.ts',
id: localFileTabId({ filePath: '/path/to/file.ts', workingDirectory: '/path/to' }),
workingDirectory: '/path/to',
},
]);
expect(result.current.activeLocalFileId).toBe(
localFileTabId({ 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 });
@@ -358,6 +376,73 @@ describe('chatDockSlice', () => {
expect(result.current.activeLocalFilePath).toBe('/path/a.ts');
});
it('should keep device context when opening a remote file', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.openLocalFile({
deviceId: 'device-1',
filePath: '/path/a.ts',
workingDirectory: '/path',
});
});
expect(result.current.openLocalFiles).toEqual([
{
deviceId: 'device-1',
filePath: '/path/a.ts',
id: localFileTabId({
deviceId: 'device-1',
filePath: '/path/a.ts',
workingDirectory: '/path',
}),
workingDirectory: '/path',
},
]);
expect(result.current.activeLocalFilePath).toBe('/path/a.ts');
});
it('should keep same file path from different device context as separate tabs', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' });
});
act(() => {
result.current.openLocalFile({
deviceId: 'device-1',
filePath: '/path/a.ts',
workingDirectory: '/remote/path',
});
});
expect(result.current.openLocalFiles).toEqual([
{
filePath: '/path/a.ts',
id: localFileTabId({ filePath: '/path/a.ts', workingDirectory: '/path' }),
workingDirectory: '/path',
},
{
deviceId: 'device-1',
filePath: '/path/a.ts',
id: localFileTabId({
deviceId: 'device-1',
filePath: '/path/a.ts',
workingDirectory: '/remote/path',
}),
workingDirectory: '/remote/path',
},
]);
expect(result.current.activeLocalFileId).toBe(
localFileTabId({
deviceId: 'device-1',
filePath: '/path/a.ts',
workingDirectory: '/remote/path',
}),
);
});
it('should add multiple files as separate tabs and keep portal as single entry', () => {
const { result } = renderHook(() => useChatStore());
@@ -506,6 +591,32 @@ describe('chatDockSlice', () => {
expect(result.current.openLocalFiles).toHaveLength(1);
});
it('should not clear local dirty buffer when closing a remote tab with the same file path', () => {
const { result } = renderHook(() => useChatStore());
const remoteId = localFileTabId({
deviceId: 'device-1',
filePath: '/path/a.ts',
workingDirectory: '/remote/path',
});
act(() => {
result.current.openLocalFile({ filePath: '/path/a.ts', workingDirectory: '/path' });
result.current.setLocalFileBuffer('/path/a.ts', 'dirty content');
result.current.openLocalFile({
deviceId: 'device-1',
filePath: '/path/a.ts',
workingDirectory: '/remote/path',
});
});
act(() => {
result.current.closeLocalFileTab(remoteId);
});
expect(result.current.openLocalFiles).toHaveLength(1);
expect(result.current.dirtyLocalFileContents['/path/a.ts']).toBe('dirty content');
});
});
describe('closeLeftLocalFileTabs', () => {
@@ -603,7 +714,11 @@ describe('chatDockSlice', () => {
});
expect(result.current.openLocalFiles).toEqual([
{ filePath: '/path/b.ts', workingDirectory: '/path' },
{
filePath: '/path/b.ts',
id: localFileTabId({ filePath: '/path/b.ts', workingDirectory: '/path' }),
workingDirectory: '/path',
},
]);
expect(result.current.activeLocalFilePath).toBe('/path/b.ts');
expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.LocalFile });
+126 -40
View File
@@ -3,7 +3,8 @@ import { type ChatStore } from '@/store/chat/store';
import { type StoreSetter } from '@/store/types';
import { type PortalArtifact } from '@/types/artifact';
import { type PortalFile, type PortalViewData } from './initialState';
import { createLocalFileTabId, getLocalFileTabId } from './helpers';
import { type OpenLocalFileParams, type PortalFile, type PortalViewData } from './initialState';
import { PortalViewType } from './initialState';
// Helper to get current view type from stack
@@ -12,6 +13,33 @@ const getCurrentViewType = (portalStack: PortalViewData[]): PortalViewType | nul
return top?.type ?? null;
};
const findLocalFileIndexById = (
openLocalFiles: Array<OpenLocalFileParams & { id?: string }>,
id: string,
) => {
const index = openLocalFiles.findIndex((file) => getLocalFileTabId(file) === id);
return index >= 0 ? index : openLocalFiles.findIndex((file) => file.filePath === id);
};
const findLocalFileById = (
openLocalFiles: Array<OpenLocalFileParams & { id?: string }>,
id: string | undefined,
) =>
id
? (openLocalFiles.find((file) => getLocalFileTabId(file) === id) ??
openLocalFiles.find((file) => file.filePath === id))
: undefined;
const resolveActiveLocalFile = (
openLocalFiles: Array<OpenLocalFileParams & { id?: string }>,
activeLocalFileId: string | undefined,
activeLocalFilePath: string | undefined,
) =>
findLocalFileById(openLocalFiles, activeLocalFileId) ??
(activeLocalFilePath
? openLocalFiles.find((file) => file.filePath === activeLocalFilePath)
: undefined);
type Setter = StoreSetter<ChatStore>;
export const chatPortalSlice = (set: Setter, get: () => ChatStore, _api?: unknown) =>
new ChatPortalActionImpl(set, get, _api);
@@ -58,30 +86,45 @@ export class ChatPortalActionImpl {
}
};
closeLocalFileTab = (filePath: string): void => {
const { openLocalFiles, activeLocalFilePath, dirtyLocalFileContents } = this.#get();
const idx = openLocalFiles.findIndex((f) => f.filePath === filePath);
closeLocalFileTab = (id: string): void => {
const { openLocalFiles, activeLocalFileId, activeLocalFilePath, dirtyLocalFileContents } =
this.#get();
const idx = findLocalFileIndexById(openLocalFiles, id);
if (idx === -1) return;
const target = openLocalFiles[idx];
const targetId = getLocalFileTabId(target);
const nextFiles = openLocalFiles.filter((_, i) => i !== idx);
let nextActive: string | undefined;
if (activeLocalFilePath === filePath) {
let nextActiveId: string | undefined;
let nextActivePath: string | undefined;
const activeFile = resolveActiveLocalFile(
openLocalFiles,
activeLocalFileId,
activeLocalFilePath,
);
if (activeFile && getLocalFileTabId(activeFile) === targetId) {
const neighbor = nextFiles[idx] ?? nextFiles[idx - 1];
nextActive = neighbor?.filePath;
nextActiveId = neighbor ? getLocalFileTabId(neighbor) : undefined;
nextActivePath = neighbor?.filePath;
} else {
nextActive = activeLocalFilePath;
nextActiveId = activeLocalFileId;
nextActivePath = activeLocalFilePath;
}
let nextDirty = dirtyLocalFileContents;
if (filePath in dirtyLocalFileContents) {
const { [filePath]: _, ...rest } = dirtyLocalFileContents;
const shouldClearDirty =
!target.deviceId &&
!nextFiles.some((file) => !file.deviceId && file.filePath === target.filePath);
if (shouldClearDirty && target.filePath in dirtyLocalFileContents) {
const { [target.filePath]: _, ...rest } = dirtyLocalFileContents;
nextDirty = rest;
}
this.#set(
{
activeLocalFilePath: nextActive,
activeLocalFileId: nextActiveId,
activeLocalFilePath: nextActivePath,
dirtyLocalFileContents: nextDirty,
openLocalFiles: nextFiles,
},
@@ -94,47 +137,77 @@ export class ChatPortalActionImpl {
}
};
closeLeftLocalFileTabs = (filePath: string): void => {
const { openLocalFiles, activeLocalFilePath } = this.#get();
const idx = openLocalFiles.findIndex((f) => f.filePath === filePath);
closeLeftLocalFileTabs = (id: string): void => {
const { openLocalFiles, activeLocalFileId, activeLocalFilePath } = this.#get();
const idx = findLocalFileIndexById(openLocalFiles, id);
if (idx <= 0) return;
const nextFiles = openLocalFiles.slice(idx);
const nextActive = nextFiles.some((f) => f.filePath === activeLocalFilePath)
? activeLocalFilePath
: filePath;
const activeFile = resolveActiveLocalFile(
openLocalFiles,
activeLocalFileId,
activeLocalFilePath,
);
const currentActiveId = activeFile ? getLocalFileTabId(activeFile) : undefined;
const targetId = getLocalFileTabId(openLocalFiles[idx]);
const nextActiveId = nextFiles.some((f) => getLocalFileTabId(f) === currentActiveId)
? currentActiveId
: targetId;
const nextActiveFile = findLocalFileById(nextFiles, nextActiveId);
this.#set(
{ activeLocalFilePath: nextActive, openLocalFiles: nextFiles },
{
activeLocalFileId: nextActiveId,
activeLocalFilePath: nextActiveFile?.filePath,
openLocalFiles: nextFiles,
},
false,
'closeLeftLocalFileTabs',
);
};
closeOtherLocalFileTabs = (filePath: string): void => {
closeOtherLocalFileTabs = (id: string): void => {
const { openLocalFiles } = this.#get();
const target = openLocalFiles.find((f) => f.filePath === filePath);
const target = findLocalFileById(openLocalFiles, id);
if (!target) return;
const targetId = getLocalFileTabId(target);
const targetFile = { ...target, id: targetId };
this.#set(
{ activeLocalFilePath: filePath, openLocalFiles: [target] },
{
activeLocalFileId: targetId,
activeLocalFilePath: target.filePath,
openLocalFiles: [targetFile],
},
false,
'closeOtherLocalFileTabs',
);
};
closeRightLocalFileTabs = (filePath: string): void => {
const { openLocalFiles, activeLocalFilePath } = this.#get();
const idx = openLocalFiles.findIndex((f) => f.filePath === filePath);
closeRightLocalFileTabs = (id: string): void => {
const { openLocalFiles, activeLocalFileId, activeLocalFilePath } = this.#get();
const idx = findLocalFileIndexById(openLocalFiles, id);
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;
const activeFile = resolveActiveLocalFile(
openLocalFiles,
activeLocalFileId,
activeLocalFilePath,
);
const currentActiveId = activeFile ? getLocalFileTabId(activeFile) : undefined;
const targetId = getLocalFileTabId(openLocalFiles[idx]);
const nextActiveId = nextFiles.some((f) => getLocalFileTabId(f) === currentActiveId)
? currentActiveId
: targetId;
const nextActiveFile = findLocalFileById(nextFiles, nextActiveId);
this.#set(
{ activeLocalFilePath: nextActive, openLocalFiles: nextFiles },
{
activeLocalFileId: nextActiveId,
activeLocalFilePath: nextActiveFile?.filePath,
openLocalFiles: nextFiles,
},
false,
'closeRightLocalFileTabs',
);
@@ -188,22 +261,35 @@ export class ChatPortalActionImpl {
this.#get().pushPortalView({ file, type: PortalViewType.FilePreview });
};
openLocalFile = ({
filePath,
workingDirectory,
}: {
filePath: string;
workingDirectory: string;
}): void => {
openLocalFile = ({ deviceId, filePath, workingDirectory }: OpenLocalFileParams): 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');
const id = createLocalFileTabId({ deviceId, filePath, workingDirectory });
const exists = openLocalFiles.some((f) => getLocalFileTabId(f) === id);
const nextFile = deviceId
? { deviceId, filePath, id, workingDirectory }
: { filePath, id, workingDirectory };
const nextFiles = exists
? openLocalFiles.map((file) => (getLocalFileTabId(file) === id ? nextFile : file))
: [...openLocalFiles, nextFile];
this.#set(
{ activeLocalFileId: id, activeLocalFilePath: filePath, openLocalFiles: nextFiles },
false,
'openLocalFile',
);
this.#get().pushPortalView({ type: PortalViewType.LocalFile });
};
setActiveLocalFile = (filePath: string): void => {
this.#set({ activeLocalFilePath: filePath }, false, 'setActiveLocalFile');
setActiveLocalFile = (id: string): void => {
const { openLocalFiles } = this.#get();
const activeFile = findLocalFileById(openLocalFiles, id);
this.#set(
{
activeLocalFileId: activeFile ? getLocalFileTabId(activeFile) : id,
activeLocalFilePath: activeFile?.filePath ?? id,
},
false,
'setActiveLocalFile',
);
};
setLocalFileBuffer = (filePath: string, content: string | undefined): void => {
+15
View File
@@ -0,0 +1,15 @@
import type { OpenLocalFileParams } from './initialState';
const LOCAL_FILE_TAB_LOCAL_DEVICE = 'local';
export const createLocalFileTabId = ({
deviceId,
filePath,
workingDirectory,
}: OpenLocalFileParams): string =>
[deviceId ? `device:${deviceId}` : LOCAL_FILE_TAB_LOCAL_DEVICE, workingDirectory, filePath]
.map(encodeURIComponent)
.join('|');
export const getLocalFileTabId = (entry: OpenLocalFileParams & { id?: string }): string =>
entry.id ?? createLocalFileTabId(entry);
+15 -2
View File
@@ -27,6 +27,16 @@ export interface PortalFile {
fileId: string;
}
export interface OpenLocalFileParams {
deviceId?: string;
filePath: string;
workingDirectory: string;
}
export interface OpenLocalFileEntry extends OpenLocalFileParams {
id: string;
}
export type PortalViewData =
| { type: PortalViewType.Home }
| { artifact: PortalArtifact; type: PortalViewType.Artifact }
@@ -48,7 +58,10 @@ export type PortalViewData =
// ============== Portal State ==============
export interface ChatPortalState {
/** Path of the currently active tab; undefined when no tabs open. */
/** Composite id of the currently active local-file tab; undefined when no tabs open. */
activeLocalFileId?: string;
/** Path of the currently active tab; kept for legacy consumers that only need display/open path. */
activeLocalFilePath?: string;
/** Unsaved edit buffers keyed by file path. Presence implies the file is dirty. */
@@ -57,7 +70,7 @@ export interface ChatPortalState {
// 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 }>;
openLocalFiles: OpenLocalFileEntry[];
/** @deprecated Use portalStack instead */
portalArtifact?: PortalArtifact;
portalArtifactDisplayMode: ArtifactDisplayMode;
@@ -3,9 +3,20 @@ import { describe, expect, it } from 'vitest';
import { type ChatStoreState } from '@/store/chat';
import { createLocalFileTabId } from './helpers';
import { PortalViewType } from './initialState';
import { chatPortalSelectors } from './selectors';
const localFileTabId = ({
deviceId,
filePath,
workingDirectory,
}: {
deviceId?: string;
filePath: string;
workingDirectory: string;
}) => createLocalFileTabId({ deviceId, filePath, workingDirectory });
describe('chatDockSelectors', () => {
const createState = (overrides?: Partial<ChatStoreState>) => {
const state = {
@@ -226,6 +237,72 @@ describe('chatDockSelectors', () => {
});
});
it('should preserve device context on the active file entry', () => {
const state = createState({
activeLocalFileId: localFileTabId({
deviceId: 'device-1',
filePath: '/path/to/file.ts',
workingDirectory: '/path/to',
}),
activeLocalFilePath: '/path/to/file.ts',
openLocalFiles: [
{
deviceId: 'device-1',
filePath: '/path/to/file.ts',
id: localFileTabId({
deviceId: 'device-1',
filePath: '/path/to/file.ts',
workingDirectory: '/path/to',
}),
workingDirectory: '/path/to',
},
],
} as Partial<ChatStoreState>);
expect(chatPortalSelectors.currentLocalFile(state)).toEqual({
deviceId: 'device-1',
filePath: '/path/to/file.ts',
id: localFileTabId({
deviceId: 'device-1',
filePath: '/path/to/file.ts',
workingDirectory: '/path/to',
}),
workingDirectory: '/path/to',
});
});
it('should use activeLocalFileId when multiple tabs share the same filePath', () => {
const localId = localFileTabId({
filePath: '/path/to/file.ts',
workingDirectory: '/local',
});
const remoteId = localFileTabId({
deviceId: 'device-1',
filePath: '/path/to/file.ts',
workingDirectory: '/remote',
});
const state = createState({
activeLocalFileId: remoteId,
activeLocalFilePath: '/path/to/file.ts',
openLocalFiles: [
{ filePath: '/path/to/file.ts', id: localId, workingDirectory: '/local' },
{
deviceId: 'device-1',
filePath: '/path/to/file.ts',
id: remoteId,
workingDirectory: '/remote',
},
],
} as Partial<ChatStoreState>);
expect(chatPortalSelectors.currentLocalFile(state)).toEqual({
deviceId: 'device-1',
filePath: '/path/to/file.ts',
id: remoteId,
workingDirectory: '/remote',
});
});
it('should return undefined when activeLocalFilePath is not in openLocalFiles', () => {
const state = createState({
activeLocalFilePath: '/path/to/other.ts',
@@ -292,6 +369,25 @@ describe('chatDockSelectors', () => {
});
});
describe('activeLocalFileId', () => {
it('should derive an id from the active file path for legacy state', () => {
const state = createState({
activeLocalFilePath: '/path/a.ts',
openLocalFiles: [
{
filePath: '/path/a.ts',
id: localFileTabId({ filePath: '/path/a.ts', workingDirectory: '/path' }),
workingDirectory: '/path',
},
],
} as Partial<ChatStoreState>);
expect(chatPortalSelectors.activeLocalFileId(state)).toBe(
localFileTabId({ filePath: '/path/a.ts', workingDirectory: '/path' }),
);
});
});
describe('artifactMessageContent', () => {
it('should return empty string when message not found', () => {
const state = createState();
+22 -9
View File
@@ -3,7 +3,8 @@ import { type ChatStoreState } from '@/store/chat';
import { type PortalArtifact } from '@/types/artifact';
import { dbMessageSelectors } from '../message/selectors';
import { type PortalFile, type PortalViewData } from './initialState';
import { getLocalFileTabId } from './helpers';
import { type OpenLocalFileEntry, type PortalFile, type PortalViewData } from './initialState';
import { PortalViewType } from './initialState';
// ============== Core Stack Selectors ==============
@@ -132,17 +133,28 @@ 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 activeLocalFileId = (s: ChatStoreState): string | undefined => {
if (s.activeLocalFileId) return s.activeLocalFileId;
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 file = (s.openLocalFiles ?? []).find((item) => item.filePath === active);
return file ? getLocalFileTabId(file) : undefined;
};
const activeLocalFilePath = (s: ChatStoreState): string | undefined =>
currentLocalFile(s)?.filePath ?? s.activeLocalFilePath;
const openLocalFiles = (s: ChatStoreState): OpenLocalFileEntry[] => s.openLocalFiles ?? [];
const currentLocalFile = (s: ChatStoreState): OpenLocalFileEntry | undefined => {
const active = activeLocalFileId(s);
if (!active) return undefined;
const files = s.openLocalFiles ?? [];
return (
files.find((f) => getLocalFileTabId(f) === active) ?? files.find((f) => f.filePath === active)
);
};
const localFilePath = (s: ChatStoreState) => currentLocalFile(s)?.filePath;
@@ -226,6 +238,7 @@ export const chatPortalSelectors = {
chunkText,
// Local file data
activeLocalFileId,
activeLocalFilePath,
currentLocalFile,
dirtyLocalFileContents,
@@ -100,6 +100,32 @@ describe('systemStatusSelectors', () => {
});
expect(systemStatusSelectors.portalWidth(noPortalWidth)).toBe(400);
});
it('should clamp persisted left panel width to the draggable panel bounds', () => {
expect(
systemStatusSelectors.leftPanelWidth(
merge(initialState, {
status: { leftPanelWidth: 120 },
}),
),
).toBe(240);
expect(
systemStatusSelectors.leftPanelWidth(
merge(initialState, {
status: { leftPanelWidth: 720 },
}),
),
).toBe(400);
expect(
systemStatusSelectors.leftPanelWidth(
merge(initialState, {
status: { leftPanelWidth: '360px' as unknown as number },
}),
),
).toBe(360);
});
});
describe('modelDetailPanelExpandedKeys', () => {
+13 -2
View File
@@ -7,6 +7,18 @@ import {
export const systemStatus = (s: GlobalState) => s.status;
export const NAV_PANEL_MIN_WIDTH = 240;
export const NAV_PANEL_MAX_WIDTH = 400;
const normalizeNavPanelWidth = (width: number | string | undefined): number => {
const parsed = typeof width === 'string' ? Number.parseInt(width) : width;
const fallback = INITIAL_STATUS.leftPanelWidth;
if (!parsed || !Number.isFinite(parsed)) return fallback;
return Math.min(NAV_PANEL_MAX_WIDTH, Math.max(NAV_PANEL_MIN_WIDTH, parsed));
};
const agentBuilderPanelWidth = (s: GlobalState) => s.status.agentBuilderPanelWidth || 360;
const sessionGroupKeys = (s: GlobalState): string[] =>
@@ -213,8 +225,7 @@ const pageAgentPanelWidth = (s: GlobalState) => s.status.pageAgentPanelWidth ||
const showChatHeader = (s: GlobalState) => !s.status.zenMode;
const inZenMode = (s: GlobalState) => s.status.zenMode;
const leftPanelWidth = (s: GlobalState): number => {
const width = s.status.leftPanelWidth;
return typeof width === 'string' ? Number.parseInt(width) : width;
return normalizeNavPanelWidth(s.status.leftPanelWidth);
};
const portalWidth = (s: GlobalState) => s.status.portalWidth || 400;
const filePanelWidth = (s: GlobalState) => s.status.filePanelWidth;