mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix(core,cli): enable recursive directory access for (#17094)
This commit is contained in:
@@ -138,7 +138,12 @@ vi.mock('@google/gemini-cli-core', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('./extension-manager.js');
|
vi.mock('./extension-manager.js', () => {
|
||||||
|
const ExtensionManager = vi.fn();
|
||||||
|
ExtensionManager.prototype.loadExtensions = vi.fn();
|
||||||
|
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||||
|
return { ExtensionManager };
|
||||||
|
});
|
||||||
|
|
||||||
// Global setup to ensure clean environment for all tests in this file
|
// Global setup to ensure clean environment for all tests in this file
|
||||||
const originalArgv = process.argv;
|
const originalArgv = process.argv;
|
||||||
@@ -146,6 +151,11 @@ const originalGeminiModel = process.env['GEMINI_MODEL'];
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env['GEMINI_MODEL'];
|
delete process.env['GEMINI_MODEL'];
|
||||||
|
// Restore ExtensionManager mocks by re-assigning them
|
||||||
|
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||||
|
ExtensionManager.prototype.loadExtensions = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -698,6 +708,12 @@ describe('loadCliConfig', () => {
|
|||||||
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
// Restore ExtensionManager mocks that were reset
|
||||||
|
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||||
|
ExtensionManager.prototype.loadExtensions = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
// Other common mocks would be reset here.
|
// Other common mocks would be reset here.
|
||||||
});
|
});
|
||||||
@@ -755,6 +771,63 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||||||
200, // maxDirs
|
200, // maxDirs
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
|
||||||
|
const settings = createTestMergedSettings({
|
||||||
|
context: {
|
||||||
|
includeDirectories: [includeDir],
|
||||||
|
loadMemoryFromIncludeDirectories: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = await parseArguments(settings);
|
||||||
|
await loadCliConfig(settings, 'session-id', argv);
|
||||||
|
|
||||||
|
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
[includeDir],
|
||||||
|
false,
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(ExtensionManager),
|
||||||
|
true,
|
||||||
|
'tree',
|
||||||
|
expect.objectContaining({
|
||||||
|
respectGitIgnore: true,
|
||||||
|
respectGeminiIgnore: true,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const settings = createTestMergedSettings({
|
||||||
|
context: {
|
||||||
|
includeDirectories: ['/path/to/include'],
|
||||||
|
loadMemoryFromIncludeDirectories: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = await parseArguments(settings);
|
||||||
|
await loadCliConfig(settings, 'session-id', argv);
|
||||||
|
|
||||||
|
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
[],
|
||||||
|
false,
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(ExtensionManager),
|
||||||
|
true,
|
||||||
|
'tree',
|
||||||
|
expect.objectContaining({
|
||||||
|
respectGitIgnore: true,
|
||||||
|
respectGeminiIgnore: true,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mergeMcpServers', () => {
|
describe('mergeMcpServers', () => {
|
||||||
|
|||||||
@@ -476,7 +476,9 @@ export async function loadCliConfig(
|
|||||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||||
const result = await loadServerHierarchicalMemory(
|
const result = await loadServerHierarchicalMemory(
|
||||||
cwd,
|
cwd,
|
||||||
[],
|
settings.context?.loadMemoryFromIncludeDirectories || false
|
||||||
|
? includeDirectories
|
||||||
|
: [],
|
||||||
debugMode,
|
debugMode,
|
||||||
fileService,
|
fileService,
|
||||||
extensionManager,
|
extensionManager,
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('command-exists', () => ({
|
vi.mock('command-exists', () => {
|
||||||
default: {
|
const sync = vi.fn();
|
||||||
sync: vi.fn(),
|
return {
|
||||||
},
|
sync,
|
||||||
}));
|
default: {
|
||||||
|
sync,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('node:os', async (importOriginal) => {
|
vi.mock('node:os', async (importOriginal) => {
|
||||||
const actual = await importOriginal();
|
const actual = await importOriginal();
|
||||||
@@ -49,6 +53,8 @@ describe('loadSandboxConfig', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env['SANDBOX'];
|
||||||
|
delete process.env['GEMINI_SANDBOX'];
|
||||||
mockedGetPackageJson.mockResolvedValue({
|
mockedGetPackageJson.mockResolvedValue({
|
||||||
config: { sandboxImageUri: 'default/image' },
|
config: { sandboxImageUri: 'default/image' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -657,10 +657,48 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
refreshAuth: vi.fn(),
|
refreshAuth: vi.fn(),
|
||||||
getRemoteAdminSettings: () => undefined,
|
getRemoteAdminSettings: () => undefined,
|
||||||
setTerminalBackground: vi.fn(),
|
setTerminalBackground: vi.fn(),
|
||||||
|
getToolRegistry: () => ({ getAllTools: () => [] }),
|
||||||
|
getModel: () => 'gemini-pro',
|
||||||
|
getEmbeddingModel: () => 'embedding-model',
|
||||||
|
getCoreTools: () => [],
|
||||||
|
getApprovalMode: () => 'default',
|
||||||
|
getPreviewFeatures: () => false,
|
||||||
|
getTargetDir: () => '/',
|
||||||
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
getTelemetryEnabled: () => false,
|
||||||
|
getTelemetryTarget: () => 'none',
|
||||||
|
getTelemetryOtlpEndpoint: () => '',
|
||||||
|
getTelemetryOtlpProtocol: () => 'grpc',
|
||||||
|
getTelemetryLogPromptsEnabled: () => false,
|
||||||
|
getContinueOnFailedApiCall: () => false,
|
||||||
|
getShellToolInactivityTimeout: () => 0,
|
||||||
|
getTruncateToolOutputThreshold: () => 0,
|
||||||
|
getUseRipgrep: () => false,
|
||||||
|
getUseWriteTodos: () => false,
|
||||||
|
getHooks: () => undefined,
|
||||||
|
getExperiments: () => undefined,
|
||||||
|
getFileFilteringRespectGitIgnore: () => true,
|
||||||
|
getOutputFormat: () => 'text',
|
||||||
|
getFolderTrust: () => false,
|
||||||
|
getPendingIncludeDirectories: () => [],
|
||||||
|
getWorkspaceContext: () => ({ getDirectories: () => ['/'] }),
|
||||||
|
getModelAvailabilityService: () => ({
|
||||||
|
reset: vi.fn(),
|
||||||
|
resetTurn: vi.fn(),
|
||||||
|
}),
|
||||||
|
getBaseLlmClient: () => ({}),
|
||||||
|
getGeminiClient: () => ({}),
|
||||||
|
getContentGenerator: () => ({}),
|
||||||
|
isTrustedFolder: () => true,
|
||||||
|
isYoloModeDisabled: () => true,
|
||||||
|
isPlanEnabled: () => false,
|
||||||
|
isEventDrivenSchedulerEnabled: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||||
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
vi.mocked(loadSandboxConfig).mockResolvedValue({
|
||||||
|
command: 'docker',
|
||||||
|
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
vi.mocked(relaunchOnExitCode).mockImplementation(async (fn) => {
|
vi.mocked(relaunchOnExitCode).mockImplementation(async (fn) => {
|
||||||
await fn();
|
await fn();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe('directoryCommand', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockWorkspaceContext = {
|
mockWorkspaceContext = {
|
||||||
addDirectory: vi.fn(),
|
addDirectory: vi.fn(),
|
||||||
|
addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }),
|
||||||
getDirectories: vi
|
getDirectories: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValue([
|
.mockReturnValue([
|
||||||
@@ -125,9 +126,15 @@ describe('directoryCommand', () => {
|
|||||||
|
|
||||||
it('should call addDirectory and show a success message for a single path', async () => {
|
it('should call addDirectory and show a success message for a single path', async () => {
|
||||||
const newPath = path.normalize('/home/user/new-project');
|
const newPath = path.normalize('/home/user/new-project');
|
||||||
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
|
added: [newPath],
|
||||||
|
failed: [],
|
||||||
|
});
|
||||||
if (!addCommand?.action) throw new Error('No action');
|
if (!addCommand?.action) throw new Error('No action');
|
||||||
await addCommand.action(mockContext, newPath);
|
await addCommand.action(mockContext, newPath);
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
|
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||||
|
newPath,
|
||||||
|
]);
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
@@ -139,10 +146,16 @@ describe('directoryCommand', () => {
|
|||||||
it('should call addDirectory for each path and show a success message for multiple paths', async () => {
|
it('should call addDirectory for each path and show a success message for multiple paths', async () => {
|
||||||
const newPath1 = path.normalize('/home/user/new-project1');
|
const newPath1 = path.normalize('/home/user/new-project1');
|
||||||
const newPath2 = path.normalize('/home/user/new-project2');
|
const newPath2 = path.normalize('/home/user/new-project2');
|
||||||
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
|
added: [newPath1, newPath2],
|
||||||
|
failed: [],
|
||||||
|
});
|
||||||
if (!addCommand?.action) throw new Error('No action');
|
if (!addCommand?.action) throw new Error('No action');
|
||||||
await addCommand.action(mockContext, `${newPath1},${newPath2}`);
|
await addCommand.action(mockContext, `${newPath1},${newPath2}`);
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1);
|
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2);
|
newPath1,
|
||||||
|
newPath2,
|
||||||
|
]);
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
@@ -153,10 +166,11 @@ describe('directoryCommand', () => {
|
|||||||
|
|
||||||
it('should show an error if addDirectory throws an exception', async () => {
|
it('should show an error if addDirectory throws an exception', async () => {
|
||||||
const error = new Error('Directory does not exist');
|
const error = new Error('Directory does not exist');
|
||||||
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => {
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
const newPath = path.normalize('/home/user/invalid-project');
|
const newPath = path.normalize('/home/user/invalid-project');
|
||||||
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
|
added: [],
|
||||||
|
failed: [{ path: newPath, error }],
|
||||||
|
});
|
||||||
if (!addCommand?.action) throw new Error('No action');
|
if (!addCommand?.action) throw new Error('No action');
|
||||||
await addCommand.action(mockContext, newPath);
|
await addCommand.action(mockContext, newPath);
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
@@ -171,10 +185,16 @@ describe('directoryCommand', () => {
|
|||||||
if (!addCommand?.action) throw new Error('No action');
|
if (!addCommand?.action) throw new Error('No action');
|
||||||
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
|
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
|
||||||
const newPath = path.normalize('/home/user/new-project');
|
const newPath = path.normalize('/home/user/new-project');
|
||||||
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
|
added: [newPath],
|
||||||
|
failed: [],
|
||||||
|
});
|
||||||
|
|
||||||
await addCommand.action(mockContext, newPath);
|
await addCommand.action(mockContext, newPath);
|
||||||
|
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
|
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||||
|
newPath,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show an info message for an already added directory', async () => {
|
it('should show an info message for an already added directory', async () => {
|
||||||
@@ -196,13 +216,10 @@ describe('directoryCommand', () => {
|
|||||||
const validPath = path.normalize('/home/user/valid-project');
|
const validPath = path.normalize('/home/user/valid-project');
|
||||||
const invalidPath = path.normalize('/home/user/invalid-project');
|
const invalidPath = path.normalize('/home/user/invalid-project');
|
||||||
const error = new Error('Directory does not exist');
|
const error = new Error('Directory does not exist');
|
||||||
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
(p: string) => {
|
added: [validPath],
|
||||||
if (p === invalidPath) {
|
failed: [{ path: invalidPath, error }],
|
||||||
throw error;
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!addCommand?.action) throw new Error('No action');
|
if (!addCommand?.action) throw new Error('No action');
|
||||||
await addCommand.action(mockContext, `${validPath},${invalidPath}`);
|
await addCommand.action(mockContext, `${validPath},${invalidPath}`);
|
||||||
@@ -290,10 +307,16 @@ describe('directoryCommand', () => {
|
|||||||
if (!addCommand?.action) throw new Error('No action');
|
if (!addCommand?.action) throw new Error('No action');
|
||||||
mockIsPathTrusted.mockReturnValue(true);
|
mockIsPathTrusted.mockReturnValue(true);
|
||||||
const newPath = path.normalize('/home/user/trusted-project');
|
const newPath = path.normalize('/home/user/trusted-project');
|
||||||
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
|
added: [newPath],
|
||||||
|
failed: [],
|
||||||
|
});
|
||||||
|
|
||||||
await addCommand.action(mockContext, newPath);
|
await addCommand.action(mockContext, newPath);
|
||||||
|
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
|
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||||
|
newPath,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show an error for an untrusted directory', async () => {
|
it('should show an error for an untrusted directory', async () => {
|
||||||
@@ -303,7 +326,7 @@ describe('directoryCommand', () => {
|
|||||||
|
|
||||||
await addCommand.action(mockContext, newPath);
|
await addCommand.action(mockContext, newPath);
|
||||||
|
|
||||||
expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalled();
|
expect(mockWorkspaceContext.addDirectories).not.toHaveBeenCalled();
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
|
|||||||
import {
|
import {
|
||||||
expandHomeDir,
|
expandHomeDir,
|
||||||
getDirectorySuggestions,
|
getDirectorySuggestions,
|
||||||
|
batchAddDirectories,
|
||||||
} from '../utils/directoryUtils.js';
|
} from '../utils/directoryUtils.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
@@ -193,14 +194,10 @@ export const directoryCommand: SlashCommand = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pathToAdd of trustedDirs) {
|
if (trustedDirs.length > 0) {
|
||||||
try {
|
const result = batchAddDirectories(workspaceContext, trustedDirs);
|
||||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd));
|
added.push(...result.added);
|
||||||
added.push(pathToAdd);
|
errors.push(...result.errors);
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
errors.push(`Error adding '${pathToAdd}': ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (undefinedTrustDirs.length > 0) {
|
if (undefinedTrustDirs.length > 0) {
|
||||||
@@ -220,17 +217,9 @@ export const directoryCommand: SlashCommand = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const pathToAdd of pathsToProcess) {
|
const result = batchAddDirectories(workspaceContext, pathsToProcess);
|
||||||
try {
|
added.push(...result.added);
|
||||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
|
errors.push(...result.errors);
|
||||||
added.push(pathToAdd.trim());
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
errors.push(
|
|
||||||
`Error adding '${pathToAdd.trim()}': ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await finishAddingDirectories(config, addItem, added, errors);
|
await finishAddingDirectories(config, addItem, added, errors);
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('footer configuration filtering (golden snapshots)', () => {
|
describe('footer configuration filtering (golden snapshots)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('SANDBOX', '');
|
||||||
|
vi.stubEnv('SEATBELT_PROFILE', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders complete footer with all sections visible (baseline)', () => {
|
it('renders complete footer with all sections visible (baseline)', () => {
|
||||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||||
width: 120,
|
width: 120,
|
||||||
|
|||||||
@@ -16,10 +16,26 @@ import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
|
|||||||
|
|
||||||
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
|
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
|
||||||
|
|
||||||
vi.mock('../utils/directoryUtils.js', () => ({
|
vi.mock('../utils/directoryUtils.js', async (importOriginal) => {
|
||||||
expandHomeDir: (p: string) => p, // Simple pass-through for testing
|
const actual =
|
||||||
loadMemoryFromDirectories: vi.fn().mockResolvedValue({ fileCount: 1 }),
|
await importOriginal<typeof import('../utils/directoryUtils.js')>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
expandHomeDir: (p: string) => p, // Simple pass-through for testing
|
||||||
|
batchAddDirectories: (
|
||||||
|
workspaceContext: WorkspaceContext,
|
||||||
|
paths: string[],
|
||||||
|
) => {
|
||||||
|
const result = workspaceContext.addDirectories(paths);
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const failure of result.failed) {
|
||||||
|
errors.push(`Error adding '${failure.path}': ${failure.error.message}`);
|
||||||
|
}
|
||||||
|
return { added: result.added, errors };
|
||||||
|
},
|
||||||
|
loadMemoryFromDirectories: vi.fn().mockResolvedValue({ fileCount: 1 }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../components/MultiFolderTrustDialog.js', () => ({
|
vi.mock('../components/MultiFolderTrustDialog.js', () => ({
|
||||||
MultiFolderTrustDialog: (props: MultiFolderTrustDialogProps) => (
|
MultiFolderTrustDialog: (props: MultiFolderTrustDialogProps) => (
|
||||||
@@ -38,6 +54,7 @@ describe('useIncludeDirsTrust', () => {
|
|||||||
|
|
||||||
mockWorkspaceContext = {
|
mockWorkspaceContext = {
|
||||||
addDirectory: vi.fn(),
|
addDirectory: vi.fn(),
|
||||||
|
addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }),
|
||||||
getDirectories: vi.fn().mockReturnValue([]),
|
getDirectories: vi.fn().mockReturnValue([]),
|
||||||
onDirectoriesChangedListeners: new Set(),
|
onDirectoriesChangedListeners: new Set(),
|
||||||
onDirectoriesChanged: vi.fn(),
|
onDirectoriesChanged: vi.fn(),
|
||||||
@@ -111,23 +128,18 @@ describe('useIncludeDirsTrust', () => {
|
|||||||
'/dir1',
|
'/dir1',
|
||||||
'/dir2',
|
'/dir2',
|
||||||
]);
|
]);
|
||||||
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
(path) => {
|
added: ['/dir1'],
|
||||||
if (path === '/dir2') {
|
failed: [{ path: '/dir2', error: new Error('Test error') }],
|
||||||
throw new Error('Test error');
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
renderTestHook(isTrusted);
|
renderTestHook(isTrusted);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
|
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||||
'/dir1',
|
'/dir1',
|
||||||
);
|
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
|
|
||||||
'/dir2',
|
'/dir2',
|
||||||
);
|
]);
|
||||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
text: expect.stringContaining("Error adding '/dir2': Test error"),
|
text: expect.stringContaining("Error adding '/dir2': Test error"),
|
||||||
@@ -171,6 +183,11 @@ describe('useIncludeDirsTrust', () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
|
added: ['/trusted'],
|
||||||
|
failed: [],
|
||||||
|
});
|
||||||
|
|
||||||
renderTestHook(true);
|
renderTestHook(true);
|
||||||
|
|
||||||
// Opens dialog for undefined trust dir
|
// Opens dialog for undefined trust dir
|
||||||
@@ -193,15 +210,16 @@ describe('useIncludeDirsTrust', () => {
|
|||||||
pendingDirs,
|
pendingDirs,
|
||||||
);
|
);
|
||||||
mockIsPathTrusted.mockReturnValue(true);
|
mockIsPathTrusted.mockReturnValue(true);
|
||||||
|
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||||
|
added: pendingDirs,
|
||||||
|
failed: [],
|
||||||
|
});
|
||||||
|
|
||||||
renderTestHook(true);
|
renderTestHook(true);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
|
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith(
|
||||||
'/trusted1',
|
pendingDirs,
|
||||||
);
|
|
||||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
|
|
||||||
'/trusted2',
|
|
||||||
);
|
);
|
||||||
expect(mockSetCustomDialog).not.toHaveBeenCalled();
|
expect(mockSetCustomDialog).not.toHaveBeenCalled();
|
||||||
expect(mockConfig.clearPendingIncludeDirectories).toHaveBeenCalledTimes(
|
expect(mockConfig.clearPendingIncludeDirectories).toHaveBeenCalledTimes(
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import { type Config } from '@google/gemini-cli-core';
|
||||||
import { loadTrustedFolders } from '../../config/trustedFolders.js';
|
import { loadTrustedFolders } from '../../config/trustedFolders.js';
|
||||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
import { expandHomeDir, batchAddDirectories } from '../utils/directoryUtils.js';
|
||||||
import {
|
import {
|
||||||
debugLogger,
|
debugLogger,
|
||||||
refreshServerHierarchicalMemory,
|
refreshServerHierarchicalMemory,
|
||||||
@@ -79,15 +79,10 @@ export function useIncludeDirsTrust(
|
|||||||
const added: string[] = [];
|
const added: string[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const workspaceContext = config.getWorkspaceContext();
|
const workspaceContext = config.getWorkspaceContext();
|
||||||
for (const pathToAdd of pendingDirs) {
|
|
||||||
try {
|
const result = batchAddDirectories(workspaceContext, pendingDirs);
|
||||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
|
added.push(...result.added);
|
||||||
added.push(pathToAdd.trim());
|
errors.push(...result.errors);
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (added.length > 0 || errors.length > 0) {
|
if (added.length > 0 || errors.length > 0) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
@@ -125,14 +120,10 @@ export function useIncludeDirsTrust(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceContext = config.getWorkspaceContext();
|
const workspaceContext = config.getWorkspaceContext();
|
||||||
for (const pathToAdd of trustedDirs) {
|
if (trustedDirs.length > 0) {
|
||||||
try {
|
const result = batchAddDirectories(workspaceContext, trustedDirs);
|
||||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd));
|
added.push(...result.added);
|
||||||
added.push(pathToAdd);
|
errors.push(...result.errors);
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
errors.push(`Error adding '${pathToAdd}': ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (undefinedTrustDirs.length > 0) {
|
if (undefinedTrustDirs.length > 0) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { opendir } from 'node:fs/promises';
|
import { opendir } from 'node:fs/promises';
|
||||||
import { homedir } from '@google/gemini-cli-core';
|
import { homedir, type WorkspaceContext } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
const MAX_SUGGESTIONS = 50;
|
const MAX_SUGGESTIONS = 50;
|
||||||
const MATCH_BUFFER_MULTIPLIER = 3;
|
const MATCH_BUFFER_MULTIPLIER = 3;
|
||||||
@@ -139,3 +139,28 @@ export async function getDirectorySuggestions(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BatchAddResult {
|
||||||
|
added: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to batch add directories to the workspace context.
|
||||||
|
* Handles expansion and error formatting.
|
||||||
|
*/
|
||||||
|
export function batchAddDirectories(
|
||||||
|
workspaceContext: WorkspaceContext,
|
||||||
|
paths: string[],
|
||||||
|
): BatchAddResult {
|
||||||
|
const result = workspaceContext.addDirectories(
|
||||||
|
paths.map((p) => expandHomeDir(p.trim())),
|
||||||
|
);
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const failure of result.failed) {
|
||||||
|
errors.push(`Error adding '${failure.path}': ${failure.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added: result.added, errors };
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ describe('handleAutoUpdate', () => {
|
|||||||
let mockChildProcess: ChildProcess;
|
let mockChildProcess: ChildProcess;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||||
|
vi.stubEnv('SANDBOX', '');
|
||||||
mockSpawn = vi.fn();
|
mockSpawn = vi.fn();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.spyOn(updateEventEmitter, 'emit');
|
vi.spyOn(updateEventEmitter, 'emit');
|
||||||
@@ -75,6 +77,7 @@ describe('handleAutoUpdate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -801,6 +801,11 @@ export class Config {
|
|||||||
}
|
}
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
|
|
||||||
|
// Add pending directories to workspace context
|
||||||
|
for (const dir of this.pendingIncludeDirectories) {
|
||||||
|
this.workspaceContext.addDirectory(dir);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize centralized FileDiscoveryService
|
// Initialize centralized FileDiscoveryService
|
||||||
const discoverToolsHandle = startupProfiler.start('discover_tools');
|
const discoverToolsHandle = startupProfiler.start('discover_tools');
|
||||||
this.getFileService();
|
this.getFileService();
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { HookSystem } from './hookSystem.js';
|
|||||||
import { Config } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
import { HookType } from './types.js';
|
import { HookType } from './types.js';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
import type { Readable, Writable } from 'node:stream';
|
import type { Readable, Writable } from 'node:stream';
|
||||||
|
|
||||||
@@ -58,13 +61,16 @@ describe('HookSystem Integration', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
|
const testDir = path.join(os.tmpdir(), 'test-hooks');
|
||||||
|
fs.mkdirSync(testDir, { recursive: true });
|
||||||
|
|
||||||
// Create a real config with simple command hook configurations for testing
|
// Create a real config with simple command hook configurations for testing
|
||||||
config = new Config({
|
config = new Config({
|
||||||
model: 'gemini-1.5-flash',
|
model: 'gemini-1.5-flash',
|
||||||
targetDir: '/tmp/test-hooks',
|
targetDir: testDir,
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/tmp/test-hooks',
|
cwd: testDir,
|
||||||
hooks: {
|
hooks: {
|
||||||
BeforeTool: [
|
BeforeTool: [
|
||||||
{
|
{
|
||||||
@@ -141,13 +147,16 @@ describe('HookSystem Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle initialization errors gracefully', async () => {
|
it('should handle initialization errors gracefully', async () => {
|
||||||
|
const invalidDir = path.join(os.tmpdir(), 'test-hooks-invalid');
|
||||||
|
fs.mkdirSync(invalidDir, { recursive: true });
|
||||||
|
|
||||||
// Create a config with invalid hooks to trigger initialization errors
|
// Create a config with invalid hooks to trigger initialization errors
|
||||||
const invalidConfig = new Config({
|
const invalidConfig = new Config({
|
||||||
model: 'gemini-1.5-flash',
|
model: 'gemini-1.5-flash',
|
||||||
targetDir: '/tmp/test-hooks-invalid',
|
targetDir: invalidDir,
|
||||||
sessionId: 'test-session-invalid',
|
sessionId: 'test-session-invalid',
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/tmp/test-hooks-invalid',
|
cwd: invalidDir,
|
||||||
hooks: {
|
hooks: {
|
||||||
BeforeTool: [
|
BeforeTool: [
|
||||||
{
|
{
|
||||||
@@ -254,13 +263,16 @@ describe('HookSystem Integration', () => {
|
|||||||
|
|
||||||
describe('hook disabling via settings', () => {
|
describe('hook disabling via settings', () => {
|
||||||
it('should not execute disabled hooks from settings', async () => {
|
it('should not execute disabled hooks from settings', async () => {
|
||||||
|
const disabledDir = path.join(os.tmpdir(), 'test-hooks-disabled');
|
||||||
|
fs.mkdirSync(disabledDir, { recursive: true });
|
||||||
|
|
||||||
// Create config with two hooks, one enabled and one disabled via settings
|
// Create config with two hooks, one enabled and one disabled via settings
|
||||||
const configWithDisabled = new Config({
|
const configWithDisabled = new Config({
|
||||||
model: 'gemini-1.5-flash',
|
model: 'gemini-1.5-flash',
|
||||||
targetDir: '/tmp/test-hooks-disabled',
|
targetDir: disabledDir,
|
||||||
sessionId: 'test-session-disabled',
|
sessionId: 'test-session-disabled',
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/tmp/test-hooks-disabled',
|
cwd: disabledDir,
|
||||||
hooks: {
|
hooks: {
|
||||||
BeforeTool: [
|
BeforeTool: [
|
||||||
{
|
{
|
||||||
@@ -322,13 +334,16 @@ describe('HookSystem Integration', () => {
|
|||||||
|
|
||||||
describe('hook disabling via command', () => {
|
describe('hook disabling via command', () => {
|
||||||
it('should disable hook when setHookEnabled is called', async () => {
|
it('should disable hook when setHookEnabled is called', async () => {
|
||||||
|
const setEnabledDir = path.join(os.tmpdir(), 'test-hooks-setEnabled');
|
||||||
|
fs.mkdirSync(setEnabledDir, { recursive: true });
|
||||||
|
|
||||||
// Create config with a hook
|
// Create config with a hook
|
||||||
const configForDisabling = new Config({
|
const configForDisabling = new Config({
|
||||||
model: 'gemini-1.5-flash',
|
model: 'gemini-1.5-flash',
|
||||||
targetDir: '/tmp/test-hooks-setEnabled',
|
targetDir: setEnabledDir,
|
||||||
sessionId: 'test-session-setEnabled',
|
sessionId: 'test-session-setEnabled',
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
cwd: '/tmp/test-hooks-setEnabled',
|
cwd: setEnabledDir,
|
||||||
hooks: {
|
hooks: {
|
||||||
BeforeTool: [
|
BeforeTool: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -480,6 +480,7 @@ describe('editor utils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it(`should allow ${editor} when not in sandbox mode`, () => {
|
it(`should allow ${editor} when not in sandbox mode`, () => {
|
||||||
|
vi.stubEnv('SANDBOX', '');
|
||||||
expect(allowEditorTypeInSandbox(editor)).toBe(true);
|
expect(allowEditorTypeInSandbox(editor)).toBe(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -500,6 +501,7 @@ describe('editor utils', () => {
|
|||||||
|
|
||||||
it('should return true for vscode when installed and not in sandbox mode', () => {
|
it('should return true for vscode when installed and not in sandbox mode', () => {
|
||||||
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
|
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
|
||||||
|
vi.stubEnv('SANDBOX', '');
|
||||||
expect(isEditorAvailable('vscode')).toBe(true);
|
expect(isEditorAvailable('vscode')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { isSubpath } from './paths.js';
|
import { isSubpath } from './paths.js';
|
||||||
import { marked, type Token } from 'marked';
|
|
||||||
import { debugLogger } from './debugLogger.js';
|
import { debugLogger } from './debugLogger.js';
|
||||||
|
|
||||||
// Simple console logger for import processing
|
// Simple console logger for import processing
|
||||||
@@ -83,7 +82,7 @@ function hasMessage(err: unknown): err is { message: string } {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to find all code block and inline code regions using marked
|
// Helper to find all code block and inline code regions using regex
|
||||||
/**
|
/**
|
||||||
* Finds all import statements in content without using regex
|
* Finds all import statements in content without using regex
|
||||||
* @returns Array of {start, _end, path} objects for each import found
|
* @returns Array of {start, _end, path} objects for each import found
|
||||||
@@ -154,35 +153,13 @@ function isLetter(char: string): boolean {
|
|||||||
|
|
||||||
function findCodeRegions(content: string): Array<[number, number]> {
|
function findCodeRegions(content: string): Array<[number, number]> {
|
||||||
const regions: Array<[number, number]> = [];
|
const regions: Array<[number, number]> = [];
|
||||||
const tokens = marked.lexer(content);
|
// Regex to match code blocks (inline and multiline)
|
||||||
let offset = 0;
|
// Matches one or more backticks, content (lazy), and same number of backticks
|
||||||
|
const regex = /(`+)([\s\S]*?)\1/g;
|
||||||
function walk(token: Token, baseOffset: number) {
|
let match;
|
||||||
if (token.type === 'code' || token.type === 'codespan') {
|
while ((match = regex.exec(content)) !== null) {
|
||||||
regions.push([baseOffset, baseOffset + token.raw.length]);
|
regions.push([match.index, match.index + match[0].length]);
|
||||||
}
|
|
||||||
|
|
||||||
if ('tokens' in token && token.tokens) {
|
|
||||||
let childOffset = 0;
|
|
||||||
for (const child of token.tokens) {
|
|
||||||
const childIndexInParent = token.raw.indexOf(child.raw, childOffset);
|
|
||||||
if (childIndexInParent === -1) {
|
|
||||||
logger.error(
|
|
||||||
`Could not find child token in parent raw content. Aborting parsing for this branch. Child raw: "${child.raw}"`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
walk(child, baseOffset + childIndexInParent);
|
|
||||||
childOffset = childIndexInParent + child.raw.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const token of tokens) {
|
|
||||||
walk(token, offset);
|
|
||||||
offset += token.raw.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return regions;
|
return regions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -374,6 +374,75 @@ describe('WorkspaceContext with real filesystem', () => {
|
|||||||
expect(dirs1).toEqual(dirs2);
|
expect(dirs1).toEqual(dirs2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addDirectories', () => {
|
||||||
|
it('should add multiple directories and emit one event', () => {
|
||||||
|
const dir3 = path.join(tempDir, 'dir3');
|
||||||
|
fs.mkdirSync(dir3);
|
||||||
|
|
||||||
|
const workspaceContext = new WorkspaceContext(cwd);
|
||||||
|
const listener = vi.fn();
|
||||||
|
workspaceContext.onDirectoriesChanged(listener);
|
||||||
|
|
||||||
|
const result = workspaceContext.addDirectories([otherDir, dir3]);
|
||||||
|
|
||||||
|
expect(workspaceContext.getDirectories()).toContain(otherDir);
|
||||||
|
expect(workspaceContext.getDirectories()).toContain(dir3);
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
expect(result.added).toHaveLength(2);
|
||||||
|
expect(result.failed).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial failures', () => {
|
||||||
|
const workspaceContext = new WorkspaceContext(cwd);
|
||||||
|
const listener = vi.fn();
|
||||||
|
workspaceContext.onDirectoriesChanged(listener);
|
||||||
|
|
||||||
|
const loggerSpy = vi
|
||||||
|
.spyOn(debugLogger, 'warn')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const nonExistent = path.join(tempDir, 'does-not-exist');
|
||||||
|
const result = workspaceContext.addDirectories([otherDir, nonExistent]);
|
||||||
|
|
||||||
|
expect(workspaceContext.getDirectories()).toContain(otherDir);
|
||||||
|
expect(workspaceContext.getDirectories()).not.toContain(nonExistent);
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
expect(loggerSpy).toHaveBeenCalled();
|
||||||
|
expect(result.added).toEqual([otherDir]);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
expect(result.failed[0].path).toBe(nonExistent);
|
||||||
|
expect(result.failed[0].error).toBeDefined();
|
||||||
|
|
||||||
|
loggerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not emit event if no directories added', () => {
|
||||||
|
const workspaceContext = new WorkspaceContext(cwd);
|
||||||
|
const listener = vi.fn();
|
||||||
|
workspaceContext.onDirectoriesChanged(listener);
|
||||||
|
const loggerSpy = vi
|
||||||
|
.spyOn(debugLogger, 'warn')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const nonExistent = path.join(tempDir, 'does-not-exist');
|
||||||
|
const result = workspaceContext.addDirectories([nonExistent]);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(result.added).toHaveLength(0);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
loggerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addDirectory', () => {
|
||||||
|
it('should throw error if directory fails to add', () => {
|
||||||
|
const workspaceContext = new WorkspaceContext(cwd);
|
||||||
|
const nonExistent = path.join(tempDir, 'does-not-exist');
|
||||||
|
|
||||||
|
expect(() => workspaceContext.addDirectory(nonExistent)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WorkspaceContext with optional directories', () => {
|
describe('WorkspaceContext with optional directories', () => {
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { debugLogger } from './debugLogger.js';
|
|||||||
|
|
||||||
export type Unsubscribe = () => void;
|
export type Unsubscribe = () => void;
|
||||||
|
|
||||||
|
export interface AddDirectoriesResult {
|
||||||
|
added: string[];
|
||||||
|
failed: Array<{ path: string; error: Error }>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WorkspaceContext manages multiple workspace directories and validates paths
|
* WorkspaceContext manages multiple workspace directories and validates paths
|
||||||
* against them. This allows the CLI to operate on files from multiple directories
|
* against them. This allows the CLI to operate on files from multiple directories
|
||||||
@@ -31,9 +36,7 @@ export class WorkspaceContext {
|
|||||||
additionalDirectories: string[] = [],
|
additionalDirectories: string[] = [],
|
||||||
) {
|
) {
|
||||||
this.addDirectory(targetDir);
|
this.addDirectory(targetDir);
|
||||||
for (const additionalDirectory of additionalDirectories) {
|
this.addDirectories(additionalDirectories);
|
||||||
this.addDirectory(additionalDirectory);
|
|
||||||
}
|
|
||||||
this.initialDirectories = new Set(this.directories);
|
this.initialDirectories = new Set(this.directories);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,22 +70,49 @@ export class WorkspaceContext {
|
|||||||
* Adds a directory to the workspace.
|
* Adds a directory to the workspace.
|
||||||
* @param directory The directory path to add (can be relative or absolute)
|
* @param directory The directory path to add (can be relative or absolute)
|
||||||
* @param basePath Optional base path for resolving relative paths (defaults to cwd)
|
* @param basePath Optional base path for resolving relative paths (defaults to cwd)
|
||||||
|
* @throws Error if the directory cannot be added
|
||||||
*/
|
*/
|
||||||
addDirectory(directory: string): void {
|
addDirectory(directory: string): void {
|
||||||
try {
|
const result = this.addDirectories([directory]);
|
||||||
const resolved = this.resolveAndValidateDir(directory);
|
if (result.failed.length > 0) {
|
||||||
if (this.directories.has(resolved)) {
|
throw result.failed[0].error;
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.directories.add(resolved);
|
|
||||||
this.notifyDirectoriesChanged();
|
|
||||||
} catch (err) {
|
|
||||||
debugLogger.warn(
|
|
||||||
`[WARN] Skipping unreadable directory: ${directory} (${err instanceof Error ? err.message : String(err)})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds multiple directories to the workspace.
|
||||||
|
* Emits a single change event if any directories are added.
|
||||||
|
* @param directories The directory paths to add
|
||||||
|
* @returns Object containing successfully added directories and failures
|
||||||
|
*/
|
||||||
|
addDirectories(directories: string[]): AddDirectoriesResult {
|
||||||
|
const result: AddDirectoriesResult = { added: [], failed: [] };
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const directory of directories) {
|
||||||
|
try {
|
||||||
|
const resolved = this.resolveAndValidateDir(directory);
|
||||||
|
if (!this.directories.has(resolved)) {
|
||||||
|
this.directories.add(resolved);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
result.added.push(directory);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
debugLogger.warn(
|
||||||
|
`[WARN] Skipping unreadable directory: ${directory} (${error.message})`,
|
||||||
|
);
|
||||||
|
result.failed.push({ path: directory, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
this.notifyDirectoriesChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private resolveAndValidateDir(directory: string): string {
|
private resolveAndValidateDir(directory: string): string {
|
||||||
const absolutePath = path.resolve(this.targetDir, directory);
|
const absolutePath = path.resolve(this.targetDir, directory);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user