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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
6544b12e78
commit
2de0d9d3dd
@@ -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]);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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, {
|
||||
|
||||
@@ -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() } };
|
||||
|
||||
Reference in New Issue
Block a user