mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ 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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user