Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ version with its date and start a fresh empty `[Unreleased]` above it.
- Editing a queued message now replaces the composer content instead of
appending to it.

### Fixed

- Re-selecting a folder in the external context picker that nests inside
(or contains) an existing entry now refreshes the selection: the
conflicting entry is replaced by the new pick (persistence inherited)
instead of being silently rejected, so the workspace folder visibly
updates after re-selection.

## [1.0.5] - 2026-08-18

### Added
Expand Down
25 changes: 25 additions & 0 deletions src/core/context/external-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ export function findConflictingPath(
return null;
}

/**
* Returns every existing path that nests with the new path (either direction).
* Used by the folder picker's refresh semantics: re-selecting a nested folder
* replaces the conflicting entries instead of being rejected.
*/
export function findAllConflictingPaths(
newPath: string,
existingPaths: string[]
): PathConflict[] {
const normalizedNew = normalizePathForComparison(newPath);
const conflicts: PathConflict[] = [];

for (const existing of existingPaths) {
const normalizedExisting = normalizePathForComparison(existing);

if (normalizedNew.startsWith(normalizedExisting + '/')) {
conflicts.push({ path: existing, type: 'parent' });
} else if (normalizedExisting.startsWith(normalizedNew + '/')) {
conflicts.push({ path: existing, type: 'child' });
}
}

return conflicts;
}

export function getFolderName(p: string): string {
const normalized = normalizePathForDisplay(p);
const segments = normalized.split('/');
Expand Down
43 changes: 35 additions & 8 deletions src/features/chat/ui/input-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Notice, setIcon } from 'obsidian';
import * as os from 'os';
import * as path from 'path';

import { filterValidPaths, findConflictingPath, isDuplicatePath, isValidDirectoryPath, validateDirectoryPath } from '../../../core/context/external-context';
import type { PathConflict } from '../../../core/context/external-context';
import { filterValidPaths, findAllConflictingPaths, findConflictingPath, isDuplicatePath, isValidDirectoryPath, validateDirectoryPath } from '../../../core/context/external-context';
import { expandHomePath, normalizePathForFilesystem } from '../../../core/fs/path';
import type {
ManagedMcpServer,
Expand Down Expand Up @@ -261,14 +262,14 @@ export class ExternalContextSelector {
return;
}

// Check for nested/overlapping paths
const conflict = findConflictingPath(selectedPath, this.externalContextPaths);
if (conflict) {
new Notice(this.formatConflictMessage(selectedPath, conflict), 5000);
return;
// Re-selecting a nested folder refreshes the selection: conflicting
// entries are replaced by the new pick instead of being rejected.
const conflicts = findAllConflictingPaths(selectedPath, this.externalContextPaths);
if (conflicts.length > 0) {
this.replaceConflictingPaths(conflicts, selectedPath);
} else {
this.externalContextPaths = [...this.externalContextPaths, selectedPath];
}

this.externalContextPaths = [...this.externalContextPaths, selectedPath];
this.onChangeCallback?.(this.externalContextPaths);
this.updateDisplay();
this.renderDropdown();
Expand All @@ -278,6 +279,32 @@ export class ExternalContextSelector {
}
}

/**
* Refresh semantics for the folder picker: entries nesting with the
* re-selected folder are replaced by it. Persistence (lock) is inherited
* when any replaced entry was persistent.
*/
private replaceConflictingPaths(conflicts: PathConflict[], selectedPath: string): void {
const replaced = new Set(conflicts.map((conflict) => conflict.path));
const hadPersistent = conflicts.some((conflict) => this.persistentPaths.has(conflict.path));

this.externalContextPaths = [
...this.externalContextPaths.filter((p) => !replaced.has(p)),
selectedPath,
];

if (hadPersistent) {
for (const p of replaced) {
this.persistentPaths.delete(p);
}
this.persistentPaths.add(selectedPath);
this.onPersistenceChangeCallback?.([...this.persistentPaths]);
}

const replacedNames = conflicts.map((conflict) => this.shortenPath(conflict.path)).join(', ');
new Notice(`Replaced ${replacedNames} with "${this.shortenPath(selectedPath)}"`, 5000);
}

/** Formats a conflict error message for display. */
private formatConflictMessage(newPath: string, conflict: { path: string; type: 'parent' | 'child' }): string {
const shortNew = this.shortenPath(newPath);
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/features/chat/ui/input-toolbar.external-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ jest.mock('obsidian', () => ({
// Mock fs
jest.mock('fs');

// Mock electron remote dialog used by the native folder picker
jest.mock(
'electron',
() => ({
remote: {
dialog: { showOpenDialog: jest.fn() },
},
}),
{ virtual: true }
);

// eslint-disable-next-line @typescript-eslint/no-require-imports
const electronMock = require('electron') as {
remote: { dialog: { showOpenDialog: jest.Mock } };
};

// Mock callbacks
function createMockCallbacks() {
return {
Expand Down Expand Up @@ -534,4 +550,64 @@ describe('ExternalContextSelector', () => {
expect(onPersistenceChange).toHaveBeenCalledWith([]);
});
});

describe('folder picker refresh semantics', () => {
const showOpenDialog = electronMock.remote.dialog.showOpenDialog;

async function pickFolder(paths: string[]): Promise<void> {
showOpenDialog.mockResolvedValue({ canceled: false, filePaths: paths });
// openFolderPicker is private; exercised end-to-end here.
await (selector as unknown as { openFolderPicker(): Promise<void> }).openFolderPicker();
}

beforeEach(() => {
showOpenDialog.mockReset();
});

it('replaces a child entry when its parent is re-selected', async () => {
selector.addExternalContext('/vault/kb/qa');

await pickFolder(['/vault/kb']);

expect(selector.getExternalContexts()).toEqual(['/vault/kb']);
});

it('replaces a parent entry when a child is re-selected', async () => {
selector.addExternalContext('/vault/kb');

await pickFolder(['/vault/kb/qa']);

expect(selector.getExternalContexts()).toEqual(['/vault/kb/qa']);
});

it('inherits persistence from a replaced entry', async () => {
const onPersistenceChange = jest.fn();
selector.setOnPersistenceChange(onPersistenceChange);
selector.addExternalContext('/vault/kb');
selector.togglePersistence('/vault/kb');
onPersistenceChange.mockClear();

await pickFolder(['/vault/kb/qa']);

expect(selector.getExternalContexts()).toEqual(['/vault/kb/qa']);
expect(selector.getPersistentPaths()).toEqual(['/vault/kb/qa']);
expect(onPersistenceChange).toHaveBeenCalledWith(['/vault/kb/qa']);
});

it('still appends unrelated folders', async () => {
selector.addExternalContext('/vault/kb');

await pickFolder(['/other/docs']);

expect(selector.getExternalContexts()).toEqual(['/vault/kb', '/other/docs']);
});

it('still rejects exact duplicates', async () => {
selector.addExternalContext('/vault/kb');

await pickFolder(['/vault/kb']);

expect(selector.getExternalContexts()).toEqual(['/vault/kb']);
});
});
});
Loading