Picking a project in Settings called setActiveProject, which relocates the chat, the session list, the file tree and the Git surface. Reading another project's MCP servers or agents moved the user's whole app. It had to, because the configuration stores resolved the directory themselves from the active project and held one flat list. Each of them now takes an explicit directory — omitted still means the active project, so every caller outside Settings is unchanged — and keys loaded data by directory next to a flat mirror of the active project. Chat, autocompletes and pickers keep reading that mirror; a load for another directory writes only the map. A failed load restores that directory's previous list. Settings resolves its own directory through useSettingsDirectory, backed by a session-local settingsProjectPath that follows the active project until the user picks something else.
121 lines
4.2 KiB
TypeScript
121 lines
4.2 KiB
TypeScript
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
|
|
const activeProjectPath = '/workspace/project';
|
|
|
|
let listCommandsWithDetailsCalls = 0;
|
|
let listCommandsWithDetailsImpl: () => Promise<unknown[]> = async () => [];
|
|
let withDirectoryImpl: (_directory: string | null, callback: () => Promise<unknown>) => Promise<unknown> = async (_directory, callback) => callback();
|
|
let getDirectoryImpl: () => string = () => '/fallback/project';
|
|
let runtimeFetchImpl: () => Promise<Response> = async () => new Response(JSON.stringify({ scope: 'project' }), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
const listCommandsWithDetailsMock = async () => {
|
|
listCommandsWithDetailsCalls += 1;
|
|
return listCommandsWithDetailsImpl();
|
|
};
|
|
|
|
const withDirectoryMock = async (directory: string | null, callback: () => Promise<unknown>) => withDirectoryImpl(directory, callback);
|
|
const getDirectoryMock = () => getDirectoryImpl();
|
|
const runtimeFetchMock = async () => runtimeFetchImpl();
|
|
|
|
mock.module('@/lib/opencode/client', () => ({
|
|
opencodeClient: {
|
|
getDirectory: getDirectoryMock,
|
|
listCommandsWithDetails: listCommandsWithDetailsMock,
|
|
withDirectory: withDirectoryMock,
|
|
},
|
|
}));
|
|
|
|
mock.module('@/stores/useProjectsStore', () => ({
|
|
useProjectsStore: {
|
|
getState: () => ({
|
|
getActiveProject: () => ({ path: activeProjectPath }),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
mock.module('@/lib/runtime-fetch', () => ({
|
|
runtimeFetch: runtimeFetchMock,
|
|
}));
|
|
|
|
mock.module('@/lib/configUpdate', () => ({
|
|
startConfigUpdate: mock(() => undefined),
|
|
finishConfigUpdate: mock(() => undefined),
|
|
updateConfigUpdateMessage: mock(() => undefined),
|
|
}));
|
|
|
|
mock.module('@/lib/configSync', () => ({
|
|
emitConfigChange: mock(() => undefined),
|
|
scopeMatches: mock(() => false),
|
|
subscribeToConfigChanges: mock(() => () => undefined),
|
|
}));
|
|
|
|
const { useCommandsStore } = await import('./useCommandsStore');
|
|
|
|
describe('useCommandsStore', () => {
|
|
beforeEach(() => {
|
|
listCommandsWithDetailsCalls = 0;
|
|
listCommandsWithDetailsImpl = async () => [];
|
|
withDirectoryImpl = async (_directory, callback) => callback();
|
|
getDirectoryImpl = () => '/fallback/project';
|
|
runtimeFetchImpl = async () => new Response(JSON.stringify({ scope: 'project' }), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
useCommandsStore.setState({
|
|
selectedCommandName: null,
|
|
commands: [],
|
|
commandsByDirectory: {},
|
|
isLoading: false,
|
|
commandDraft: null,
|
|
});
|
|
});
|
|
|
|
test('loading another project leaves the active project\'s commands alone', async () => {
|
|
// Settings can browse a project the app is not on. Chat reads `commands`,
|
|
// so that list must keep describing the active project.
|
|
const activeCommands = [{
|
|
name: 'active-only',
|
|
description: 'Active project command',
|
|
template: 'run it',
|
|
scope: 'project' as const,
|
|
}];
|
|
useCommandsStore.setState({
|
|
commands: activeCommands,
|
|
commandsByDirectory: { [activeProjectPath]: activeCommands },
|
|
});
|
|
listCommandsWithDetailsImpl = async () => [
|
|
{ name: 'other-only', description: 'Other project command', template: 'run there' },
|
|
];
|
|
|
|
const result = await useCommandsStore.getState().loadCommands('/workspace/other');
|
|
|
|
expect(result).toBe(true);
|
|
const state = useCommandsStore.getState();
|
|
expect(state.commands).toEqual(activeCommands);
|
|
expect(state.commandsByDirectory['/workspace/other']?.map((command) => command.name)).toEqual(['other-only']);
|
|
expect(state.commandsByDirectory[activeProjectPath]).toEqual(activeCommands);
|
|
});
|
|
|
|
test('loadCommands preserves previous commands when the command list fails', async () => {
|
|
const previousCommands = [{
|
|
name: 'existing',
|
|
description: 'Existing command',
|
|
template: 'do the previous thing',
|
|
scope: 'project' as const,
|
|
}];
|
|
useCommandsStore.setState({ commands: previousCommands });
|
|
listCommandsWithDetailsImpl = async () => {
|
|
throw new Error('network down');
|
|
};
|
|
|
|
const result = await useCommandsStore.getState().loadCommands();
|
|
|
|
expect(result).toBe(false);
|
|
expect(listCommandsWithDetailsCalls).toBe(3);
|
|
expect(useCommandsStore.getState().commands).toEqual(previousCommands);
|
|
expect(useCommandsStore.getState().isLoading).toBe(false);
|
|
});
|
|
});
|