diff --git a/apps/desktop/electron/git-repo-scan.test.ts b/apps/desktop/electron/git-repo-scan.test.ts index 43f8302fc2c8..1ad003576372 100644 --- a/apps/desktop/electron/git-repo-scan.test.ts +++ b/apps/desktop/electron/git-repo-scan.test.ts @@ -11,11 +11,13 @@ const tempDirs: string[] = [] function tempDir(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-repo-scan-')) tempDirs.push(dir) + return dir } function makeRepo(root: string, valid = true): void { fs.mkdirSync(path.join(root, '.git'), { recursive: true }) + if (valid) { fs.writeFileSync(path.join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n') } @@ -23,6 +25,7 @@ function makeRepo(root: string, valid = true): void { afterEach(() => { vi.restoreAllMocks() + for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { force: true, recursive: true }) } diff --git a/apps/desktop/electron/git-repo-scan.ts b/apps/desktop/electron/git-repo-scan.ts index 4e41a69edf3b..ce5b60368c85 100644 --- a/apps/desktop/electron/git-repo-scan.ts +++ b/apps/desktop/electron/git-repo-scan.ts @@ -36,11 +36,13 @@ export function normalizeRepoScanPath(rawPath: string, options: RepoScanPathOpti const homeDir = options.homeDir ?? os.homedir() const pathApi = pathApiFor(platform) const raw = String(rawPath ?? '').trim() + if (!raw) { return null } let expanded = raw + if (raw === '~') { expanded = homeDir } else if (raw.startsWith('~/') || raw.startsWith('~\\')) { @@ -50,6 +52,7 @@ export function normalizeRepoScanPath(rawPath: string, options: RepoScanPathOpti const absolute = pathApi.isAbsolute(expanded) ? expanded : pathApi.resolve(homeDir, expanded) const value = pathApi.normalize(absolute) const key = platform === 'win32' ? value.toLocaleLowerCase('en-US') : value + return { key, value } } @@ -58,10 +61,13 @@ export function repoScanPathIsWithin(candidate: string, parent: string, options: const pathApi = pathApiFor(platform) const candidatePath = normalizeRepoScanPath(candidate, options) const parentPath = normalizeRepoScanPath(parent, options) + if (!candidatePath || !parentPath) { return false } + const relative = pathApi.relative(parentPath.key, candidatePath.key) + return ( relative === '' || (relative !== '..' && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative)) ) @@ -95,6 +101,7 @@ export async function scanGitRepos(roots: string[], options: RepoScanOptions = { const maxDepth = Number.isFinite(maxDepthValue) && maxDepthValue >= 0 ? maxDepthValue : DEFAULT_MAX_DEPTH const pathOptions: RepoScanPathOptions = {} const requestedRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] + const searchRoots = [ ...new Map( requestedRoots @@ -103,9 +110,11 @@ export async function scanGitRepos(roots: string[], options: RepoScanOptions = { .map(entry => [entry.key, entry.value]) ).values() ] + const exclusions = (options.excludePaths ?? []) .map(excluded => normalizeRepoScanPath(excluded, pathOptions)) .filter((entry): entry is NormalizedScanPath => entry !== null) + const found = new Map() function isExcluded(candidate: string): boolean { @@ -118,6 +127,7 @@ export async function scanGitRepos(roots: string[], options: RepoScanOptions = { } let entries: fs.Dirent[] + try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { @@ -125,28 +135,34 @@ export async function scanGitRepos(roots: string[], options: RepoScanOptions = { } const gitDir = entries.find(entry => entry.name === '.git' && entry.isDirectory()) + if (gitDir) { try { await fsp.access(path.join(dir, '.git', 'HEAD'), fs.constants.R_OK) } catch { return } + const normalized = normalizeRepoScanPath(dir, pathOptions) + if (normalized) { found.set(normalized.key, { root: normalized.value, label: path.basename(normalized.value) || normalized.value }) } + return } const subdirs = entries .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !JUNK_DIRS.has(entry.name)) .map(entry => path.join(dir, entry.name)) + await mapLimit(subdirs, MAX_CONCURRENCY, subdir => walk(subdir, depth + 1)) } await mapLimit(searchRoots, MAX_CONCURRENCY, root => walk(root, 0)) + return [...found.values()] } diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts index 8dea6d97d552..259cae81cc07 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts @@ -22,6 +22,7 @@ const { renameSession, request, activeGateway } = vi.hoisted(() => ({ request: vi.fn(async () => ({ title: 'rpc-title' }) as never), activeGateway: vi.fn<() => { request: unknown } | null>(() => ({ request: undefined })) })) + // Wire activeGateway's default return to the shared request mock now that it exists. activeGateway.mockReturnValue({ request }) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index cdd2a645cb77..1d8a2de0127f 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -137,19 +137,23 @@ export function ConfigSettings({ void (async () => { try { const result = await saveHermesConfig(config) + if (!result.ok) { throw new Error(c.autosaveFailed) } + // Mirror the saved record into the shared cache so MCP/model surfaces // reflect the edit without their own refetch. setHermesConfigCache(config) if (saveVersionRef.current === v) { const discoverySignature = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(config)) + if (savedDiscoverySignatureRef.current !== discoverySignature) { savedDiscoverySignatureRef.current = discoverySignature await scanAndRecordRepos(true) } + onConfigSaved?.() } } catch (err) { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index e33952904260..38b8e2fb85e9 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -231,6 +231,7 @@ export function setApiRequestProfile(profile: null | string): void { function profileScoped(profile?: null | string): { profile?: string } { const selected = profile === undefined ? _apiProfile : profile + return selected ? { profile: selected } : {} } diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index a2365b523de0..678bcd1f3f7d 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -90,10 +90,7 @@ if ( // Inverse: persisted/default active id is still the live-preview tab, but that // target isn't open and file tabs are. Point at the first file tab so ⌘W and // the strip agree before React's fallback sync runs. -if ( - $rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID && - $filePreviewTabs.get().length > 0 -) { +if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID && $filePreviewTabs.get().length > 0) { selectRightRailTab($filePreviewTabs.get()[0]!.id) } diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index 69b2dae04b05..db19c1d5c6fe 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -292,6 +292,7 @@ describe('repository discovery policy', () => { const gateway = { connectionState: 'open', request } activeGateway.mockReturnValue(gateway as never) gatewayAtom.set(gateway as never) + return gateway } @@ -301,6 +302,7 @@ describe('repository discovery policy', () => { ? { active_id: null, projects: [], scoped_session_ids: [] } : { accepted: false, repos: [] } ) + gatewayWith(request) const scanRepos = vi.fn() desktopGit.mockReturnValue({ scanRepos } as never) @@ -327,6 +329,7 @@ describe('repository discovery policy', () => { ? { active_id: null, projects: [], scoped_session_ids: [] } : { accepted: true, repos: [] } ) + gatewayWith(request) const scanRepos = vi.fn().mockResolvedValue([{ label: 'repo', root: '/work/repo' }]) desktopGit.mockReturnValue({ scanRepos } as never) @@ -370,10 +373,13 @@ describe('repository discovery policy', () => { describe('project tree profile isolation', () => { it('does not publish a late response from the previous profile', async () => { let resolveA: ((value: unknown) => void) | undefined + const responseA = new Promise(resolve => { resolveA = resolve }) + const gatewayA = { connectionState: 'open', request: vi.fn(() => responseA) } + const gatewayB = { connectionState: 'open', request: vi.fn().mockResolvedValue({ @@ -382,6 +388,7 @@ describe('project tree profile isolation', () => { scoped_session_ids: [] }) } + let current = gatewayA activeGateway.mockImplementation(() => current as never) gatewayAtom.set(gatewayA as never) diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 5dfd6d250875..29af6fb91234 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -282,12 +282,15 @@ interface ActiveProjectsContext { async function activeProjectsContext(): Promise { const profile = $activeGatewayProfile.get() || 'default' let gateway = activeGateway() + if (!gateway || gateway.connectionState !== 'open') { gateway = await ensureActiveGatewayOpen() } + if (!gateway || gateway !== activeGateway() || profile !== ($activeGatewayProfile.get() || 'default')) { throw new Error('Active Hermes profile changed while connecting') } + return { gateway, profile } } @@ -318,13 +321,16 @@ let projectTreeRefreshGeneration = 0 async function refreshProjectTreeOn(gateway: HermesGateway): Promise { const generation = ++projectTreeRefreshGeneration + if (activeGateway() === gateway) { $projectTreeLoading.set(true) } + try { const res = await gatewayRequestOn(gateway, 'projects.tree', { preview_limit: 3 }) + if (generation !== projectTreeRefreshGeneration || activeGateway() !== gateway) { return } @@ -333,12 +339,15 @@ async function refreshProjectTreeOn(gateway: HermesGateway): Promise { $projectTree.set(res.projects ?? []) $activeProjectId.set(res.active_id ?? null) const tombstones = $removedSessionIds.get() + if (tombstones.size) { const pending = new Set([...tombstones].filter(id => scoped.has(id))) + if (pending.size !== tombstones.size) { $removedSessionIds.set(pending) } } + markProjectsRpcSuccess() } catch (err) { if (activeGateway() === gateway) { @@ -386,6 +395,7 @@ export interface RepoDiscoveryPolicy { export function repoDiscoveryPolicyFromConfig(config: unknown): RepoDiscoveryPolicy { const desktopValue = config && typeof config === 'object' ? (config as { desktop?: unknown }).desktop : undefined + const desktop = desktopValue && typeof desktopValue === 'object' ? (desktopValue as { @@ -394,6 +404,7 @@ export function repoDiscoveryPolicyFromConfig(config: unknown): RepoDiscoveryPol repo_scan_roots?: unknown }) : {} + return { enabled: desktop.repo_scan_enabled !== false, roots: Array.isArray(desktop.repo_scan_roots) @@ -431,6 +442,7 @@ export async function scanAndRecordRepos(force = false): Promise { } let context: ActiveProjectsContext + try { context = await activeProjectsContext() } catch { @@ -438,6 +450,7 @@ export async function scanAndRecordRepos(force = false): Promise { } const scan = desktopGit()?.scanRepos + if (!scan) { return } @@ -449,12 +462,14 @@ export async function scanAndRecordRepos(force = false): Promise { try { const policy = repoDiscoveryPolicyFromConfig(await getHermesConfig(context.profile)) const signature = repoDiscoveryPolicySignature(policy) + if (!force && (state.completedSignature === signature || state.runningSignature === signature)) { return } generation = ++state.generation state.runningSignature = signature + if (!policy.enabled) { await gatewayRequestOn(context.gateway, 'projects.record_repos', { discovery_policy: policy, @@ -463,13 +478,16 @@ export async function scanAndRecordRepos(force = false): Promise { } else { scanningGatewayGenerations.set(context.gateway, generation) syncReposScanning() + const repos = await scan(policy.roots, { enabled: true, excludePaths: policy.exclude_paths }) + if (state.generation !== generation) { return } + await gatewayRequestOn(context.gateway, 'projects.record_repos', { discovery_policy: policy, repos @@ -479,15 +497,18 @@ export async function scanAndRecordRepos(force = false): Promise { if (state.generation !== generation) { return } + state.completedSignature = signature await refreshProjectTreeOn(context.gateway) } catch { state.completedSignature = undefined } finally { state.runningSignature = undefined + if (scanningGatewayGenerations.get(context.gateway) === generation) { scanningGatewayGenerations.delete(context.gateway) } + syncReposScanning() } }