diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 9789f42e..747cbae0 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -331,9 +331,11 @@ fn parse_omp_interjections(path: &Path) -> Result, St /// Immediate children of `path` (project tree). Folders first, then files. #[tauri::command(async)] pub fn list_dir(path: String) -> Result, String> { - let dir = expand_home(&path); - let reader = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let ignore = Ignore::load(&dir); + list_dir_sync(&expand_home(&path)) +} + +pub(crate) fn list_dir_sync(dir: &Path) -> Result, String> { + let reader = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut out = Vec::new(); for ent in reader { @@ -349,13 +351,26 @@ pub fn list_dir(path: String) -> Result, String> { .map(|t| t.is_dir() || (t.is_symlink() && path.is_dir())) .unwrap_or_else(|_| path.is_dir()); out.push(DirEntry { - ignored: ignore.matches(name), + ignored: false, name: name.to_string(), path: path_to_js(&path), is_dir, }); } + let names: Vec<&str> = out.iter().map(|e| e.name.as_str()).collect(); + let ignored = git_ignored_names(dir, &names).unwrap_or_else(|| { + let ignore = Ignore::load(dir); + names + .iter() + .filter(|n| ignore.matches(n)) + .map(|n| n.to_string()) + .collect() + }); + for entry in &mut out { + entry.ignored = entry.name == ".git" || ignored.contains(&entry.name); + } + out.sort_by(|a, b| { b.is_dir.cmp(&a.is_dir).then_with(|| { a.name @@ -400,6 +415,53 @@ pub(crate) fn list_project_files_sync(cwd: &str) -> Result, Str Ok(walk_project_files(&root)) } +const CHECK_IGNORE_SOME_MATCHED: i32 = 0; +const CHECK_IGNORE_NONE_MATCHED: i32 = 1; + +fn git_ignored_names(dir: &Path, names: &[&str]) -> Option> { + if names.is_empty() { + return Some(HashSet::new()); + } + let mut child = git_cmd() + .arg("-C") + .arg(dir) + .args(["check-ignore", "--stdin", "-z"]) + .env("GIT_OPTIONAL_LOCKS", "0") + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let mut input = Vec::with_capacity(names.iter().map(|n| n.len() + 1).sum()); + for name in names { + input.extend_from_slice(name.as_bytes()); + input.push(0); + } + let mut stdin = child.stdin.take()?; + // Write on a separate thread: a large listing can fill the stdout pipe + // while git still waits for stdin, which would deadlock a serial writer. + let writer = std::thread::spawn(move || stdin.write_all(&input)); + let output = child.wait_with_output().ok()?; + writer.join().ok()?.ok()?; + + if !matches!( + output.status.code(), + Some(CHECK_IGNORE_SOME_MATCHED | CHECK_IGNORE_NONE_MATCHED) + ) { + return None; + } + Some( + output + .stdout + .split(|b| *b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect(), + ) +} + fn git_ls_files(root: &Path) -> Option> { let output = git_cmd() .arg("-C") @@ -3898,6 +3960,8 @@ fn preserves_unix_backslash_filenames() { assert_eq!(expand_home(r"~\literal"), PathBuf::from(r"~\literal")); } +/// Name-only `.gitignore` subset for directories outside a git repository. +/// Inside a repository `git check-ignore` is the source of truth. struct Ignore { exact: HashSet, suffixes: Vec, @@ -4969,6 +5033,68 @@ mod tests { assert!(is_indexable_root(&dir.0)); } + fn ignored_names(entries: &[DirEntry]) -> Vec { + entries + .iter() + .filter(|e| e.ignored) + .map(|e| e.name.clone()) + .collect() + } + + fn is_ignored(dir: &Path, name: &str) -> bool { + ignored_names(&list_dir_sync(dir).unwrap()) + .iter() + .any(|n| n == name) + } + + #[test] + fn list_dir_marks_ignored_with_git_semantics() { + let dir = tmp("list-dir-git"); + let init = Command::new("git") + .args(["init"]) + .current_dir(&dir.0) + .output(); + let Ok(init) = init else { return }; + if !init.status.success() { + return; + } + std::fs::write( + dir.0.join(".gitignore"), + "*.zzlog\n!keep.zzlog\n/zz-dist\nzz-build/\nnested/*.zztmp\n", + ) + .unwrap(); + std::fs::write(dir.0.join("a.zzlog"), "x\n").unwrap(); + std::fs::write(dir.0.join("keep.zzlog"), "x\n").unwrap(); + std::fs::write(dir.0.join("zz-build"), "x\n").unwrap(); + std::fs::create_dir_all(dir.0.join("zz-dist")).unwrap(); + std::fs::create_dir_all(dir.0.join("src").join("zz-dist")).unwrap(); + std::fs::create_dir_all(dir.0.join("nested")).unwrap(); + std::fs::write(dir.0.join("nested").join("x.zztmp"), "x\n").unwrap(); + std::fs::write(dir.0.join("nested").join("x.txt"), "x\n").unwrap(); + + assert!(is_ignored(&dir.0, ".git")); + assert!(is_ignored(&dir.0, "a.zzlog")); + assert!(!is_ignored(&dir.0, "keep.zzlog")); + assert!(is_ignored(&dir.0, "zz-dist")); + assert!(!is_ignored(&dir.0.join("src"), "zz-dist")); + assert!(!is_ignored(&dir.0, "zz-build")); + assert!(is_ignored(&dir.0.join("nested"), "x.zztmp")); + assert!(!is_ignored(&dir.0.join("nested"), "x.txt")); + } + + #[test] + fn list_dir_falls_back_to_name_parser_outside_git() { + let dir = tmp("list-dir-plain"); + std::fs::write(dir.0.join(".gitignore"), "secret.txt\n*.log\n").unwrap(); + std::fs::write(dir.0.join("secret.txt"), "x\n").unwrap(); + std::fs::write(dir.0.join("a.log"), "x\n").unwrap(); + std::fs::write(dir.0.join("app.ts"), "x\n").unwrap(); + + let mut ignored = ignored_names(&list_dir_sync(&dir.0).unwrap()); + ignored.sort_unstable(); + assert_eq!(ignored, vec!["a.log", "secret.txt"]); + } + #[test] fn git_ls_files_includes_untracked_and_drops_ignored() { let dir = tmp("index-git"); diff --git a/src/chrome/FileTree.test.ts b/src/chrome/FileTree.test.ts index 5329ac28..93e8edcc 100644 --- a/src/chrome/FileTree.test.ts +++ b/src/chrome/FileTree.test.ts @@ -2,9 +2,11 @@ import { act, createElement, type ComponentProps } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { saveShowExcludedFiles } from "../lib/appearance"; import { listCachedDir, notifyDirsChanged, + refreshDir, saveExpanded, } from "../lib/fileTree"; import type { FsEntry } from "../lib/fs"; @@ -37,8 +39,12 @@ let cwd: string; let props: ComponentProps; let project = 0; -function file(name: string): FsEntry { - return { name, path: `${cwd}/${name}`, isDir: false, ignored: false }; +function file(name: string, ignored = false): FsEntry { + return { name, path: `${cwd}/${name}`, isDir: false, ignored }; +} + +function folder(name: string, ignored = false): FsEntry { + return { name, path: `${cwd}/${name}`, isDir: true, ignored }; } function render(tick = 0, hidden = false) { @@ -69,6 +75,7 @@ beforeEach(async () => { afterEach(() => { act(() => root.unmount()); container.remove(); + localStorage.removeItem("monocode.showExcludedFiles"); vi.clearAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals(); @@ -127,3 +134,30 @@ describe("FileTree render isolation", () => { expect(row("first.ts")).toBeNull(); }); }); + +describe("FileTree excluded files", () => { + it("hides ignored entries by default and follows the setting", async () => { + directories.set(cwd, [ + folder("dist", true), + folder("src"), + file("first.ts"), + file("debug.log", true), + ]); + await refreshDir(cwd); + await act(async () => render()); + + expect(row("src")).not.toBeNull(); + expect(row("first.ts")).not.toBeNull(); + expect(row("dist")).toBeNull(); + expect(row("debug.log")).toBeNull(); + + act(() => saveShowExcludedFiles(true)); + expect(row("dist")).not.toBeNull(); + expect(row("debug.log")).not.toBeNull(); + + act(() => saveShowExcludedFiles(false)); + expect(row("dist")).toBeNull(); + expect(row("debug.log")).toBeNull(); + expect(row("first.ts")).not.toBeNull(); + }); +}); diff --git a/src/chrome/FileTree.tsx b/src/chrome/FileTree.tsx index 38e8e9df..2859654e 100644 --- a/src/chrome/FileTree.tsx +++ b/src/chrome/FileTree.tsx @@ -14,6 +14,7 @@ import { useEffect, useRef, useState, + useSyncExternalStore, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, @@ -25,6 +26,10 @@ import { type NameIssue, } from "../lib/fileName"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; +import { + loadShowExcludedFiles, + subscribeShowExcludedFiles, +} from "../lib/appearance"; import { createParentOf, dirsTouchedByCreate, @@ -95,6 +100,7 @@ type TreeCtxValue = { renaming: string | null; cutPath: string | null; epoch: number; + showExcludedFiles: boolean; gitStatuses?: GitStatusMap; onToggle: (path: string) => void; onSelect: (path: string) => void; @@ -239,6 +245,11 @@ export const FileTree = memo(function FileTree({ const [menu, setMenu] = useState(null); const [opError, setOpError] = useState(null); const [epoch, setEpoch] = useState(0); + const showExcludedFiles = useSyncExternalStore( + subscribeShowExcludedFiles, + loadShowExcludedFiles, + loadShowExcludedFiles, + ); const creatingRef = useRef(creating); creatingRef.current = creating; const rootRef = useRef(null); @@ -602,6 +613,7 @@ export const FileTree = memo(function FileTree({ renaming, cutPath: clip?.mode === "cut" ? clip.path : null, epoch, + showExcludedFiles, gitStatuses, onToggle: toggle, onSelect, @@ -840,8 +852,11 @@ function TreeChildren({ onCancel={() => ctx.onCreateCancel(creating.id)} /> ) : null; - const folders = entries?.filter((e) => e.isDir) ?? []; - const files = entries?.filter((e) => !e.isDir) ?? []; + const visible = ctx.showExcludedFiles + ? entries + : entries?.filter((e) => !e.ignored); + const folders = visible?.filter((e) => e.isDir) ?? []; + const files = visible?.filter((e) => !e.isDir) ?? []; const pad = { paddingLeft: 28 + depth * 12 }; return ( diff --git a/src/lib/appearance.test.ts b/src/lib/appearance.test.ts index dcab035c..676d74a8 100644 --- a/src/lib/appearance.test.ts +++ b/src/lib/appearance.test.ts @@ -17,6 +17,9 @@ import { loadTranscriptAnchor, saveTranscriptAnchor, TRANSCRIPT_ANCHOR_DEFAULT, + loadShowExcludedFiles, + saveShowExcludedFiles, + SHOW_EXCLUDED_FILES_DEFAULT, loadThemePreference, loadThemeDarkLightness, saveThemeDarkLightness, @@ -30,6 +33,7 @@ const KEY = "monocode.transcriptLayout"; const ACCENT_COLOR_KEY = "monocode.accentColor"; const SCHEME_KEY = "monocode.colorScheme"; const ANCHOR_KEY = "monocode.transcriptAnchor"; +const SHOW_EXCLUDED_FILES_KEY = "monocode.showExcludedFiles"; const CHAT_BACKGROUND_PATH_KEY = "monocode.chatBackgroundPath"; const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity"; const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; @@ -128,6 +132,25 @@ describe("transcript prompt-to-top setting", () => { }); }); +describe("show excluded files setting", () => { + beforeEach(mockLocalStorage); + afterEach(() => { + localStorage.removeItem(SHOW_EXCLUDED_FILES_KEY); + }); + + it("defaults to off", () => { + expect(SHOW_EXCLUDED_FILES_DEFAULT).toBe(false); + expect(loadShowExcludedFiles()).toBe(false); + }); + + it("persists across loads", () => { + saveShowExcludedFiles(true); + expect(loadShowExcludedFiles()).toBe(true); + saveShowExcludedFiles(false); + expect(loadShowExcludedFiles()).toBe(false); + }); +}); + describe("chat background setting", () => { beforeEach(mockLocalStorage); afterEach(() => { diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index e804ee82..4d8783b8 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -23,6 +23,7 @@ const CHAT_BACKGROUND_SESSION_OPACITY_KEY = "monocode.chatBackgroundSessionOpacity"; const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; const CHANGES_VIEW_KEY = "monocode.changesView"; +const SHOW_EXCLUDED_FILES_KEY = "monocode.showExcludedFiles"; let chatBackgroundRevision = Date.now(); let nativeGlassReady = false; @@ -54,6 +55,12 @@ export const TRANSCRIPT_ANCHOR_CHANGE_EVENT = "monocode:transcriptanchorchange"; /** Fired on `window` whenever the transcript layout flips (detail: TranscriptLayout). */ export const TRANSCRIPT_LAYOUT_CHANGE_EVENT = "monocode:transcriptlayoutchange"; +export const SHOW_EXCLUDED_FILES_DEFAULT = false; + +/** Fired on `window` whenever the explorer excluded-files setting flips (detail: boolean). */ +export const SHOW_EXCLUDED_FILES_CHANGE_EVENT = + "monocode:showexcludedfileschange"; + export type SidebarTabId = "files" | "sessions" | "changes" | "inbox"; const DEFAULT_SIDEBAR_TAB_ORDER: SidebarTabId[] = [ @@ -693,3 +700,24 @@ export function saveTranscriptAnchor(value: boolean) { }), ); } + +export function loadShowExcludedFiles(): boolean { + return readFlag(SHOW_EXCLUDED_FILES_KEY) ?? SHOW_EXCLUDED_FILES_DEFAULT; +} + +export function saveShowExcludedFiles(value: boolean) { + writeFlag(SHOW_EXCLUDED_FILES_KEY, value); + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(SHOW_EXCLUDED_FILES_CHANGE_EVENT, { + detail: value, + }), + ); +} + +export function subscribeShowExcludedFiles(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(SHOW_EXCLUDED_FILES_CHANGE_EVENT, onStoreChange); + return () => + window.removeEventListener(SHOW_EXCLUDED_FILES_CHANGE_EVENT, onStoreChange); +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index c1967d24..cda7e57b 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -202,6 +202,12 @@ export const SETTINGS_INDEX: SettingsEntry[] = [ label: "Interface scale", keywords: "zoom font size bigger smaller ui", }, + { + id: "show-excluded-files", + section: "appearance", + label: "Show excluded files", + keywords: "explorer gitignore ignored hidden files tree", + }, { id: "chat-background", section: "appearance", diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 3a426332..93878a8d 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -84,6 +84,9 @@ import { saveTranscriptLayout, saveTranscriptAnchor, TRANSCRIPT_ANCHOR_CHANGE_EVENT, + loadShowExcludedFiles, + saveShowExcludedFiles, + SHOW_EXCLUDED_FILES_DEFAULT, SIDEBAR_BLUR_DEFAULT, SIDEBAR_BLUR_MAX, SIDEBAR_BLUR_MIN, @@ -1312,6 +1315,9 @@ function useAppearanceSettings() { loadThemeDarkLightness, ); const [bodyGlass, setBodyGlass] = useState(loadBodyGlass); + const [showExcludedFiles, setShowExcludedFiles] = useState( + loadShowExcludedFiles, + ); const [chatBackgroundPath, setChatBackgroundPath] = useState( loadChatBackgroundPath, ); @@ -1374,6 +1380,11 @@ function useAppearanceSettings() { setBodyGlass(next); }, []); + const onShowExcludedFiles = useCallback((next: boolean) => { + saveShowExcludedFiles(next); + setShowExcludedFiles(next); + }, []); + const onChooseChatBackground = useCallback(async () => { setChatBackgroundBusy(true); setChatBackgroundError(null); @@ -1441,6 +1452,7 @@ function useAppearanceSettings() { onTint(THEME_HUE_DEFAULT, THEME_SATURATION_DEFAULT); onDarkLightness(THEME_DARK_LIGHTNESS_DEFAULT); onBodyGlass(BODY_GLASS_DEFAULT); + onShowExcludedFiles(SHOW_EXCLUDED_FILES_DEFAULT); onChatBackgroundEmptyOpacity( Math.round(CHAT_BACKGROUND_EMPTY_OPACITY_DEFAULT * 100), ); @@ -1459,6 +1471,7 @@ function useAppearanceSettings() { onChatBackgroundScope, onClearChatBackground, onAccentColor, + onShowExcludedFiles, onThemePreference, onOpacity, onTint, @@ -1475,6 +1488,7 @@ function useAppearanceSettings() { themeSaturation, themeDarkLightness, bodyGlass, + showExcludedFiles, chatBackgroundPath, chatBackgroundEmptyOpacity, chatBackgroundSessionOpacity, @@ -1489,6 +1503,7 @@ function useAppearanceSettings() { onTint, onDarkLightness, onBodyGlass, + onShowExcludedFiles, onChooseChatBackground, onClearChatBackground, onChatBackgroundEmptyOpacity, @@ -1662,6 +1677,17 @@ function AppearancePage({ appearance }: { appearance: AppearanceSettings }) { onChange={appearance.onUiScale} /> + + + );