🐛 fix(portal): scope local file tabs by working directory (#15732)

This commit is contained in:
Arvin Xu
2026-06-13 01:54:44 +08:00
committed by GitHub
parent a9141c8ade
commit da94942d9c
6 changed files with 438 additions and 61 deletions
+100 -1
View File
@@ -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' }),
);
});
});
+211 -51
View File
@@ -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,
+4
View File
@@ -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,
+87 -1
View File
@@ -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', () => {
+32 -8
View File
@@ -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)
);