diff --git a/src/server/__tests__/filesystem.test.ts b/src/server/__tests__/filesystem.test.ts index 6c3d0c3a..200f1b92 100644 --- a/src/server/__tests__/filesystem.test.ts +++ b/src/server/__tests__/filesystem.test.ts @@ -65,7 +65,10 @@ describe('filesystem API', () => { it('allows browsing a directory under the user home directory', async () => { const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) cleanupDirs.add(homeFixtureDir) + await fsp.mkdir(path.join(homeFixtureDir, '.config')) + await fsp.mkdir(path.join(homeFixtureDir, '.git')) await fsp.writeFile(path.join(homeFixtureDir, 'note.txt'), 'hello') + await fsp.writeFile(path.join(homeFixtureDir, '.env.local'), 'SECRET=example') const res = await handleFilesystemRoute( '/api/filesystem/browse', @@ -77,6 +80,9 @@ describe('filesystem API', () => { expect(res.status).toBe(200) const body = await res.json() as { entries: Array<{ name: string }> } + expect(body.entries.some((entry) => entry.name === '.config')).toBe(true) + expect(body.entries.some((entry) => entry.name === '.env.local')).toBe(true) + expect(body.entries.some((entry) => entry.name === '.git')).toBe(false) expect(body.entries.some((entry) => entry.name === 'note.txt')).toBe(true) }) diff --git a/src/server/__tests__/workspace-service.test.ts b/src/server/__tests__/workspace-service.test.ts index 408c7c3e..203005de 100644 --- a/src/server/__tests__/workspace-service.test.ts +++ b/src/server/__tests__/workspace-service.test.ts @@ -394,10 +394,12 @@ describe('WorkspaceService', () => { }) }) - it('lists a single directory level with dotfiles excluded and directories first', async () => { + it('lists a single directory level with dotfiles included and directories first', async () => { const workDir = await makeTempDir('workspace-service-tree-') const service = new WorkspaceService(async () => workDir) + await fs.mkdir(path.join(workDir, '.hidden-dir')) + await fs.mkdir(path.join(workDir, '.git')) await fs.mkdir(path.join(workDir, 'b-dir')) await fs.mkdir(path.join(workDir, 'a-dir')) await fs.mkdir(path.join(workDir, 'a-dir', 'inner')) @@ -409,8 +411,10 @@ describe('WorkspaceService', () => { state: 'ok', path: '', entries: [ + { name: '.hidden-dir', path: '.hidden-dir', isDirectory: true }, { name: 'a-dir', path: 'a-dir', isDirectory: true }, { name: 'b-dir', path: 'b-dir', isDirectory: true }, + { name: '.hidden.txt', path: '.hidden.txt', isDirectory: false }, { name: 'z-file.txt', path: 'z-file.txt', isDirectory: false }, ], }) diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index 62426079..61814227 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -26,6 +26,7 @@ type ScoredFilesystemEntry = FilesystemEntry & { } const FILE_SEARCH_TIMEOUT_MS = 10_000 +const VCS_METADATA_DIRECTORY_NAMES = new Set(['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']) const IMAGE_MIME_TYPES: Record = { '.png': 'image/png', @@ -50,6 +51,10 @@ function normalizeComparablePath(filePath: string): string { return process.platform === 'win32' ? resolved.toLowerCase() : resolved } +function isVcsMetadataDirectoryName(name: string): boolean { + return VCS_METADATA_DIRECTORY_NAMES.has(name.toLowerCase()) +} + function isAllowedFilesystemPath(targetPath: string): boolean { const resolvedPath = path.resolve(targetPath) const homeDir = path.resolve(os.homedir()) @@ -159,10 +164,9 @@ async function handleBrowse(url: URL): Promise { const entries = fs.readdirSync(resolvedPath, { withFileTypes: true }) - // Browse mode: show all directories (and optionally files) + // Browse mode: show dot-prefixed project entries while keeping VCS internals hidden. const filtered = entries.filter((e) => { - if (e.name.startsWith('.')) return false - if (e.isDirectory()) return true + if (e.isDirectory()) return !isVcsMetadataDirectoryName(e.name) return includeFiles }) diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index 2d93e290..4df47b9b 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -11,8 +11,13 @@ const MAX_PREVIEW_BYTES = 1024 * 1024 const MAX_UNTRACKED_STAT_BYTES = 256 * 1024 const GIT_TIMEOUT_MS = 5_000 const MAX_GIT_BUFFER_BYTES = 2_000_000 +const VCS_METADATA_DIRECTORY_NAMES = new Set(['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']) const execFile = promisify(execFileCallback) +function isVcsMetadataDirectoryName(name: string): boolean { + return VCS_METADATA_DIRECTORY_NAMES.has(name.toLowerCase()) +} + const LANGUAGE_MAP: Record = { cjs: 'javascript', css: 'css', @@ -512,7 +517,7 @@ export class WorkspaceService { } } const visibleEntries = entries - .filter((entry) => !entry.name.startsWith('.')) + .filter((entry) => !(entry.isDirectory() && isVcsMetadataDirectoryName(entry.name))) .sort((a, b) => { if (a.isDirectory() !== b.isDirectory()) { return a.isDirectory() ? -1 : 1 diff --git a/src/utils/suggestions/directoryCompletion.test.ts b/src/utils/suggestions/directoryCompletion.test.ts new file mode 100644 index 00000000..d50e29ba --- /dev/null +++ b/src/utils/suggestions/directoryCompletion.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { clearPathCache, getDirectoryCompletions, scanDirectory } from './directoryCompletion.js' + +const cleanupDirs = new Set() + +afterEach(async () => { + clearPathCache() + for (const dir of cleanupDirs) { + await fs.rm(dir, { recursive: true, force: true }) + } + cleanupDirs.clear() +}) + +describe('directory completion', () => { + it('includes dot-prefixed directories in directory scans and completions', async () => { + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), 'directory-completion-')) + cleanupDirs.add(fixtureDir) + + await fs.mkdir(path.join(fixtureDir, '.claude')) + await fs.mkdir(path.join(fixtureDir, '.git')) + await fs.mkdir(path.join(fixtureDir, 'src')) + await fs.writeFile(path.join(fixtureDir, '.env'), 'SECRET=example') + + await expect(scanDirectory(fixtureDir)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: '.claude', type: 'directory' }), + expect.objectContaining({ name: 'src', type: 'directory' }), + ]), + ) + await expect(scanDirectory(fixtureDir)).resolves.not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: '.git' }), + ]), + ) + + const completions = await getDirectoryCompletions('./.c', { basePath: fixtureDir }) + expect(completions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayText: '.claude/' }), + ]), + ) + expect(completions.some((completion) => completion.displayText === '.env/')).toBe(false) + }) +}) diff --git a/src/utils/suggestions/directoryCompletion.ts b/src/utils/suggestions/directoryCompletion.ts index 7377ed73..ba5e85f0 100644 --- a/src/utils/suggestions/directoryCompletion.ts +++ b/src/utils/suggestions/directoryCompletion.ts @@ -36,6 +36,7 @@ type ParsedPath = { // Cache configuration const CACHE_SIZE = 500 const CACHE_TTL = 5 * 60 * 1000 // 5 minutes +const VCS_METADATA_DIRECTORY_NAMES = new Set(['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']) // Initialize LRU cache for directory scans const directoryCache = new LRUCache({ @@ -49,6 +50,10 @@ const pathCache = new LRUCache({ ttl: CACHE_TTL, }) +function isVcsMetadataDirectoryName(name: string): boolean { + return VCS_METADATA_DIRECTORY_NAMES.has(name.toLowerCase()) +} + /** * Parses a partial path into directory and prefix components */ @@ -95,9 +100,10 @@ export async function scanDirectory( const fs = getFsImplementation() const entries = await fs.readdir(dirPath) - // Filter for directories only, exclude hidden directories + // Filter for directories only. Dot-prefixed project folders like .claude and .github + // are valid navigation targets; VCS internals remain hidden. const directories = entries - .filter(entry => entry.isDirectory() && !entry.name.startsWith('.')) + .filter(entry => entry.isDirectory() && !isVcsMetadataDirectoryName(entry.name)) .map(entry => ({ name: entry.name, path: join(dirPath, entry.name),