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]);
@@ -5,10 +5,12 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
export const GitSettings: React.FC = () => {
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled);
const showGitignored = useFilesViewShowGitignored();
const [isLoading, setIsLoading] = React.useState(true);
@@ -85,7 +87,7 @@ export const GitSettings: React.FC = () => {
}
return (
<div className="space-y-4">
<div className="space-y-6">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Commit Messages</h3>
@@ -116,6 +118,29 @@ export const GitSettings: React.FC = () => {
</p>
</div>
</div>
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Files Overview</h3>
<p className="typography-meta text-muted-foreground">
Show gitignored files in the Files browser pane only.
</p>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary"
checked={showGitignored}
onChange={(event) => setFilesViewShowGitignored(event.target.checked)}
/>
<span className="typography-ui-label text-foreground">Display gitignored files</span>
</label>
<p className="typography-meta text-muted-foreground pl-5.5">
Toggles gitignored files in the Files tree and search results.
</p>
</div>
</div>
</div>
);
};
@@ -81,7 +81,7 @@ const VisualSectionContent: React.FC = () => {
// Chat section: Default Tool Output, Diff layout, Show reasoning traces, Queue mode
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'reasoning', 'queueMode']} />;
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'dotfiles', 'reasoning', 'queueMode']} />;
};
// Sessions section: Default model & agent, Session retention, Memory limits
@@ -10,6 +10,10 @@ import { ButtonSmall } from '@/components/ui/button-small';
import { NumberInput } from '@/components/ui/number-input';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
} from '@/lib/directoryShowHidden';
interface Option<T extends string> {
id: T;
@@ -69,7 +73,7 @@ const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
},
];
export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'reasoning' | 'queueMode';
export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'dotfiles' | 'reasoning' | 'queueMode';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -78,6 +82,7 @@ interface OpenChamberVisualSettingsProps {
export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> = ({ visibleSettings }) => {
const { isMobile } = useDeviceInfo();
const directoryShowHidden = useDirectoryShowHidden();
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const toolCallExpansion = useUIStore(state => state.toolCallExpansion);
@@ -474,6 +479,38 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{DIFF_VIEW_MODE_OPTIONS.find((option) => option.id === diffViewMode)?.description}
</p>
</div>
</div>
)}
{shouldShow('dotfiles') && !isVSCodeRuntime() && (
<div className="space-y-3">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Hidden files (Chat)
</h3>
<p className="typography-meta text-muted-foreground/80">
Show or hide dotfiles in file lists and directory pickers.
</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-1 w-fit">
{[
{ id: 'hide', label: 'Hide', value: false },
{ id: 'show', label: 'Show', value: true },
].map((option) => (
<ButtonSmall
key={option.id}
variant={directoryShowHidden === option.value ? 'default' : 'outline'}
className={cn(directoryShowHidden === option.value ? undefined : 'text-foreground')}
onClick={() => setDirectoryShowHidden(option.value)}
>
{option.label}
</ButtonSmall>
))}
</div>
</div>
</div>
)}
@@ -22,8 +22,10 @@ import {
import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { DirectoryAutocomplete, type DirectoryAutocompleteHandle } from './DirectoryAutocomplete';
const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
} from '@/lib/directoryShowHidden';
interface DirectoryExplorerDialogProps {
open: boolean;
@@ -40,21 +42,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const [pathInputValue, setPathInputValue] = React.useState('');
const [hasUserSelection, setHasUserSelection] = React.useState(false);
const [isConfirming, setIsConfirming] = React.useState(false);
const [showHidden, setShowHidden] = React.useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
try {
const stored = window.localStorage.getItem(SHOW_HIDDEN_STORAGE_KEY);
if (stored === 'true') {
return true;
}
if (stored === 'false') {
return false;
}
} catch { /* ignored */ }
return false;
});
const showHidden = useDirectoryShowHidden();
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
const { isMobile } = useDeviceInfo();
const [autocompleteVisible, setAutocompleteVisible] = React.useState(false);
@@ -92,15 +80,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
}
}, [open, hasUserSelection, pendingPath, homeDirectory, isHomeReady]);
// Persist show hidden setting
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(SHOW_HIDDEN_STORAGE_KEY, showHidden ? 'true' : 'false');
} catch { /* ignored */ }
}, [showHidden]);
const handleClose = React.useCallback(() => {
onOpenChange(false);
@@ -220,8 +199,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
}, []);
const toggleShowHidden = React.useCallback(() => {
setShowHidden(prev => !prev);
}, []);
setDirectoryShowHidden(!showHidden);
}, [showHidden]);
+63 -35
View File
@@ -26,6 +26,7 @@ import { Textarea } from '@/components/ui/textarea';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import {
Dialog,
DialogContent,
@@ -48,6 +49,8 @@ import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
type FileNode = {
name: string;
@@ -232,7 +235,10 @@ const getFileIcon = (extension?: string): React.ReactNode => {
export const FilesView: React.FC = () => {
const { files, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const { isMobile, screenWidth } = useDeviceInfo();
const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored();
const currentDirectory = useEffectiveDirectory();
const root = normalizePath(currentDirectory);
@@ -400,7 +406,8 @@ export const FilesView: React.FC = () => {
const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => {
const nodes = entries
.filter((entry) => entry && typeof entry.name === 'string' && entry.name.length > 0)
.filter((entry) => !shouldIgnoreEntryName(entry.name))
.filter((entry) => showHidden || !entry.name.startsWith('.'))
.filter((entry) => showGitignored || !shouldIgnoreEntryName(entry.name))
.map<FileNode>((entry) => {
const name = entry.name;
const path = normalizePath(entry.path || `${dirPath}/${name}`);
@@ -415,7 +422,7 @@ export const FilesView: React.FC = () => {
});
return sortNodes(nodes);
}, []);
}, [showGitignored, showHidden]);
const loadDirectory = React.useCallback(async (dirPath: string) => {
const normalizedDir = normalizePath(dirPath.trim());
@@ -431,17 +438,17 @@ export const FilesView: React.FC = () => {
inFlightDirsRef.current.add(normalizedDir);
try {
// Use gitignore filtering for both desktop and web
const respectGitignore = !showGitignored;
let entries: Array<{ name: string; path: string; isDirectory: boolean }>;
if (runtime.isDesktop) {
const result = await files.listDirectory(normalizedDir, { respectGitignore: true });
const result = await files.listDirectory(normalizedDir, { respectGitignore });
entries = result.entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
}));
} else {
const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore: true });
const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore });
entries = result.map((entry) => ({
name: entry.name,
path: entry.path,
@@ -463,7 +470,7 @@ export const FilesView: React.FC = () => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
}
}, [files, mapDirectoryEntries, runtime.isDesktop]);
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
const refreshRoot = React.useCallback(async () => {
const normalizedRoot = normalizePath(currentDirectory.trim());
@@ -494,6 +501,14 @@ export const FilesView: React.FC = () => {
setShowMobilePageContent(false);
}, [currentDirectory, refreshRoot]);
React.useEffect(() => {
if (!currentDirectory) {
return;
}
void refreshRoot();
}, [currentDirectory, refreshRoot, showGitignored]);
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
if (!q) {
@@ -544,6 +559,14 @@ export const FilesView: React.FC = () => {
return score;
}, []);
React.useEffect(() => {
if (!currentDirectory) {
return;
}
void refreshRoot();
}, [currentDirectory, refreshRoot, showGitignored]);
React.useEffect(() => {
if (!currentDirectory) {
setSearchResults([]);
@@ -562,13 +585,16 @@ export const FilesView: React.FC = () => {
let cancelled = false;
setSearching(true);
searchFiles(currentDirectory, trimmedQuery, 150)
searchFiles(currentDirectory, trimmedQuery, 150, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
})
.then((hits) => {
if (cancelled) {
return;
}
const filtered = hits.filter((hit) => !shouldIgnorePath(hit.path));
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
// Apply fuzzy scoring and sort by score
const ranked = filtered
@@ -609,7 +635,7 @@ export const FilesView: React.FC = () => {
return () => {
cancelled = true;
};
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles]);
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
const readFile = React.useCallback(async (path: string): Promise<string> => {
if (files.readFile) {
@@ -1318,33 +1344,35 @@ export const FilesView: React.FC = () => {
"flex min-h-0 flex-col overflow-hidden",
isMobile ? "h-full w-full bg-background" : "h-full rounded-xl border border-border/60 bg-background/70"
)}>
<div className={cn("flex items-center gap-2 py-2", isMobile ? "px-3" : "px-2")}>
<div className="relative flex-1 min-w-0">
<RiSearchLine className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search files…"
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 && (
<button
type="button"
aria-label="Clear search"
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
>
<RiCloseLine className="h-4 w-4" />
</button>
)}
<div className={cn("flex flex-col gap-2 py-2", isMobile ? "px-3" : "px-2")}>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<RiSearchLine className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search files…"
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 && (
<button
type="button"
aria-label="Clear search"
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
>
<RiCloseLine className="h-4 w-4" />
</button>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0">
<RiRefreshLine className="h-4 w-4" />
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0">
<RiRefreshLine className="h-4 w-4" />
</Button>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn("py-2", isMobile ? "px-3" : "px-2")}>
+2 -1
View File
@@ -322,6 +322,8 @@ export interface FileSearchQuery {
directory: string;
query: string;
maxResults?: number;
includeHidden?: boolean;
respectGitignore?: boolean;
}
export interface FileSearchResult {
@@ -576,4 +578,3 @@ export interface SkillsInstallResponse {
skipped?: Array<{ skillName: string; reason: string }>;
error?: SkillsInstallError;
}
@@ -0,0 +1,60 @@
import React from 'react';
import { getSafeStorage } from '@/stores/utils/safeStorage';
const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden';
const SHOW_HIDDEN_EVENT = 'directory-show-hidden-change';
const readStoredShowHidden = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
try {
const stored = getSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY);
return stored === 'true';
} catch {
return false;
}
};
export const notifyDirectoryShowHiddenChanged = () => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(SHOW_HIDDEN_EVENT));
};
export const setDirectoryShowHidden = (value: boolean) => {
if (typeof window === 'undefined') {
return;
}
try {
getSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false');
notifyDirectoryShowHiddenChanged();
} catch {
// ignore storage errors
}
};
export const useDirectoryShowHidden = (): boolean => {
const [showHidden, setShowHidden] = React.useState(readStoredShowHidden);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleChange = () => {
setShowHidden(readStoredShowHidden());
};
window.addEventListener('storage', handleChange);
window.addEventListener(SHOW_HIDDEN_EVENT, handleChange);
return () => {
window.removeEventListener('storage', handleChange);
window.removeEventListener(SHOW_HIDDEN_EVENT, handleChange);
};
}, []);
return showHidden;
};
export const DIRECTORY_SHOW_HIDDEN_STORAGE_KEY = SHOW_HIDDEN_STORAGE_KEY;
@@ -0,0 +1,61 @@
import React from 'react';
import { getSafeStorage } from '@/stores/utils/safeStorage';
const SHOW_GITIGNORED_STORAGE_KEY = 'filesViewShowGitignored';
const SHOW_GITIGNORED_EVENT = 'files-view-show-gitignored-change';
const readStoredShowGitignored = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
try {
const stored = getSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY);
return stored === 'true';
} catch {
return false;
}
};
export const notifyFilesViewShowGitignoredChanged = () => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(SHOW_GITIGNORED_EVENT));
};
export const setFilesViewShowGitignored = (value: boolean) => {
if (typeof window === 'undefined') {
return;
}
try {
getSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false');
notifyFilesViewShowGitignoredChanged();
} catch {
// ignore storage errors
}
};
export const useFilesViewShowGitignored = (): boolean => {
const [showGitignored, setShowGitignored] = React.useState(readStoredShowGitignored);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleChange = () => {
setShowGitignored(readStoredShowGitignored());
};
window.addEventListener('storage', handleChange);
window.addEventListener(SHOW_GITIGNORED_EVENT, handleChange);
return () => {
window.removeEventListener('storage', handleChange);
window.removeEventListener(SHOW_GITIGNORED_EVENT, handleChange);
};
}, []);
return showGitignored;
};
export const FILES_VIEW_SHOW_GITIGNORED_STORAGE_KEY = SHOW_GITIGNORED_STORAGE_KEY;
+18 -2
View File
@@ -1708,7 +1708,7 @@ class OpencodeService {
const desktopFiles = getDesktopFilesApi();
if (desktopFiles) {
try {
const result = await desktopFiles.listDirectory(directoryPath || '');
const result = await desktopFiles.listDirectory(directoryPath || '', options);
if (!result || !Array.isArray(result.entries)) {
return [];
}
@@ -1753,7 +1753,15 @@ class OpencodeService {
}
}
async searchFiles(query: string, options?: { directory?: string | null; limit?: number }): Promise<ProjectFileSearchHit[]> {
async searchFiles(
query: string,
options?: {
directory?: string | null;
limit?: number;
includeHidden?: boolean;
respectGitignore?: boolean;
}
): Promise<ProjectFileSearchHit[]> {
const desktopFiles = getDesktopFilesApi();
const directory = typeof options?.directory === 'string' && options.directory.trim().length > 0
? options.directory.trim()
@@ -1766,6 +1774,8 @@ class OpencodeService {
directory: directory || '',
query,
maxResults: options?.limit,
includeHidden: options?.includeHidden,
respectGitignore: options?.respectGitignore,
});
if (!Array.isArray(results)) {
@@ -1809,6 +1819,12 @@ class OpencodeService {
if (typeof options?.limit === 'number' && Number.isFinite(options.limit)) {
params.set('limit', String(options.limit));
}
if (options?.includeHidden) {
params.set('includeHidden', 'true');
}
if (options?.respectGitignore === false) {
params.set('respectGitignore', 'false');
}
const searchUrl = `${this.baseUrl}/fs/search${params.toString() ? `?${params.toString()}` : ''}`;
const response = await fetch(searchUrl, {
+24 -6
View File
@@ -15,14 +15,25 @@ interface FileSearchStoreState {
cache: Record<string, FileSearchCacheEntry>;
cacheKeys: string[];
inFlight: Record<string, Promise<ProjectFileSearchHit[]>>;
searchFiles: (directory: string, query: string, limit?: number) => Promise<ProjectFileSearchHit[]>;
searchFiles: (
directory: string,
query: string,
limit?: number,
options?: { includeHidden?: boolean; respectGitignore?: boolean }
) => Promise<ProjectFileSearchHit[]>;
invalidateDirectory: (directory?: string | null) => void;
}
const buildCacheKey = (directory: string, query: string, limit: number) => {
const buildCacheKey = (
directory: string,
query: string,
limit: number,
includeHidden: boolean,
respectGitignore: boolean
) => {
const normalizedDirectory = directory.trim();
const normalizedQuery = query.trim().toLowerCase();
return `${normalizedDirectory}::${normalizedQuery}::${limit}`;
return `${normalizedDirectory}::${normalizedQuery}::${limit}::${includeHidden ? '1' : '0'}::${respectGitignore ? '1' : '0'}`;
};
export const useFileSearchStore = create<FileSearchStoreState>()(
@@ -31,14 +42,16 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
cache: {},
cacheKeys: [],
inFlight: {},
async searchFiles(directory, query, limit = DEFAULT_SEARCH_LIMIT) {
async searchFiles(directory, query, limit = DEFAULT_SEARCH_LIMIT, options) {
if (!directory || directory.trim().length === 0) {
return [];
}
const normalizedDirectory = directory.trim();
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit);
const includeHidden = Boolean(options?.includeHidden);
const respectGitignore = options?.respectGitignore ?? true;
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore);
const now = Date.now();
const cached = get().cache[key];
@@ -52,7 +65,12 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
}
const searchPromise = opencodeClient
.searchFiles(normalizedQuery, { directory: normalizedDirectory, limit })
.searchFiles(normalizedQuery, {
directory: normalizedDirectory,
limit,
includeHidden,
respectGitignore,
})
.then((files) => {
set((state) => {
const nextCache = { ...state.cache, [key]: { files, timestamp: Date.now() } };