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
@@ -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))
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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() } };
|
||||
|
||||
+138
-37
@@ -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;
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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' } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user