feat: allow control over displaying hidden/dotfiles and .gitignore matches (#179)

* feat: add chat setting to toggle hidden files (dotfiles)

* feat: add toggle to show/hide gitignored files in file browser

* refactor: don't reuse visibility setting for dotfiles toggle

* fix: honor hidden/gitignored toggles across runtimes

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
syntext
2026-01-20 15:20:17 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 6544b12e78
commit 2de0d9d3dd
19 changed files with 639 additions and 157 deletions
@@ -7,6 +7,8 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
type FileInfo = ProjectFileSearchHit;
@@ -29,6 +31,8 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const { addServerFile } = useSessionStore();
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const debouncedQuery = useDebouncedValue(searchQuery, 180);
const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored();
const [files, setFiles] = React.useState<FileInfo[]>([]);
const [loading, setLoading] = React.useState(false);
const [selectedIndex, setSelectedIndex] = React.useState(0);
@@ -115,12 +119,18 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery.toLowerCase();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
let cancelled = false;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80)
searchFiles(currentDirectory, normalizedQueryLower, 80, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
})
.then((hits) => {
if (cancelled) {
return;
@@ -158,7 +168,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return () => {
cancelled = true;
};
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles]);
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -17,6 +17,8 @@ import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
interface FileInfo {
name: string;
path: string;
@@ -48,7 +50,10 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
const isCompact = isMobile;
const { currentDirectory } = useDirectoryStore();
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored();
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const [cacheNonce, setCacheNonce] = React.useState(0);
const [mobileOpen, setMobileOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
@@ -77,7 +82,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
const mapFilesystemEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileInfo[] => (
sortDirectoryItems(entries
.filter((item) => !item.name.startsWith('.'))
.filter((item) => showHidden || !item.name.startsWith('.'))
.map((item) => {
const name = item.name;
const extension = !item.isDirectory && name.includes('.')
@@ -91,13 +96,13 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
extension,
};
}))
), [sortDirectoryItems]);
), [sortDirectoryItems, showHidden]);
const loadDirectory = React.useCallback(async (dirPath: string) => {
setLoading(true);
setError(null);
try {
const entries = await opencodeClient.listLocalDirectory(dirPath);
const entries = await opencodeClient.listLocalDirectory(dirPath, { respectGitignore: !showGitignored });
const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({
name: entry.name,
path: entry.path,
@@ -117,25 +122,26 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
} finally {
setLoading(false);
}
}, [mapFilesystemEntries]);
}, [mapFilesystemEntries, showGitignored]);
const loadDirectoryChildren = React.useCallback(async (dirPath: string) => {
const normalizedDir = dirPath.trim();
if (!normalizedDir) {
return;
}
if (loadedDirsRef.current.has(normalizedDir)) {
const cacheKey = `${normalizedDir}::${cacheNonce}`;
if (loadedDirsRef.current.has(cacheKey)) {
return;
}
if (inFlightDirsRef.current.has(normalizedDir)) {
if (inFlightDirsRef.current.has(cacheKey)) {
return;
}
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.add(normalizedDir);
inFlightDirsRef.current.add(cacheKey);
try {
const entries = await opencodeClient.listLocalDirectory(normalizedDir);
const entries = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore: !showGitignored });
const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({
name: entry.name,
path: entry.path,
@@ -143,7 +149,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
})));
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
loadedDirsRef.current.add(cacheKey);
setChildrenByDir((prev) => ({
...prev,
[normalizedDir]: items,
@@ -161,9 +167,9 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
});
} finally {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
inFlightDirsRef.current.delete(cacheKey);
}
}, [mapFilesystemEntries]);
}, [mapFilesystemEntries, showGitignored, cacheNonce]);
React.useEffect(() => {
if ((open || mobileOpen) && currentDirectory) {
@@ -171,6 +177,10 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
}
}, [open, mobileOpen, currentDirectory, loadDirectory]);
React.useEffect(() => {
setCacheNonce((prev) => prev + 1);
}, [showHidden, showGitignored]);
React.useEffect(() => {
if (!(open || mobileOpen) || !currentDirectory) {
setSearchResults([]);
@@ -178,17 +188,25 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
return;
}
const trimmedQuery = debouncedSearchQuery.trim();
const trimmedQuery = debouncedSearchQuery
.trim()
.replace(/^\.\//, '')
.replace(/^\/+/, '');
if (!trimmedQuery) {
setSearchResults([]);
setSearching(false);
return;
}
const normalizedQuery = trimmedQuery.toLowerCase();
let cancelled = false;
setSearching(true);
searchFiles(currentDirectory, trimmedQuery, 150)
searchFiles(currentDirectory, normalizedQuery, 150, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
})
.then((hits) => {
if (cancelled) {
return;
@@ -217,7 +235,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
return () => {
cancelled = true;
};
}, [open, mobileOpen, currentDirectory, debouncedSearchQuery, searchFiles]);
}, [open, mobileOpen, currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
React.useEffect(() => {
if (!open && !mobileOpen) {
@@ -225,6 +243,10 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
setSearchQuery('');
setSearchResults([]);
setSearching(false);
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
setChildrenByDir({});
setExpandedDirs(new Set());
}
}, [open, mobileOpen]);