mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 fix(portal): scope local file tabs by working directory (#15732)
This commit is contained in:
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import { createLocalFileTabId } from './helpers';
|
||||
import { createLocalFileScopeKey, createLocalFileTabId } from './helpers';
|
||||
import { PortalViewType } from './initialState';
|
||||
|
||||
const localFileTabId = ({
|
||||
@@ -355,6 +355,12 @@ describe('chatDockSlice', () => {
|
||||
expect(result.current.activeLocalFileId).toBe(
|
||||
localFileTabId({ filePath: '/path/to/file.ts', workingDirectory: '/path/to' }),
|
||||
);
|
||||
expect(result.current.activeLocalFileIdsByScope).toEqual({
|
||||
[createLocalFileScopeKey('/path/to')]: 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 });
|
||||
@@ -657,6 +663,36 @@ describe('chatDockSlice', () => {
|
||||
expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual(['/path/c.ts']);
|
||||
expect(result.current.activeLocalFilePath).toBe('/path/c.ts');
|
||||
});
|
||||
|
||||
it('should only close tabs to the left within the target working directory', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-a/a.ts',
|
||||
workingDirectory: '/project-a',
|
||||
});
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-b/a.ts',
|
||||
workingDirectory: '/project-b',
|
||||
});
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-a/b.ts',
|
||||
workingDirectory: '/project-a',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeLeftLocalFileTabs(
|
||||
localFileTabId({ filePath: '/project-a/b.ts', workingDirectory: '/project-a' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual([
|
||||
'/project-b/a.ts',
|
||||
'/project-a/b.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeRightLocalFileTabs', () => {
|
||||
@@ -697,6 +733,36 @@ describe('chatDockSlice', () => {
|
||||
expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual(['/path/a.ts']);
|
||||
expect(result.current.activeLocalFilePath).toBe('/path/a.ts');
|
||||
});
|
||||
|
||||
it('should only close tabs to the right within the target working directory', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-a/a.ts',
|
||||
workingDirectory: '/project-a',
|
||||
});
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-b/a.ts',
|
||||
workingDirectory: '/project-b',
|
||||
});
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-a/b.ts',
|
||||
workingDirectory: '/project-a',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeRightLocalFileTabs(
|
||||
localFileTabId({ filePath: '/project-a/a.ts', workingDirectory: '/project-a' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual([
|
||||
'/project-a/a.ts',
|
||||
'/project-b/a.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeOtherLocalFileTabs', () => {
|
||||
@@ -723,6 +789,36 @@ describe('chatDockSlice', () => {
|
||||
expect(result.current.activeLocalFilePath).toBe('/path/b.ts');
|
||||
expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.LocalFile });
|
||||
});
|
||||
|
||||
it('should keep tabs from other working directories when closing others', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-a/a.ts',
|
||||
workingDirectory: '/project-a',
|
||||
});
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-b/a.ts',
|
||||
workingDirectory: '/project-b',
|
||||
});
|
||||
result.current.openLocalFile({
|
||||
filePath: '/project-a/b.ts',
|
||||
workingDirectory: '/project-a',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeOtherLocalFileTabs(
|
||||
localFileTabId({ filePath: '/project-a/b.ts', workingDirectory: '/project-a' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.openLocalFiles.map((f) => f.filePath)).toEqual([
|
||||
'/project-b/a.ts',
|
||||
'/project-a/b.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveLocalFile', () => {
|
||||
@@ -739,6 +835,9 @@ describe('chatDockSlice', () => {
|
||||
});
|
||||
|
||||
expect(result.current.activeLocalFilePath).toBe('/path/a.ts');
|
||||
expect(result.current.activeLocalFileIdsByScope[createLocalFileScopeKey('/path')]).toBe(
|
||||
localFileTabId({ filePath: '/path/a.ts', workingDirectory: '/path' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type ChatStore } from '@/store/chat/store';
|
||||
import { type StoreSetter } from '@/store/types';
|
||||
import { type PortalArtifact } from '@/types/artifact';
|
||||
|
||||
import { createLocalFileTabId, getLocalFileTabId } from './helpers';
|
||||
import { createLocalFileScopeKey, createLocalFileTabId, getLocalFileTabId } from './helpers';
|
||||
import { type OpenLocalFileParams, type PortalFile, type PortalViewData } from './initialState';
|
||||
import { PortalViewType } from './initialState';
|
||||
|
||||
@@ -21,8 +21,8 @@ const findLocalFileIndexById = (
|
||||
return index >= 0 ? index : openLocalFiles.findIndex((file) => file.filePath === id);
|
||||
};
|
||||
|
||||
const findLocalFileById = (
|
||||
openLocalFiles: Array<OpenLocalFileParams & { id?: string }>,
|
||||
const findLocalFileById = <T extends OpenLocalFileParams & { id?: string }>(
|
||||
openLocalFiles: T[],
|
||||
id: string | undefined,
|
||||
) =>
|
||||
id
|
||||
@@ -30,8 +30,16 @@ const findLocalFileById = (
|
||||
openLocalFiles.find((file) => file.filePath === id))
|
||||
: undefined;
|
||||
|
||||
const resolveActiveLocalFile = (
|
||||
openLocalFiles: Array<OpenLocalFileParams & { id?: string }>,
|
||||
const getLocalFileEntryScopeKey = (file: OpenLocalFileParams): string =>
|
||||
createLocalFileScopeKey(file.workingDirectory);
|
||||
|
||||
const getLocalFilesInScope = <T extends OpenLocalFileParams & { id?: string }>(
|
||||
openLocalFiles: T[],
|
||||
scopeKey: string,
|
||||
) => openLocalFiles.filter((file) => getLocalFileEntryScopeKey(file) === scopeKey);
|
||||
|
||||
const resolveActiveLocalFile = <T extends OpenLocalFileParams & { id?: string }>(
|
||||
openLocalFiles: T[],
|
||||
activeLocalFileId: string | undefined,
|
||||
activeLocalFilePath: string | undefined,
|
||||
) =>
|
||||
@@ -40,6 +48,72 @@ const resolveActiveLocalFile = (
|
||||
? openLocalFiles.find((file) => file.filePath === activeLocalFilePath)
|
||||
: undefined);
|
||||
|
||||
const resolveActiveLocalFileInScope = <T extends OpenLocalFileParams & { id?: string }>(
|
||||
openLocalFiles: T[],
|
||||
scopeKey: string,
|
||||
activeLocalFileIdsByScope: Record<string, string> | undefined,
|
||||
activeLocalFileId: string | undefined,
|
||||
activeLocalFilePath: string | undefined,
|
||||
) =>
|
||||
findLocalFileById(openLocalFiles, activeLocalFileIdsByScope?.[scopeKey]) ??
|
||||
resolveActiveLocalFile(openLocalFiles, activeLocalFileId, activeLocalFilePath);
|
||||
|
||||
const setActiveLocalFileForScope = (
|
||||
activeLocalFileIdsByScope: Record<string, string> | undefined,
|
||||
scopeKey: string,
|
||||
activeFile: (OpenLocalFileParams & { id?: string }) | undefined,
|
||||
) => {
|
||||
const next = { ...activeLocalFileIdsByScope };
|
||||
|
||||
if (activeFile) {
|
||||
next[scopeKey] = getLocalFileTabId(activeFile);
|
||||
} else {
|
||||
delete next[scopeKey];
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const keepScopedLocalFiles = <T extends OpenLocalFileParams & { id?: string }>(
|
||||
openLocalFiles: T[],
|
||||
scopeKey: string,
|
||||
scopedFilesToKeep: T[],
|
||||
) => {
|
||||
const keepIds = new Set(scopedFilesToKeep.map(getLocalFileTabId));
|
||||
|
||||
return openLocalFiles.filter(
|
||||
(file) => getLocalFileEntryScopeKey(file) !== scopeKey || keepIds.has(getLocalFileTabId(file)),
|
||||
);
|
||||
};
|
||||
|
||||
const resolveLegacyActiveAfterClose = ({
|
||||
activeLocalFileId,
|
||||
activeLocalFilePath,
|
||||
nextScopeActiveFile,
|
||||
nextOpenLocalFiles,
|
||||
openLocalFiles,
|
||||
}: {
|
||||
activeLocalFileId: string | undefined;
|
||||
activeLocalFilePath: string | undefined;
|
||||
nextScopeActiveFile: (OpenLocalFileParams & { id?: string }) | undefined;
|
||||
nextOpenLocalFiles: Array<OpenLocalFileParams & { id?: string }>;
|
||||
openLocalFiles: Array<OpenLocalFileParams & { id?: string }>;
|
||||
}) => {
|
||||
const activeFile = resolveActiveLocalFile(openLocalFiles, activeLocalFileId, activeLocalFilePath);
|
||||
const activeStillOpen =
|
||||
activeFile &&
|
||||
nextOpenLocalFiles.some((file) => getLocalFileTabId(file) === getLocalFileTabId(activeFile));
|
||||
|
||||
if (!activeFile || activeStillOpen) {
|
||||
return { activeLocalFileId, activeLocalFilePath };
|
||||
}
|
||||
|
||||
return {
|
||||
activeLocalFileId: nextScopeActiveFile ? getLocalFileTabId(nextScopeActiveFile) : undefined,
|
||||
activeLocalFilePath: nextScopeActiveFile?.filePath,
|
||||
};
|
||||
};
|
||||
|
||||
type Setter = StoreSetter<ChatStore>;
|
||||
export const chatPortalSlice = (set: Setter, get: () => ChatStore, _api?: unknown) =>
|
||||
new ChatPortalActionImpl(set, get, _api);
|
||||
@@ -87,30 +161,42 @@ export class ChatPortalActionImpl {
|
||||
};
|
||||
|
||||
closeLocalFileTab = (id: string): void => {
|
||||
const { openLocalFiles, activeLocalFileId, activeLocalFilePath, dirtyLocalFileContents } =
|
||||
this.#get();
|
||||
const {
|
||||
activeLocalFileId,
|
||||
activeLocalFileIdsByScope,
|
||||
activeLocalFilePath,
|
||||
dirtyLocalFileContents,
|
||||
openLocalFiles,
|
||||
} = this.#get();
|
||||
const idx = findLocalFileIndexById(openLocalFiles, id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const target = openLocalFiles[idx];
|
||||
const targetId = getLocalFileTabId(target);
|
||||
const scopeKey = getLocalFileEntryScopeKey(target);
|
||||
const scopedFiles = getLocalFilesInScope(openLocalFiles, scopeKey);
|
||||
const scopedIdx = findLocalFileIndexById(scopedFiles, targetId);
|
||||
const nextFiles = openLocalFiles.filter((_, i) => i !== idx);
|
||||
const nextScopedFiles = scopedFiles.filter((_, i) => i !== scopedIdx);
|
||||
|
||||
let nextActiveId: string | undefined;
|
||||
let nextActivePath: string | undefined;
|
||||
const activeFile = resolveActiveLocalFile(
|
||||
openLocalFiles,
|
||||
const scopedActiveFile = resolveActiveLocalFileInScope(
|
||||
scopedFiles,
|
||||
scopeKey,
|
||||
activeLocalFileIdsByScope,
|
||||
activeLocalFileId,
|
||||
activeLocalFilePath,
|
||||
);
|
||||
if (activeFile && getLocalFileTabId(activeFile) === targetId) {
|
||||
const neighbor = nextFiles[idx] ?? nextFiles[idx - 1];
|
||||
nextActiveId = neighbor ? getLocalFileTabId(neighbor) : undefined;
|
||||
nextActivePath = neighbor?.filePath;
|
||||
} else {
|
||||
nextActiveId = activeLocalFileId;
|
||||
nextActivePath = activeLocalFilePath;
|
||||
}
|
||||
const nextScopeActiveFile =
|
||||
scopedActiveFile && getLocalFileTabId(scopedActiveFile) === targetId
|
||||
? (nextScopedFiles[scopedIdx] ?? nextScopedFiles[scopedIdx - 1])
|
||||
: scopedActiveFile;
|
||||
const legacyActive = resolveLegacyActiveAfterClose({
|
||||
activeLocalFileId,
|
||||
activeLocalFilePath,
|
||||
nextOpenLocalFiles: nextFiles,
|
||||
nextScopeActiveFile,
|
||||
openLocalFiles,
|
||||
});
|
||||
|
||||
let nextDirty = dirtyLocalFileContents;
|
||||
const shouldClearDirty =
|
||||
@@ -123,8 +209,13 @@ export class ChatPortalActionImpl {
|
||||
|
||||
this.#set(
|
||||
{
|
||||
activeLocalFileId: nextActiveId,
|
||||
activeLocalFilePath: nextActivePath,
|
||||
activeLocalFileId: legacyActive.activeLocalFileId,
|
||||
activeLocalFileIdsByScope: setActiveLocalFileForScope(
|
||||
activeLocalFileIdsByScope,
|
||||
scopeKey,
|
||||
nextScopeActiveFile,
|
||||
),
|
||||
activeLocalFilePath: legacyActive.activeLocalFilePath,
|
||||
dirtyLocalFileContents: nextDirty,
|
||||
openLocalFiles: nextFiles,
|
||||
},
|
||||
@@ -132,33 +223,57 @@ export class ChatPortalActionImpl {
|
||||
'closeLocalFileTab',
|
||||
);
|
||||
|
||||
if (nextFiles.length === 0) {
|
||||
if (nextScopedFiles.length === 0) {
|
||||
this.#get().closeLocalFile();
|
||||
}
|
||||
};
|
||||
|
||||
closeLeftLocalFileTabs = (id: string): void => {
|
||||
const { openLocalFiles, activeLocalFileId, activeLocalFilePath } = this.#get();
|
||||
const { activeLocalFileId, activeLocalFileIdsByScope, activeLocalFilePath, openLocalFiles } =
|
||||
this.#get();
|
||||
const idx = findLocalFileIndexById(openLocalFiles, id);
|
||||
if (idx <= 0) return;
|
||||
if (idx < 0) return;
|
||||
|
||||
const nextFiles = openLocalFiles.slice(idx);
|
||||
const activeFile = resolveActiveLocalFile(
|
||||
openLocalFiles,
|
||||
const target = openLocalFiles[idx];
|
||||
const scopeKey = getLocalFileEntryScopeKey(target);
|
||||
const scopedFiles = getLocalFilesInScope(openLocalFiles, scopeKey);
|
||||
const scopedIdx = findLocalFileIndexById(scopedFiles, getLocalFileTabId(target));
|
||||
if (scopedIdx <= 0) return;
|
||||
|
||||
const nextScopedFiles = scopedFiles.slice(scopedIdx);
|
||||
const nextFiles = keepScopedLocalFiles(openLocalFiles, scopeKey, nextScopedFiles);
|
||||
const scopedActiveFile = resolveActiveLocalFileInScope(
|
||||
scopedFiles,
|
||||
scopeKey,
|
||||
activeLocalFileIdsByScope,
|
||||
activeLocalFileId,
|
||||
activeLocalFilePath,
|
||||
);
|
||||
const currentActiveId = activeFile ? getLocalFileTabId(activeFile) : undefined;
|
||||
const targetId = getLocalFileTabId(openLocalFiles[idx]);
|
||||
const nextActiveId = nextFiles.some((f) => getLocalFileTabId(f) === currentActiveId)
|
||||
? currentActiveId
|
||||
const currentScopeActiveId = scopedActiveFile ? getLocalFileTabId(scopedActiveFile) : undefined;
|
||||
const targetId = getLocalFileTabId(target);
|
||||
const nextScopeActiveId = nextScopedFiles.some(
|
||||
(f) => getLocalFileTabId(f) === currentScopeActiveId,
|
||||
)
|
||||
? currentScopeActiveId
|
||||
: targetId;
|
||||
const nextActiveFile = findLocalFileById(nextFiles, nextActiveId);
|
||||
const nextScopeActiveFile = findLocalFileById(nextScopedFiles, nextScopeActiveId);
|
||||
const legacyActive = resolveLegacyActiveAfterClose({
|
||||
activeLocalFileId,
|
||||
activeLocalFilePath,
|
||||
nextOpenLocalFiles: nextFiles,
|
||||
nextScopeActiveFile,
|
||||
openLocalFiles,
|
||||
});
|
||||
|
||||
this.#set(
|
||||
{
|
||||
activeLocalFileId: nextActiveId,
|
||||
activeLocalFilePath: nextActiveFile?.filePath,
|
||||
activeLocalFileId: legacyActive.activeLocalFileId,
|
||||
activeLocalFileIdsByScope: setActiveLocalFileForScope(
|
||||
activeLocalFileIdsByScope,
|
||||
scopeKey,
|
||||
nextScopeActiveFile,
|
||||
),
|
||||
activeLocalFilePath: legacyActive.activeLocalFilePath,
|
||||
openLocalFiles: nextFiles,
|
||||
},
|
||||
false,
|
||||
@@ -167,17 +282,24 @@ export class ChatPortalActionImpl {
|
||||
};
|
||||
|
||||
closeOtherLocalFileTabs = (id: string): void => {
|
||||
const { openLocalFiles } = this.#get();
|
||||
const { activeLocalFileIdsByScope, openLocalFiles } = this.#get();
|
||||
const target = findLocalFileById(openLocalFiles, id);
|
||||
if (!target) return;
|
||||
const scopeKey = getLocalFileEntryScopeKey(target);
|
||||
const targetId = getLocalFileTabId(target);
|
||||
const targetFile = { ...target, id: targetId };
|
||||
const nextFiles = keepScopedLocalFiles(openLocalFiles, scopeKey, [targetFile]);
|
||||
|
||||
this.#set(
|
||||
{
|
||||
activeLocalFileId: targetId,
|
||||
activeLocalFileIdsByScope: setActiveLocalFileForScope(
|
||||
activeLocalFileIdsByScope,
|
||||
scopeKey,
|
||||
targetFile,
|
||||
),
|
||||
activeLocalFilePath: target.filePath,
|
||||
openLocalFiles: [targetFile],
|
||||
openLocalFiles: nextFiles,
|
||||
},
|
||||
false,
|
||||
'closeOtherLocalFileTabs',
|
||||
@@ -185,27 +307,51 @@ export class ChatPortalActionImpl {
|
||||
};
|
||||
|
||||
closeRightLocalFileTabs = (id: string): void => {
|
||||
const { openLocalFiles, activeLocalFileId, activeLocalFilePath } = this.#get();
|
||||
const { activeLocalFileId, activeLocalFileIdsByScope, activeLocalFilePath, openLocalFiles } =
|
||||
this.#get();
|
||||
const idx = findLocalFileIndexById(openLocalFiles, id);
|
||||
if (idx < 0 || idx >= openLocalFiles.length - 1) return;
|
||||
if (idx < 0) return;
|
||||
|
||||
const nextFiles = openLocalFiles.slice(0, idx + 1);
|
||||
const activeFile = resolveActiveLocalFile(
|
||||
openLocalFiles,
|
||||
const target = openLocalFiles[idx];
|
||||
const scopeKey = getLocalFileEntryScopeKey(target);
|
||||
const scopedFiles = getLocalFilesInScope(openLocalFiles, scopeKey);
|
||||
const scopedIdx = findLocalFileIndexById(scopedFiles, getLocalFileTabId(target));
|
||||
if (scopedIdx < 0 || scopedIdx >= scopedFiles.length - 1) return;
|
||||
|
||||
const nextScopedFiles = scopedFiles.slice(0, scopedIdx + 1);
|
||||
const nextFiles = keepScopedLocalFiles(openLocalFiles, scopeKey, nextScopedFiles);
|
||||
const scopedActiveFile = resolveActiveLocalFileInScope(
|
||||
scopedFiles,
|
||||
scopeKey,
|
||||
activeLocalFileIdsByScope,
|
||||
activeLocalFileId,
|
||||
activeLocalFilePath,
|
||||
);
|
||||
const currentActiveId = activeFile ? getLocalFileTabId(activeFile) : undefined;
|
||||
const targetId = getLocalFileTabId(openLocalFiles[idx]);
|
||||
const nextActiveId = nextFiles.some((f) => getLocalFileTabId(f) === currentActiveId)
|
||||
? currentActiveId
|
||||
const currentScopeActiveId = scopedActiveFile ? getLocalFileTabId(scopedActiveFile) : undefined;
|
||||
const targetId = getLocalFileTabId(target);
|
||||
const nextScopeActiveId = nextScopedFiles.some(
|
||||
(f) => getLocalFileTabId(f) === currentScopeActiveId,
|
||||
)
|
||||
? currentScopeActiveId
|
||||
: targetId;
|
||||
const nextActiveFile = findLocalFileById(nextFiles, nextActiveId);
|
||||
const nextScopeActiveFile = findLocalFileById(nextScopedFiles, nextScopeActiveId);
|
||||
const legacyActive = resolveLegacyActiveAfterClose({
|
||||
activeLocalFileId,
|
||||
activeLocalFilePath,
|
||||
nextOpenLocalFiles: nextFiles,
|
||||
nextScopeActiveFile,
|
||||
openLocalFiles,
|
||||
});
|
||||
|
||||
this.#set(
|
||||
{
|
||||
activeLocalFileId: nextActiveId,
|
||||
activeLocalFilePath: nextActiveFile?.filePath,
|
||||
activeLocalFileId: legacyActive.activeLocalFileId,
|
||||
activeLocalFileIdsByScope: setActiveLocalFileForScope(
|
||||
activeLocalFileIdsByScope,
|
||||
scopeKey,
|
||||
nextScopeActiveFile,
|
||||
),
|
||||
activeLocalFilePath: legacyActive.activeLocalFilePath,
|
||||
openLocalFiles: nextFiles,
|
||||
},
|
||||
false,
|
||||
@@ -262,8 +408,9 @@ export class ChatPortalActionImpl {
|
||||
};
|
||||
|
||||
openLocalFile = ({ deviceId, filePath, workingDirectory }: OpenLocalFileParams): void => {
|
||||
const { openLocalFiles } = this.#get();
|
||||
const { activeLocalFileIdsByScope, openLocalFiles } = this.#get();
|
||||
const id = createLocalFileTabId({ deviceId, filePath, workingDirectory });
|
||||
const scopeKey = createLocalFileScopeKey(workingDirectory);
|
||||
const exists = openLocalFiles.some((f) => getLocalFileTabId(f) === id);
|
||||
const nextFile = deviceId
|
||||
? { deviceId, filePath, id, workingDirectory }
|
||||
@@ -272,7 +419,16 @@ export class ChatPortalActionImpl {
|
||||
? openLocalFiles.map((file) => (getLocalFileTabId(file) === id ? nextFile : file))
|
||||
: [...openLocalFiles, nextFile];
|
||||
this.#set(
|
||||
{ activeLocalFileId: id, activeLocalFilePath: filePath, openLocalFiles: nextFiles },
|
||||
{
|
||||
activeLocalFileId: id,
|
||||
activeLocalFileIdsByScope: setActiveLocalFileForScope(
|
||||
activeLocalFileIdsByScope,
|
||||
scopeKey,
|
||||
nextFile,
|
||||
),
|
||||
activeLocalFilePath: filePath,
|
||||
openLocalFiles: nextFiles,
|
||||
},
|
||||
false,
|
||||
'openLocalFile',
|
||||
);
|
||||
@@ -280,11 +436,15 @@ export class ChatPortalActionImpl {
|
||||
};
|
||||
|
||||
setActiveLocalFile = (id: string): void => {
|
||||
const { openLocalFiles } = this.#get();
|
||||
const { activeLocalFileIdsByScope, openLocalFiles } = this.#get();
|
||||
const activeFile = findLocalFileById(openLocalFiles, id);
|
||||
const scopeKey = activeFile ? getLocalFileEntryScopeKey(activeFile) : undefined;
|
||||
this.#set(
|
||||
{
|
||||
activeLocalFileId: activeFile ? getLocalFileTabId(activeFile) : id,
|
||||
activeLocalFileIdsByScope: scopeKey
|
||||
? setActiveLocalFileForScope(activeLocalFileIdsByScope, scopeKey, activeFile)
|
||||
: activeLocalFileIdsByScope,
|
||||
activeLocalFilePath: activeFile?.filePath ?? id,
|
||||
},
|
||||
false,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { OpenLocalFileParams } from './initialState';
|
||||
|
||||
const LOCAL_FILE_TAB_LOCAL_DEVICE = 'local';
|
||||
const LOCAL_FILE_GLOBAL_SCOPE = '__global__';
|
||||
|
||||
export const createLocalFileScopeKey = (workingDirectory?: string): string =>
|
||||
workingDirectory || LOCAL_FILE_GLOBAL_SCOPE;
|
||||
|
||||
export const createLocalFileTabId = ({
|
||||
deviceId,
|
||||
|
||||
@@ -61,6 +61,9 @@ export interface ChatPortalState {
|
||||
/** Composite id of the currently active local-file tab; undefined when no tabs open. */
|
||||
activeLocalFileId?: string;
|
||||
|
||||
/** Active local-file tab id keyed by project/root working directory. */
|
||||
activeLocalFileIdsByScope: Record<string, string>;
|
||||
|
||||
/** Path of the currently active tab; kept for legacy consumers that only need display/open path. */
|
||||
activeLocalFilePath?: string;
|
||||
|
||||
@@ -92,6 +95,7 @@ export interface ChatPortalState {
|
||||
}
|
||||
|
||||
export const initialChatPortalState: ChatPortalState = {
|
||||
activeLocalFileIdsByScope: {},
|
||||
dirtyLocalFileContents: {},
|
||||
openLocalFiles: [],
|
||||
portalArtifactDisplayMode: ArtifactDisplayMode.Preview,
|
||||
|
||||
@@ -2,8 +2,9 @@ import { type UIChatMessage } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type ChatStoreState } from '@/store/chat';
|
||||
import { topicMapKey } from '@/store/chat/utils/topicMapKey';
|
||||
|
||||
import { createLocalFileTabId } from './helpers';
|
||||
import { createLocalFileScopeKey, createLocalFileTabId } from './helpers';
|
||||
import { PortalViewType } from './initialState';
|
||||
import { chatPortalSelectors } from './selectors';
|
||||
|
||||
@@ -17,6 +18,25 @@ const localFileTabId = ({
|
||||
workingDirectory: string;
|
||||
}) => createLocalFileTabId({ deviceId, filePath, workingDirectory });
|
||||
|
||||
const createTopicState = (
|
||||
activeTopicId: string,
|
||||
workingDirectoriesByTopic: Record<string, string>,
|
||||
) => ({
|
||||
activeTopicId,
|
||||
topicDataMap: {
|
||||
[topicMapKey({ agentId: 'test-id' })]: {
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
items: Object.entries(workingDirectoriesByTopic).map(([id, workingDirectory]) => ({
|
||||
id,
|
||||
metadata: { workingDirectory },
|
||||
})),
|
||||
pageSize: 20,
|
||||
total: Object.keys(workingDirectoriesByTopic).length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('chatDockSelectors', () => {
|
||||
const createState = (overrides?: Partial<ChatStoreState>) => {
|
||||
const state = {
|
||||
@@ -310,6 +330,40 @@ describe('chatDockSelectors', () => {
|
||||
} as Partial<ChatStoreState>);
|
||||
expect(chatPortalSelectors.currentLocalFile(state)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should restore the active local file for the current topic working directory', () => {
|
||||
const projectAActiveId = localFileTabId({
|
||||
filePath: '/project-a/b.ts',
|
||||
workingDirectory: '/project-a',
|
||||
});
|
||||
const projectBActiveId = localFileTabId({
|
||||
filePath: '/project-b/c.ts',
|
||||
workingDirectory: '/project-b',
|
||||
});
|
||||
const state = createState({
|
||||
...createTopicState('topic-a', {
|
||||
'topic-a': '/project-a',
|
||||
'topic-b': '/project-b',
|
||||
}),
|
||||
activeLocalFileId: projectBActiveId,
|
||||
activeLocalFileIdsByScope: {
|
||||
[createLocalFileScopeKey('/project-a')]: projectAActiveId,
|
||||
[createLocalFileScopeKey('/project-b')]: projectBActiveId,
|
||||
},
|
||||
activeLocalFilePath: '/project-b/c.ts',
|
||||
openLocalFiles: [
|
||||
{ filePath: '/project-a/a.ts', workingDirectory: '/project-a' },
|
||||
{ filePath: '/project-a/b.ts', id: projectAActiveId, workingDirectory: '/project-a' },
|
||||
{ filePath: '/project-b/c.ts', id: projectBActiveId, workingDirectory: '/project-b' },
|
||||
],
|
||||
} as Partial<ChatStoreState>);
|
||||
|
||||
expect(chatPortalSelectors.currentLocalFile(state)).toEqual({
|
||||
filePath: '/project-a/b.ts',
|
||||
id: projectAActiveId,
|
||||
workingDirectory: '/project-a',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('localFilePath', () => {
|
||||
@@ -354,6 +408,23 @@ describe('chatDockSelectors', () => {
|
||||
const state = createState({ openLocalFiles: files } as Partial<ChatStoreState>);
|
||||
expect(chatPortalSelectors.openLocalFiles(state)).toEqual(files);
|
||||
});
|
||||
|
||||
it('should only return files from the current topic working directory', () => {
|
||||
const state = createState({
|
||||
...createTopicState('topic-b', {
|
||||
'topic-a': '/project-a',
|
||||
'topic-b': '/project-b',
|
||||
}),
|
||||
openLocalFiles: [
|
||||
{ filePath: '/project-a/a.ts', workingDirectory: '/project-a' },
|
||||
{ filePath: '/project-b/b.ts', workingDirectory: '/project-b' },
|
||||
],
|
||||
} as Partial<ChatStoreState>);
|
||||
|
||||
expect(chatPortalSelectors.openLocalFiles(state)).toEqual([
|
||||
{ filePath: '/project-b/b.ts', workingDirectory: '/project-b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeLocalFilePath', () => {
|
||||
@@ -367,6 +438,21 @@ describe('chatDockSelectors', () => {
|
||||
} as Partial<ChatStoreState>);
|
||||
expect(chatPortalSelectors.activeLocalFilePath(state)).toBe('/path/a.ts');
|
||||
});
|
||||
|
||||
it('should not leak the previous project active path into a topic with no open files', () => {
|
||||
const state = createState({
|
||||
...createTopicState('topic-b', {
|
||||
'topic-a': '/project-a',
|
||||
'topic-b': '/project-b',
|
||||
}),
|
||||
activeLocalFilePath: '/project-a/a.ts',
|
||||
openLocalFiles: [{ filePath: '/project-a/a.ts', workingDirectory: '/project-a' }],
|
||||
} as Partial<ChatStoreState>);
|
||||
|
||||
expect(chatPortalSelectors.openLocalFiles(state)).toEqual([]);
|
||||
expect(chatPortalSelectors.activeLocalFilePath(state)).toBeUndefined();
|
||||
expect(chatPortalSelectors.currentLocalFile(state)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeLocalFileId', () => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { type ChatStoreState } from '@/store/chat';
|
||||
import { type PortalArtifact } from '@/types/artifact';
|
||||
|
||||
import { dbMessageSelectors } from '../message/selectors';
|
||||
import { getLocalFileTabId } from './helpers';
|
||||
import { topicSelectors } from '../topic/selectors';
|
||||
import { createLocalFileScopeKey, getLocalFileTabId } from './helpers';
|
||||
import { type OpenLocalFileEntry, type PortalFile, type PortalViewData } from './initialState';
|
||||
import { PortalViewType } from './initialState';
|
||||
|
||||
@@ -133,25 +134,48 @@ const previewFileId = (s: ChatStoreState) => currentFile(s)?.fileId;
|
||||
const chunkText = (s: ChatStoreState) => currentFile(s)?.chunkText;
|
||||
|
||||
// Local File selectors
|
||||
const currentLocalFileScopeWorkingDirectory = (s: ChatStoreState): string | undefined =>
|
||||
s.topicDataMap ? topicSelectors.currentTopicWorkingDirectory(s) : undefined;
|
||||
|
||||
const currentLocalFileScopeKey = (s: ChatStoreState): string | undefined => {
|
||||
const workingDirectory = currentLocalFileScopeWorkingDirectory(s);
|
||||
return workingDirectory ? createLocalFileScopeKey(workingDirectory) : undefined;
|
||||
};
|
||||
|
||||
const isLocalFileInCurrentScope = (s: ChatStoreState, file: OpenLocalFileEntry): boolean => {
|
||||
const workingDirectory = currentLocalFileScopeWorkingDirectory(s);
|
||||
return workingDirectory ? file.workingDirectory === workingDirectory : true;
|
||||
};
|
||||
|
||||
const openLocalFiles = (s: ChatStoreState): OpenLocalFileEntry[] =>
|
||||
(s.openLocalFiles ?? []).filter((file) => isLocalFileInCurrentScope(s, file));
|
||||
|
||||
const activeLocalFileId = (s: ChatStoreState): string | undefined => {
|
||||
if (s.activeLocalFileId) return s.activeLocalFileId;
|
||||
const files = openLocalFiles(s);
|
||||
const scopeKey = currentLocalFileScopeKey(s);
|
||||
const scopedActiveId = scopeKey ? s.activeLocalFileIdsByScope?.[scopeKey] : s.activeLocalFileId;
|
||||
|
||||
if (scopedActiveId && files.some((file) => getLocalFileTabId(file) === scopedActiveId)) {
|
||||
return scopedActiveId;
|
||||
}
|
||||
|
||||
const active = s.activeLocalFilePath;
|
||||
if (!active) return undefined;
|
||||
|
||||
const file = (s.openLocalFiles ?? []).find((item) => item.filePath === active);
|
||||
return file ? getLocalFileTabId(file) : undefined;
|
||||
const file = files.find((item) => item.filePath === active);
|
||||
if (file) return getLocalFileTabId(file);
|
||||
|
||||
return scopeKey && files[0] ? getLocalFileTabId(files[0]) : undefined;
|
||||
};
|
||||
|
||||
const activeLocalFilePath = (s: ChatStoreState): string | undefined =>
|
||||
currentLocalFile(s)?.filePath ?? s.activeLocalFilePath;
|
||||
|
||||
const openLocalFiles = (s: ChatStoreState): OpenLocalFileEntry[] => s.openLocalFiles ?? [];
|
||||
currentLocalFile(s)?.filePath ??
|
||||
(currentLocalFileScopeWorkingDirectory(s) ? undefined : s.activeLocalFilePath);
|
||||
|
||||
const currentLocalFile = (s: ChatStoreState): OpenLocalFileEntry | undefined => {
|
||||
const active = activeLocalFileId(s);
|
||||
if (!active) return undefined;
|
||||
const files = s.openLocalFiles ?? [];
|
||||
const files = openLocalFiles(s);
|
||||
return (
|
||||
files.find((f) => getLocalFileTabId(f) === active) ?? files.find((f) => f.filePath === active)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user