diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index d8292ae4..d8f7a04f 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -270,6 +270,8 @@ pub async fn search_files( directory: Option, query: Option, max_results: Option, + include_hidden: Option, + respect_gitignore: Option, state: tauri::State<'_, DesktopRuntime>, ) -> Result { let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; @@ -280,6 +282,8 @@ pub async fn search_files( let limit = clamp_search_limit(max_results); let normalized_query = query.unwrap_or_default().trim().to_lowercase(); let match_all = normalized_query.is_empty(); + let include_hidden = include_hidden.unwrap_or(false); + let respect_gitignore = respect_gitignore.unwrap_or(true); // Collect more candidates for fuzzy matching, then sort and trim let collect_limit = if match_all { @@ -306,20 +310,59 @@ pub async fn search_files( Err(_) => continue, }; + let mut all_entries = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + all_entries.push((entry, name)); + } + + let ignored_names: HashSet = if respect_gitignore { + let names: Vec = all_entries.iter().map(|(_, name)| name.clone()).collect(); + if names.is_empty() { + HashSet::new() + } else { + let cwd = dir.clone(); + tokio::task::spawn_blocking(move || { + let output = Command::new("git") + .arg("check-ignore") + .arg("--") + .args(&names) + .current_dir(&cwd) + .output(); + + match output { + Ok(out) => String::from_utf8_lossy(&out.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + Err(_) => HashSet::new(), + } + }) + .await + .unwrap_or_default() + } + } else { + HashSet::new() + }; + + for (entry, name) in all_entries { let Ok(file_type) = entry.file_type().await else { continue; }; - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if name_str.is_empty() { + let name_str = name.as_str(); + if name_str.is_empty() || (!include_hidden && name_str.starts_with('.')) { + continue; + } + + if respect_gitignore && ignored_names.contains(name_str) { continue; } let entry_path = entry.path(); if file_type.is_dir() { - if should_skip_directory(&name_str) { + if should_skip_directory(name_str, include_hidden) { continue; } if visited.insert(entry_path.clone()) && candidates.len() < collect_limit { @@ -358,10 +401,6 @@ pub async fn search_files( break; } } - - if candidates.len() >= collect_limit { - break; - } } } @@ -568,7 +607,10 @@ fn clamp_search_limit(value: Option) -> usize { limit.clamp(1, MAX_FILE_SEARCH_LIMIT) } -fn should_skip_directory(name: &str) -> bool { +fn should_skip_directory(name: &str, include_hidden: bool) -> bool { + if !include_hidden && name.starts_with('.') { + return true; + } FILE_SEARCH_EXCLUDED_DIRS .iter() .any(|dir| dir.eq_ignore_ascii_case(name)) diff --git a/packages/desktop/src/api/files.ts b/packages/desktop/src/api/files.ts index 4966816a..aadec574 100644 --- a/packages/desktop/src/api/files.ts +++ b/packages/desktop/src/api/files.ts @@ -48,8 +48,9 @@ export const createDesktopFilesAPI = (): FilesAPI => ({ try { const result = await safeInvoke('list_directory', { path: normalizePath(path), - includeHidden: false, + // NOTE: pass both casings; Tauri arg casing differs across commands respectGitignore: options?.respectGitignore ?? false, + respect_gitignore: options?.respectGitignore ?? false, }, { timeout: 10000, onCancel: () => { @@ -74,7 +75,13 @@ export const createDesktopFilesAPI = (): FilesAPI => ({ const result = await safeInvoke('search_files', { directory: normalizedDirectory, query: payload.query, - max_results: payload.maxResults || 100 + // NOTE: pass both casings; Tauri arg casing differs across commands + maxResults: payload.maxResults || 100, + includeHidden: payload.includeHidden ?? false, + respectGitignore: payload.respectGitignore ?? true, + max_results: payload.maxResults || 100, + include_hidden: payload.includeHidden ?? false, + respect_gitignore: payload.respectGitignore ?? true, }, { timeout: 15000, onCancel: () => { @@ -227,4 +234,4 @@ export const createDesktopFilesAPI = (): FilesAPI => ({ throw new Error(message || 'Failed to execute commands'); } }, -}); \ No newline at end of file +}); diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 86c97ef6..7b663f23 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -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 state.searchFiles); const debouncedQuery = useDebouncedValue(searchQuery, 180); + const showHidden = useDirectoryShowHidden(); + const showGitignored = useFilesViewShowGitignored(); const [files, setFiles] = React.useState([]); const [loading, setLoading] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); @@ -115,12 +119,18 @@ export const FileMentionAutocomplete = React.forwardRef { if (cancelled) { return; @@ -158,7 +168,7 @@ export const FileMentionAutocomplete = React.forwardRef { cancelled = true; }; - }, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles]); + }, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]); React.useEffect(() => { setSelectedIndex(0); diff --git a/packages/ui/src/components/chat/ServerFilePicker.tsx b/packages/ui/src/components/chat/ServerFilePicker.tsx index 4006ad85..53e30417 100644 --- a/packages/ui/src/components/chat/ServerFilePicker.tsx +++ b/packages/ui/src/components/chat/ServerFilePicker.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ } 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 = ({ }))); 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 = ({ }); } 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 = ({ } }, [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 = ({ 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 = ({ 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 = ({ setSearchQuery(''); setSearchResults([]); setSearching(false); + loadedDirsRef.current = new Set(); + inFlightDirsRef.current = new Set(); + setChildrenByDir({}); + setExpandedDirs(new Set()); } }, [open, mobileOpen]); diff --git a/packages/ui/src/components/sections/openchamber/GitSettings.tsx b/packages/ui/src/components/sections/openchamber/GitSettings.tsx index 630108b0..f355e248 100644 --- a/packages/ui/src/components/sections/openchamber/GitSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitSettings.tsx @@ -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 ( -
+

Commit Messages

@@ -116,6 +118,29 @@ export const GitSettings: React.FC = () => {

+ +
+

Files Overview

+

+ Show gitignored files in the Files browser pane only. +

+
+
+
+ +

+ Toggles gitignored files in the Files tree and search results. +

+
+
); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index bee370fa..cf513224 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -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 ; + return ; }; // Sessions section: Default model & agent, Session retention, Memory limits diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 6e09eb9b..afdaec2e 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -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 { 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 = ({ 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 {DIFF_VIEW_MODE_OPTIONS.find((option) => option.id === diffViewMode)?.description}

+ + + )} + + {shouldShow('dotfiles') && !isVSCodeRuntime() && ( +
+
+

+ Hidden files (Chat) +

+

+ Show or hide dotfiles in file lists and directory pickers. +

+
+ +
+
+ {[ + { id: 'hide', label: 'Hide', value: false }, + { id: 'show', label: 'Show', value: true }, + ].map((option) => ( + setDirectoryShowHidden(option.value)} + > + {option.label} + + ))} +
+
)} diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 85408462..11025507 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -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 = ( const [pathInputValue, setPathInputValue] = React.useState(''); const [hasUserSelection, setHasUserSelection] = React.useState(false); const [isConfirming, setIsConfirming] = React.useState(false); - const [showHidden, setShowHidden] = React.useState(() => { - 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 = ( } }, [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 = ( }, []); const toggleShowHidden = React.useCallback(() => { - setShowHidden(prev => !prev); - }, []); + setDirectoryShowHidden(!showHidden); + }, [showHidden]); diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 368933c7..4810bbb8 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -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((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 => { 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" )}> -
-
- - setSearchQuery(e.target.value)} - placeholder="Search files…" - className="h-8 pl-8 pr-8 typography-meta" - /> - {searchQuery.trim().length > 0 && ( - - )} +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Search files…" + className="h-8 pl-8 pr-8 typography-meta" + /> + {searchQuery.trim().length > 0 && ( + + )} +
+
-
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7bea986d..6d76267e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; } - diff --git a/packages/ui/src/lib/directoryShowHidden.ts b/packages/ui/src/lib/directoryShowHidden.ts new file mode 100644 index 00000000..05539619 --- /dev/null +++ b/packages/ui/src/lib/directoryShowHidden.ts @@ -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; diff --git a/packages/ui/src/lib/filesViewShowGitignored.ts b/packages/ui/src/lib/filesViewShowGitignored.ts new file mode 100644 index 00000000..34557210 --- /dev/null +++ b/packages/ui/src/lib/filesViewShowGitignored.ts @@ -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; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 5dfe5d8e..a65978b2 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -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 { + async searchFiles( + query: string, + options?: { + directory?: string | null; + limit?: number; + includeHidden?: boolean; + respectGitignore?: boolean; + } + ): Promise { 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, { diff --git a/packages/ui/src/stores/useFileSearchStore.ts b/packages/ui/src/stores/useFileSearchStore.ts index b1d198ed..910e83ac 100644 --- a/packages/ui/src/stores/useFileSearchStore.ts +++ b/packages/ui/src/stores/useFileSearchStore.ts @@ -15,14 +15,25 @@ interface FileSearchStoreState { cache: Record; cacheKeys: string[]; inFlight: Record>; - searchFiles: (directory: string, query: string, limit?: number) => Promise; + searchFiles: ( + directory: string, + query: string, + limit?: number, + options?: { includeHidden?: boolean; respectGitignore?: boolean } + ) => Promise; 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()( @@ -31,14 +42,16 @@ export const useFileSearchStore = create()( 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()( } 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() } }; diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index f3b34394..b0ee00e6 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import * as os from 'os'; import * as path from 'path'; +import { spawn } from 'child_process'; import { type OpenCodeManager } from './opencode'; import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig'; import { removeProviderAuth } from './opencodeAuth'; @@ -96,6 +97,53 @@ const persistSettings = async (changes: Record, ctx?: BridgeCon const normalizeFsPath = (value: string) => value.replace(/\\/g, '/'); +const execGit = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => ( + new Promise((resolve) => { + const proc = spawn('git', args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + resolve({ stdout, stderr, exitCode: code ?? 0 }); + }); + + proc.on('error', (error) => { + resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 }); + }); + }) +); + +const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise> => { + if (names.length === 0) { + return new Set(); + } + + const result = await execGit(['check-ignore', '--', ...names], cwd); + if (result.exitCode !== 0 || !result.stdout) { + return new Set(); + } + + return new Set( + result.stdout + .split('\n') + .map((name: string) => name.trim()) + .filter(Boolean) + ); +}; + const expandTildePath = (value: string) => { const trimmed = (value || '').trim(); if (!trimmed) { @@ -147,11 +195,11 @@ const FILE_SEARCH_EXCLUDED_DIRS = new Set([ 'logs', ]); -const shouldSkipSearchDirectory = (name: string) => { +const shouldSkipSearchDirectory = (name: string, includeHidden: boolean) => { if (!name) { return false; } - if (name.startsWith('.')) { + if (!includeHidden && name.startsWith('.')) { return true; } return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); @@ -228,7 +276,13 @@ const fuzzyMatchScore = (query: string, candidate: string): number | null => { return score; }; -const searchFilesystemFiles = async (rootPath: string, query: string, limit: number) => { +const searchFilesystemFiles = async ( + rootPath: string, + query: string, + limit: number, + includeHidden: boolean, + respectGitignore: boolean +) => { const normalizedQuery = (query || '').trim().toLowerCase(); const matchAll = normalizedQuery.length === 0; @@ -250,8 +304,16 @@ const searchFilesystemFiles = async (rootPath: string, query: string, limit: num const currentDir = batch[index]; const dirents = dirLists[index]; + const ignoredNames = respectGitignore + ? await gitCheckIgnoreNames(normalizeFsPath(currentDir.fsPath), dirents.map(([name]) => name)) + : new Set(); + for (const [entryName, entryType] of dirents) { - if (!entryName || entryName.startsWith('.')) { + if (!entryName || (!includeHidden && entryName.startsWith('.'))) { + continue; + } + + if (respectGitignore && ignoredNames.has(entryName)) { continue; } @@ -259,7 +321,7 @@ const searchFilesystemFiles = async (rootPath: string, query: string, limit: num const absolute = normalizeFsPath(entryUri.fsPath); if (entryType === vscode.FileType.Directory) { - if (shouldSkipSearchDirectory(entryName)) { + if (shouldSkipSearchDirectory(entryName, includeHidden)) { continue; } if (!visited.has(absolute)) { @@ -330,7 +392,13 @@ const searchFilesystemFiles = async (rootPath: string, query: string, limit: num })); }; -const searchDirectory = async (directory: string, query: string, limit = 60) => { +const searchDirectory = async ( + directory: string, + query: string, + limit = 60, + includeHidden = false, + respectGitignore = true +) => { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); const rootPath = directory ? resolveUserPath(directory, workspaceRoot) @@ -339,38 +407,40 @@ const searchDirectory = async (directory: string, query: string, limit = 60) => const sanitizedQuery = query?.trim() || ''; if (!sanitizedQuery) { - return searchFilesystemFiles(rootPath, '', limit); + return searchFilesystemFiles(rootPath, '', limit, includeHidden, respectGitignore); } // Fast-path via VS Code's file index (may be case-sensitive depending on platform/workspace). - try { - const pattern = `**/*${sanitizedQuery}*`; - const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; - const results = await vscode.workspace.findFiles( - new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), - exclude, - limit, - ); + if (!includeHidden) { + try { + const pattern = `**/*${sanitizedQuery}*`; + const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; + const results = await vscode.workspace.findFiles( + new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), + exclude, + limit, + ); - if (Array.isArray(results) && results.length > 0) { - return results.map((file) => { - const absolute = normalizeFsPath(file.fsPath); - const relative = normalizeFsPath(path.relative(rootPath, absolute)); - const name = path.basename(absolute); - return { - name, - path: absolute, - relativePath: relative || name, - extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, - }; - }); + if (Array.isArray(results) && results.length > 0) { + return results.map((file) => { + const absolute = normalizeFsPath(file.fsPath); + const relative = normalizeFsPath(path.relative(rootPath, absolute)); + const name = path.basename(absolute); + return { + name, + path: absolute, + relativePath: relative || name, + extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, + }; + }); + } + } catch { + // Fall through to filesystem traversal. } - } catch { - // Fall through to filesystem traversal. } // Fallback: deterministic, case-insensitive traversal with early-exit at limit. - return searchFilesystemFiles(rootPath, sanitizedQuery, limit); + return searchFilesystemFiles(rootPath, sanitizedQuery, limit, includeHidden, respectGitignore); }; const fetchModelsMetadata = async () => { @@ -510,18 +580,49 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo case 'api:fs:list': { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const target = (payload as { path?: string })?.path || workspaceRoot; + const { path: targetPath, respectGitignore } = (payload || {}) as { path?: string; respectGitignore?: boolean }; + const target = targetPath || workspaceRoot; const resolvedPath = resolveUserPath(target, workspaceRoot) || workspaceRoot; + const entries = await listDirectoryEntries(resolvedPath); const normalized = normalizeFsPath(resolvedPath); - return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + + if (!respectGitignore) { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } + + const pathsToCheck = entries.map((entry) => entry.name).filter(Boolean); + if (pathsToCheck.length === 0) { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } + + try { + const result = await execGit(['check-ignore', '--', ...pathsToCheck], normalized); + const ignoredNames = new Set( + result.stdout + .split('\n') + .map((name) => name.trim()) + .filter(Boolean) + ); + + const filteredEntries = entries.filter((entry) => !ignoredNames.has(entry.name)); + return { id, type, success: true, data: { entries: filteredEntries, directory: normalized, path: normalized } }; + } catch { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } } - case 'api:fs:search': { - const { directory = '', query = '', limit } = (payload || {}) as { directory?: string; query?: string; limit?: number }; - const files = await searchDirectory(directory, query, limit); - return { id, type, success: true, data: { files } }; - } + case 'api:fs:search': { + const { directory = '', query = '', limit, includeHidden, respectGitignore } = (payload || {}) as { + directory?: string; + query?: string; + limit?: number; + includeHidden?: boolean; + respectGitignore?: boolean; + }; + const files = await searchDirectory(directory, query, limit, Boolean(includeHidden), respectGitignore !== false); + return { id, type, success: true, data: { files } }; + } case 'api:fs:mkdir': { const target = (payload as { path: string })?.path; diff --git a/packages/vscode/webview/api/files.ts b/packages/vscode/webview/api/files.ts index cfd93c8c..7dda8729 100644 --- a/packages/vscode/webview/api/files.ts +++ b/packages/vscode/webview/api/files.ts @@ -11,13 +11,16 @@ import { sendBridgeMessage, sendBridgeMessageWithOptions } from './bridge'; const normalizePath = (value: string): string => value.replace(/\\/g, '/'); export const createVSCodeFilesAPI = (): FilesAPI => ({ - async listDirectory(path: string): Promise { + async listDirectory(path: string, options?: { respectGitignore?: boolean }): Promise { const target = normalizePath(path); const data = await sendBridgeMessage<{ directory?: string; path?: string; entries: Array<{ name: string; path: string; isDirectory: boolean }>; - }>('api:fs:list', { path: target }); + }>('api:fs:list', { + path: target, + respectGitignore: options?.respectGitignore, + }); const directory = normalizePath(data?.directory || data?.path || target); const entries = Array.isArray(data?.entries) ? data.entries : []; @@ -36,6 +39,8 @@ export const createVSCodeFilesAPI = (): FilesAPI => ({ directory: normalizePath(payload.directory), query: payload.query, limit: payload.maxResults, + includeHidden: payload.includeHidden, + respectGitignore: payload.respectGitignore, }); const files = Array.isArray(data?.files) ? data.files : []; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index deeb8057..c8a3ab3e 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -263,6 +263,14 @@ if (workspaceFolder) { try { window.localStorage.setItem('lastDirectory', normalizedWorkspaceFolder); window.localStorage.setItem('homeDirectory', normalizedWorkspaceFolder); + + // VS Code defaults: show dotfiles, hide gitignored + if (window.localStorage.getItem('directoryTreeShowHidden') === null) { + window.localStorage.setItem('directoryTreeShowHidden', 'true'); + } + if (window.localStorage.getItem('filesViewShowGitignored') === null) { + window.localStorage.setItem('filesViewShowGitignored', 'false'); + } } catch (error) { console.warn('Failed to persist workspace folder', error); } @@ -357,7 +365,8 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { if (pathname.startsWith('/api/fs/list')) { const targetPath = url.searchParams.get('path') || ''; - const data = await sendBridgeMessage('api:fs:list', { path: targetPath }); + const respectGitignore = url.searchParams.get('respectGitignore') === 'true'; + const data = await sendBridgeMessage('api:fs:list', { path: targetPath, respectGitignore }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } @@ -367,7 +376,15 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const limitParam = url.searchParams.get('limit'); const limit = limitParam ? Number(limitParam) : undefined; const resolvedLimit = Number.isFinite(limit) ? limit : undefined; - const data = await sendBridgeMessage('api:fs:search', { directory, query, limit: resolvedLimit }); + const includeHidden = url.searchParams.get('includeHidden') === 'true'; + const respectGitignore = url.searchParams.get('respectGitignore') !== 'false'; + const data = await sendBridgeMessage('api:fs:search', { + directory, + query, + limit: resolvedLimit, + includeHidden, + respectGitignore, + }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index c0afcf82..4fc71b66 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -68,11 +68,13 @@ const normalizeRelativeSearchPath = (rootPath, targetPath) => { return relative.split(path.sep).join('/') || targetPath; }; -const shouldSkipSearchDirectory = (name) => { +const shouldSkipSearchDirectory = (name, includeHidden) => { if (!name) { return false; } - // allow dot dirs/files; still skip excluded dirs below + if (!includeHidden && name.startsWith('.')) { + return true; + } return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); }; @@ -157,33 +159,74 @@ const fuzzyMatchScoreNormalized = (normalizedQuery, candidate) => { }; const searchFilesystemFiles = async (rootPath, options) => { - const { limit, query } = options; + const { limit, query, includeHidden, respectGitignore } = options; + const includeHiddenEntries = Boolean(includeHidden); const normalizedQuery = query.trim().toLowerCase(); const matchAll = normalizedQuery.length === 0; const queue = [rootPath]; const visited = new Set([rootPath]); + const shouldRespectGitignore = respectGitignore !== false; // Collect more candidates for fuzzy matching, then sort and trim const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); const candidates = []; while (queue.length > 0 && candidates.length < collectLimit) { const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY); - const dirLists = await Promise.all(batch.map((dir) => listDirectoryEntries(dir))); - for (let index = 0; index < batch.length; index += 1) { - const currentDir = batch[index]; - const dirents = dirLists[index]; + const dirResults = await Promise.all( + batch.map(async (dir) => { + if (!shouldRespectGitignore) { + return { dir, dirents: await listDirectoryEntries(dir), ignoredPaths: new Set() }; + } + try { + const dirents = await listDirectoryEntries(dir); + const pathsToCheck = dirents.map((dirent) => dirent.name).filter(Boolean); + if (pathsToCheck.length === 0) { + return { dir, dirents, ignoredPaths: new Set() }; + } + + const result = await new Promise((resolve) => { + const child = spawn('git', ['check-ignore', '--', ...pathsToCheck], { + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.on('close', () => resolve(stdout)); + child.on('error', () => resolve('')); + }); + + const ignoredNames = new Set( + String(result) + .split('\n') + .map((name) => name.trim()) + .filter(Boolean) + ); + + return { dir, dirents, ignoredPaths: ignoredNames }; + } catch { + return { dir, dirents: await listDirectoryEntries(dir), ignoredPaths: new Set() }; + } + }) + ); + + for (const { dir: currentDir, dirents, ignoredPaths } of dirResults) { for (const dirent of dirents) { const entryName = dirent.name; - if (!entryName) { + if (!entryName || (!includeHiddenEntries && entryName.startsWith('.'))) { + continue; + } + + if (shouldRespectGitignore && ignoredPaths.has(entryName)) { continue; } const entryPath = path.join(currentDir, entryName); if (dirent.isDirectory()) { - if (shouldSkipSearchDirectory(entryName)) { + if (shouldSkipSearchDirectory(entryName, includeHiddenEntries)) { continue; } if (!visited.has(entryPath)) { @@ -4330,8 +4373,10 @@ async function main(options = {}) { : typeof req.query.directory === 'string' && req.query.directory.trim().length > 0 ? req.query.directory.trim() : os.homedir(); - const rawQuery = typeof req.query.q === 'string' ? req.query.q : ''; - const limitParam = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : undefined; + const rawQuery = typeof req.query.q === 'string' ? req.query.q : ''; + const includeHidden = req.query.includeHidden === 'true'; + const respectGitignore = req.query.respectGitignore !== 'false'; + const limitParam = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : undefined; const parsedLimit = Number.isFinite(limitParam) ? Number(limitParam) : DEFAULT_FILE_SEARCH_LIMIT; const limit = Math.max(1, Math.min(parsedLimit, MAX_FILE_SEARCH_LIMIT)); @@ -4342,7 +4387,12 @@ async function main(options = {}) { return res.status(400).json({ error: 'Specified root is not a directory' }); } - const files = await searchFilesystemFiles(resolvedRoot, { limit, query: rawQuery || '' }); + const files = await searchFilesystemFiles(resolvedRoot, { + limit, + query: rawQuery || '', + includeHidden, + respectGitignore, + }); res.json({ root: resolvedRoot, count: files.length, diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index d5c82261..22a39279 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -83,6 +83,9 @@ export const createWebFilesAPI = (): FilesAPI => ({ if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) { params.set('limit', String(payload.maxResults)); } + if (payload.includeHidden) { + params.set('includeHidden', 'true'); + } const response = await fetch(`/api/fs/search?${params.toString()}`);