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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-24 15:49:35 +08:00
parent 89f66a1c25
commit acedb2b5b5
6 changed files with 79 additions and 7 deletions

View File

@ -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)
})

View File

@ -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 },
],
})

View File

@ -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<string, string> = {
'.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<Response> {
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
})

View File

@ -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<string, string> = {
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

View File

@ -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<string>()
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)
})
})

View File

@ -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<string, DirectoryEntry[]>({
@ -49,6 +50,10 @@ const pathCache = new LRUCache<string, PathEntry[]>({
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),