Skip to content

Commit 2a7b537

Browse files
y-oktallenhutchison
authored andcommitted
feat: Multi-Directory Workspace Support (part 3: configuration in settings.json) (#5354)
Co-authored-by: Allen Hutchison <[email protected]>
1 parent 6cfe934 commit 2a7b537

File tree

17 files changed

+397
-67
lines changed

17 files changed

+397
-67
lines changed

docs/cli/configuration.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,26 @@ In addition to a project settings file, a project's `.llxprt` directory can cont
264264
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
265265
```
266266

267+
- **`includeDirectories`** (array of strings):
268+
- **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. This allows you to work with files across multiple directories as if they were one. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag.
269+
- **Default:** `[]`
270+
- **Example:**
271+
```json
272+
"includeDirectories": [
273+
"/path/to/another/project",
274+
"../shared-library",
275+
"~/common-utils"
276+
]
277+
```
278+
279+
- **`loadMemoryFromIncludeDirectories`** (boolean):
280+
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `GEMINI.md` files should be loaded from all directories that are added. If set to `false`, `GEMINI.md` should only be loaded from the current directory.
281+
- **Default:** `false`
282+
- **Example:**
283+
```json
284+
"loadMemoryFromIncludeDirectories": true
285+
```
286+
267287
### Example `settings.json`:
268288

269289
```json
@@ -296,7 +316,9 @@ In addition to a project settings file, a project's `.llxprt` directory can cont
296316
"tokenBudget": 100
297317
}
298318
},
299-
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
319+
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
320+
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
321+
"loadMemoryFromIncludeDirectories": true
300322
}
301323
```
302324

packages/cli/src/config/config.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
88
import * as os from 'os';
9+
import * as fs from 'fs';
10+
import * as path from 'path';
911
import { loadCliConfig, parseArguments } from './config';
1012
import { Settings } from './settings';
1113
import { Extension } from './extension';
@@ -66,7 +68,7 @@ vi.mock('@vybestack/llxprt-code-core', async () => {
6668
},
6769
loadEnvironment: vi.fn(),
6870
loadServerHierarchicalMemory: vi.fn(
69-
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
71+
(cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>
7072
Promise.resolve({
7173
memoryContent: extensionPaths?.join(',') || '',
7274
fileCount: extensionPaths?.length || 0,
@@ -511,6 +513,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
511513
);
512514
expect(loadServerHierarchicalMemory).toHaveBeenCalledWith(
513515
expect.any(String),
516+
[],
514517
false,
515518
expect.any(Object),
516519
[
@@ -1041,6 +1044,35 @@ describe('loadCliConfig ideModeFeature', () => {
10411044
});
10421045
});
10431046

1047+
vi.mock('fs', async () => {
1048+
const actualFs = await vi.importActual<typeof fs>('fs');
1049+
const MOCK_CWD1 = process.cwd();
1050+
const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project');
1051+
1052+
const mockPaths = new Set([
1053+
MOCK_CWD1,
1054+
MOCK_CWD2,
1055+
path.resolve(path.sep, 'cli', 'path1'),
1056+
path.resolve(path.sep, 'settings', 'path1'),
1057+
path.join(os.homedir(), 'settings', 'path2'),
1058+
path.join(MOCK_CWD2, 'cli', 'path2'),
1059+
path.join(MOCK_CWD2, 'settings', 'path3'),
1060+
]);
1061+
1062+
return {
1063+
...actualFs,
1064+
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
1065+
statSync: vi.fn((p) => {
1066+
if (mockPaths.has(p.toString())) {
1067+
return { isDirectory: () => true };
1068+
}
1069+
// Fallback for other paths if needed, though the test should be specific.
1070+
return actualFs.statSync(p);
1071+
}),
1072+
realpathSync: vi.fn((p) => p),
1073+
};
1074+
});
1075+
10441076
describe('--profile-load flag functionality', () => {
10451077
const originalArgv = process.argv;
10461078
const originalEnv = { ...process.env };
@@ -1052,6 +1084,9 @@ describe('--profile-load flag functionality', () => {
10521084
// Reset the mock
10531085
mockProfileManager.loadProfile.mockReset();
10541086
mockProfileManager.loadProfile.mockResolvedValue(mockProfile);
1087+
vi.spyOn(process, 'cwd').mockReturnValue(
1088+
path.resolve(path.sep, 'home', 'user', 'project'),
1089+
);
10551090
});
10561091

10571092
afterEach(() => {
@@ -1179,3 +1214,56 @@ describe('--profile-load flag functionality', () => {
11791214
expect(config.getAccessibility()).toEqual({ screenReaderMode: true });
11801215
});
11811216
});
1217+
1218+
describe('loadCliConfig with includeDirectories', () => {
1219+
const originalArgv = process.argv;
1220+
const originalEnv = { ...process.env };
1221+
1222+
beforeEach(() => {
1223+
vi.resetAllMocks();
1224+
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
1225+
process.env.GEMINI_API_KEY = 'test-api-key';
1226+
vi.spyOn(process, 'cwd').mockReturnValue(
1227+
path.resolve(path.sep, 'home', 'user', 'project'),
1228+
);
1229+
});
1230+
1231+
afterEach(() => {
1232+
process.argv = originalArgv;
1233+
process.env = originalEnv;
1234+
vi.restoreAllMocks();
1235+
});
1236+
1237+
it('should combine and resolve paths from settings and CLI arguments', async () => {
1238+
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
1239+
process.argv = [
1240+
'node',
1241+
'script.js',
1242+
'--include-directories',
1243+
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
1244+
];
1245+
const argv = await parseArguments();
1246+
const settings: Settings = {
1247+
includeDirectories: [
1248+
path.resolve(path.sep, 'settings', 'path1'),
1249+
path.join(os.homedir(), 'settings', 'path2'),
1250+
path.join(mockCwd, 'settings', 'path3'),
1251+
],
1252+
};
1253+
const config = await loadCliConfig(settings, [], 'test-session', argv);
1254+
const expected = [
1255+
mockCwd,
1256+
path.resolve(path.sep, 'cli', 'path1'),
1257+
path.join(mockCwd, 'cli', 'path2'),
1258+
path.resolve(path.sep, 'settings', 'path1'),
1259+
path.join(os.homedir(), 'settings', 'path2'),
1260+
path.join(mockCwd, 'settings', 'path3'),
1261+
];
1262+
expect(config.getWorkspaceContext().getDirectories()).toEqual(
1263+
expect.arrayContaining(expected),
1264+
);
1265+
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
1266+
expected.length,
1267+
);
1268+
});
1269+
});

packages/cli/src/config/config.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { getCliVersion } from '../utils/version.js';
3232
import { loadSandboxConfig } from './sandboxConfig.js';
3333
import * as dotenv from 'dotenv';
3434
import * as os from 'node:os';
35+
import { resolvePath } from '../utils/resolvePath.js';
3536

3637
const LLXPRT_DIR = '.llxprt';
3738

@@ -72,6 +73,7 @@ export interface CliArgs {
7273
proxy: string | undefined;
7374
includeDirectories: string[] | undefined;
7475
profileLoad: string | undefined;
76+
loadMemoryFromIncludeDirectories: boolean | undefined;
7577
}
7678

7779
export async function parseArguments(): Promise<CliArgs> {
@@ -244,6 +246,12 @@ export async function parseArguments(): Promise<CliArgs> {
244246
type: 'string',
245247
description: 'Load a saved profile configuration on startup',
246248
})
249+
.option('load-memory-from-include-directories', {
250+
type: 'boolean',
251+
description:
252+
'If true, when refreshing memory, LLXPRT.md files should be loaded from all directories that are added. If false, LLXPRT.md files should only be loaded from the primary working directory.',
253+
default: false,
254+
})
247255
.version(await getCliVersion()) // This will enable the --version flag based on package.json
248256
.alias('v', 'version')
249257
.help()
@@ -271,6 +279,7 @@ export async function parseArguments(): Promise<CliArgs> {
271279
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
272280
export async function loadHierarchicalLlxprtMemory(
273281
currentWorkingDirectory: string,
282+
includeDirectoriesToReadGemini: readonly string[] = [],
274283
debugMode: boolean,
275284
fileService: FileDiscoveryService,
276285
settings: Settings,
@@ -296,6 +305,7 @@ export async function loadHierarchicalLlxprtMemory(
296305
// Directly call the server function with the corrected path.
297306
return loadServerHierarchicalMemory(
298307
effectiveCwd,
308+
includeDirectoriesToReadGemini,
299309
debugMode,
300310
fileService,
301311
extensionContextFilePaths,
@@ -403,9 +413,14 @@ export async function loadCliConfig(
403413
...effectiveSettings.fileFiltering,
404414
};
405415

416+
const includeDirectories = (effectiveSettings.includeDirectories || [])
417+
.map(resolvePath)
418+
.concat((argv.includeDirectories || []).map(resolvePath));
419+
406420
// Call the (now wrapper) loadHierarchicalLlxprtMemory which calls the server's version
407421
const { memoryContent, fileCount } = await loadHierarchicalLlxprtMemory(
408422
process.cwd(),
423+
effectiveSettings.loadMemoryFromIncludeDirectories || argv.loadMemoryFromIncludeDirectories ? includeDirectories : [],
409424
debugMode,
410425
fileService,
411426
effectiveSettings,
@@ -490,7 +505,11 @@ export async function loadCliConfig(
490505
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
491506
sandbox: sandboxConfig,
492507
targetDir: process.cwd(),
493-
includeDirectories: argv.includeDirectories,
508+
includeDirectories,
509+
loadMemoryFromIncludeDirectories:
510+
argv.loadMemoryFromIncludeDirectories ||
511+
effectiveSettings.loadMemoryFromIncludeDirectories ||
512+
false,
494513
debugMode,
495514
question: argv.promptInteractive || argv.prompt || '',
496515
fullContext: argv.allFiles || argv.all_files || false,

packages/cli/src/config/settings.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ describe('Settings Loading and Merging', () => {
112112
expect(settings.merged).toEqual({
113113
customThemes: {},
114114
mcpServers: {},
115+
includeDirectories: [],
115116
});
116117
expect(settings.errors.length).toBe(0);
117118
});
@@ -145,6 +146,7 @@ describe('Settings Loading and Merging', () => {
145146
...systemSettingsContent,
146147
customThemes: {},
147148
mcpServers: {},
149+
includeDirectories: [],
148150
});
149151
});
150152

@@ -178,6 +180,7 @@ describe('Settings Loading and Merging', () => {
178180
...userSettingsContent,
179181
customThemes: {},
180182
mcpServers: {},
183+
includeDirectories: [],
181184
});
182185
});
183186

@@ -209,6 +212,7 @@ describe('Settings Loading and Merging', () => {
209212
...workspaceSettingsContent,
210213
customThemes: {},
211214
mcpServers: {},
215+
includeDirectories: [],
212216
});
213217
});
214218

@@ -246,6 +250,7 @@ describe('Settings Loading and Merging', () => {
246250
contextFileName: 'WORKSPACE_CONTEXT.md',
247251
customThemes: {},
248252
mcpServers: {},
253+
includeDirectories: [],
249254
});
250255
});
251256

@@ -295,6 +300,7 @@ describe('Settings Loading and Merging', () => {
295300
allowMCPServers: ['server1', 'server2'],
296301
customThemes: {},
297302
mcpServers: {},
303+
includeDirectories: [],
298304
});
299305
});
300306

@@ -616,6 +622,40 @@ describe('Settings Loading and Merging', () => {
616622
expect(settings.merged.mcpServers).toEqual({});
617623
});
618624

625+
it('should merge includeDirectories from all scopes', () => {
626+
(mockFsExistsSync as Mock).mockReturnValue(true);
627+
const systemSettingsContent = {
628+
includeDirectories: ['/system/dir'],
629+
};
630+
const userSettingsContent = {
631+
includeDirectories: ['/user/dir1', '/user/dir2'],
632+
};
633+
const workspaceSettingsContent = {
634+
includeDirectories: ['/workspace/dir'],
635+
};
636+
637+
(fs.readFileSync as Mock).mockImplementation(
638+
(p: fs.PathOrFileDescriptor) => {
639+
if (p === getSystemSettingsPath())
640+
return JSON.stringify(systemSettingsContent);
641+
if (p === USER_SETTINGS_PATH)
642+
return JSON.stringify(userSettingsContent);
643+
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
644+
return JSON.stringify(workspaceSettingsContent);
645+
return '{}';
646+
},
647+
);
648+
649+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
650+
651+
expect(settings.merged.includeDirectories).toEqual([
652+
'/system/dir',
653+
'/user/dir1',
654+
'/user/dir2',
655+
'/workspace/dir',
656+
]);
657+
});
658+
619659
it('should handle JSON parsing errors gracefully', () => {
620660
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
621661
const invalidJsonContent = 'invalid json';
@@ -654,6 +694,7 @@ describe('Settings Loading and Merging', () => {
654694
expect(settings.merged).toEqual({
655695
customThemes: {},
656696
mcpServers: {},
697+
includeDirectories: [],
657698
});
658699

659700
// Check that error objects are populated in settings.errors
@@ -1090,6 +1131,7 @@ describe('Settings Loading and Merging', () => {
10901131
...systemSettingsContent,
10911132
customThemes: {},
10921133
mcpServers: {},
1134+
includeDirectories: [],
10931135
});
10941136
});
10951137
});

packages/cli/src/config/settings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ export interface Settings {
139139
// Environment variables to exclude from project .env files
140140
excludedProjectEnvVars?: string[];
141141
dnsResolutionOrder?: DnsResolutionOrder;
142+
143+
includeDirectories?: string[];
144+
145+
loadMemoryFromIncludeDirectories?: boolean;
142146
}
143147

144148
export interface SettingsError {
@@ -194,6 +198,11 @@ export class LoadedSettings {
194198
...(workspace.mcpServers || {}),
195199
...(system.mcpServers || {}),
196200
},
201+
includeDirectories: [
202+
...(system.includeDirectories || []),
203+
...(user.includeDirectories || []),
204+
...(workspace.includeDirectories || []),
205+
],
197206
};
198207
}
199208

packages/cli/src/ui/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,9 @@ const App = (props: AppInternalProps) => {
433433
try {
434434
const { memoryContent, fileCount } = await loadHierarchicalLlxprtMemory(
435435
process.cwd(),
436+
settings.merged.loadMemoryFromIncludeDirectories
437+
? config.getWorkspaceContext().getDirectories()
438+
: [],
436439
config.getDebugMode(),
437440
config.getFileService(),
438441
settings.merged,
@@ -638,6 +641,7 @@ const App = (props: AppInternalProps) => {
638641
openPrivacyNotice,
639642
toggleVimEnabled,
640643
setIsProcessing,
644+
setGeminiMdFileCount,
641645
);
642646

643647
const {
@@ -758,7 +762,7 @@ const App = (props: AppInternalProps) => {
758762
if (config) {
759763
setLlxprtMdFileCount(config.getLlxprtMdFileCount());
760764
}
761-
}, [config]);
765+
}, [config, config.getGeminiMdFileCount]);
762766

763767
const logger = useLogger();
764768
const [userMessages, setUserMessages] = useState<string[]>([]);

0 commit comments

Comments
 (0)