From acedb2b5b55abda30b8a28f937a9eaeace539bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 24 May 2026 15:49:35 +0800 Subject: [PATCH] fix: show dotfiles in file lists File browsing and workspace trees were treating every dot-prefixed entry as hidden, which made project files such as .env.example impossible to select through the desktop file surfaces. The fix keeps normal dotfiles and project folders visible while still hiding VCS metadata directories such as .git. Constraint: VCS internals should stay out of user-facing file trees even when dotfiles are visible Rejected: Add a UI toggle for hidden files | issue asks for file lists to include dot-prefixed project entries by default Confidence: high Scope-risk: narrow Directive: Do not reintroduce blanket dotfile filtering on filesystem browse, workspace tree, or path completion paths Tested: bun test src/server/__tests__/filesystem.test.ts src/server/__tests__/workspace-service.test.ts src/utils/suggestions/directoryCompletion.test.ts Tested: bun run check:server Not-tested: desktop browser visual smoke --- src/server/__tests__/filesystem.test.ts | 6 +++ .../__tests__/workspace-service.test.ts | 6 ++- src/server/api/filesystem.ts | 10 ++-- src/server/services/workspaceService.ts | 7 ++- .../suggestions/directoryCompletion.test.ts | 47 +++++++++++++++++++ src/utils/suggestions/directoryCompletion.ts | 10 +++- 6 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 src/utils/suggestions/directoryCompletion.test.ts 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),