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
@@ -270,6 +270,8 @@ pub async fn search_files(
directory: Option<String>,
query: Option<String>,
max_results: Option<usize>,
include_hidden: Option<bool>,
respect_gitignore: Option<bool>,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<SearchFilesResponse, String> {
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<String> = if respect_gitignore {
let names: Vec<String> = 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>) -> 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))
+10 -3
View File
@@ -48,8 +48,9 @@ export const createDesktopFilesAPI = (): FilesAPI => ({
try {
const result = await safeInvoke<ListDirectoryResponse>('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<SearchFilesResponse>('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');
}
},
});
});
@@ -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() } };
+138 -37
View File
@@ -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<string, unknown>, 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<Set<string>> => {
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<string>();
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;
+7 -2
View File
@@ -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<DirectoryListResult> {
async listDirectory(path: string, options?: { respectGitignore?: boolean }): Promise<DirectoryListResult> {
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 : [];
+19 -2
View File
@@ -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' } });
}
+62 -12
View File
@@ -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,
+3
View File
@@ -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()}`);