Skip to content

Commit 4af855d

Browse files
committed
Add "Show Hidden Files" menu item
This is the first menu we're adding!
1 parent 435c69a commit 4af855d

9 files changed

Lines changed: 300 additions & 12 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ tauri = { version = "2", features = [] }
2323
tauri-plugin-opener = "2"
2424
tauri-plugin-store = "2"
2525
serde = { version = "1", features = ["derive"] }
26+
serde_json = "1"
2627
notify = "8"
2728
dirs = "6"
2829

src-tauri/src/lib.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
mod commands;
55
mod file_system;
6+
mod menu;
7+
8+
use menu::{MenuState, SHOW_HIDDEN_FILES_ID};
9+
use tauri::{Emitter, Manager};
610

711
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
812
#[tauri::command]
@@ -21,6 +25,36 @@ pub fn run() {
2125
builder
2226
.plugin(tauri_plugin_store::Builder::new().build())
2327
.plugin(tauri_plugin_opener::init())
28+
.setup(|app| {
29+
// Build and set the application menu
30+
// Default to showing hidden files (true)
31+
let (menu, show_hidden_item) = menu::build_menu(app.handle(), true)?;
32+
app.set_menu(menu)?;
33+
34+
// Store the CheckMenuItem reference in app state
35+
let menu_state = MenuState::default();
36+
*menu_state.show_hidden_files.lock().unwrap() = Some(show_hidden_item);
37+
app.manage(menu_state);
38+
39+
Ok(())
40+
})
41+
.on_menu_event(|app, event| {
42+
if event.id().as_ref() == SHOW_HIDDEN_FILES_ID {
43+
// Get the CheckMenuItem from app state
44+
let menu_state = app.state::<MenuState<tauri::Wry>>();
45+
let guard = menu_state.show_hidden_files.lock().unwrap();
46+
let Some(check_item) = guard.as_ref() else {
47+
return;
48+
};
49+
50+
// CheckMenuItem auto-toggles on click, so is_checked() returns the NEW state
51+
// We just need to read and emit it, not toggle again
52+
let new_state = check_item.is_checked().unwrap_or(true);
53+
54+
// Emit event to frontend with the new state
55+
let _ = app.emit("settings-changed", serde_json::json!({ "showHiddenFiles": new_state }));
56+
}
57+
})
2458
.invoke_handler(tauri::generate_handler![
2559
greet,
2660
commands::file_system::list_directory_contents,

src-tauri/src/menu.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//! Application menu configuration.
2+
3+
use std::sync::Mutex;
4+
use tauri::{
5+
AppHandle, Runtime,
6+
menu::{CheckMenuItem, Menu, Submenu},
7+
};
8+
9+
/// Menu item ID for the "Show Hidden Files" toggle.
10+
pub const SHOW_HIDDEN_FILES_ID: &str = "show_hidden_files";
11+
12+
/// Stores references to menu items that need to be accessed later.
13+
pub struct MenuState<R: Runtime> {
14+
pub show_hidden_files: Mutex<Option<CheckMenuItem<R>>>,
15+
}
16+
17+
impl<R: Runtime> Default for MenuState<R> {
18+
fn default() -> Self {
19+
Self {
20+
show_hidden_files: Mutex::new(None),
21+
}
22+
}
23+
}
24+
25+
/// Builds the application menu with default macOS items plus a custom View submenu.
26+
///
27+
/// This preserves the standard macOS app menu (About, Hide, Quit, etc.) and adds
28+
/// our Show Hidden Files item to the existing View menu.
29+
///
30+
/// # Arguments
31+
/// * `app` - The Tauri app handle
32+
/// * `show_hidden_files` - Initial checked state for the "Show Hidden Files" item
33+
///
34+
/// # Returns
35+
/// A tuple of (Menu, CheckMenuItem) so the caller can store the CheckMenuItem reference
36+
pub fn build_menu<R: Runtime>(
37+
app: &AppHandle<R>,
38+
show_hidden_files: bool,
39+
) -> tauri::Result<(Menu<R>, CheckMenuItem<R>)> {
40+
// Start with the default menu (includes app menu with Quit, Hide, etc.)
41+
let menu = Menu::default(app)?;
42+
43+
// Create our Show Hidden Files toggle
44+
let show_hidden_item = CheckMenuItem::with_id(
45+
app,
46+
SHOW_HIDDEN_FILES_ID,
47+
"Show Hidden Files",
48+
true, // enabled
49+
show_hidden_files,
50+
Some("Cmd+Shift+."),
51+
)?;
52+
53+
// Find the existing View submenu and add our item to it
54+
// The default menu on macOS has: App, File, Edit, View, Window, Help
55+
let mut found_view = false;
56+
for item in menu.items()? {
57+
if let tauri::menu::MenuItemKind::Submenu(submenu) = item
58+
&& submenu.text()? == "View"
59+
{
60+
// Add separator then our item
61+
submenu.append(&tauri::menu::PredefinedMenuItem::separator(app)?)?;
62+
submenu.append(&show_hidden_item)?;
63+
found_view = true;
64+
break;
65+
}
66+
}
67+
68+
// If View menu wasn't found (unlikely), create one
69+
if !found_view {
70+
let view_menu = Submenu::with_items(app, "View", true, &[&show_hidden_item])?;
71+
menu.append(&view_menu)?;
72+
}
73+
74+
Ok((menu, show_hidden_item))
75+
}

src/lib/file-explorer/DualPaneExplorer.svelte

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
<script lang="ts">
2-
import { onMount } from 'svelte'
2+
import { onMount, onDestroy } from 'svelte'
33
import FilePane from './FilePane.svelte'
44
import { loadAppStatus, saveAppStatus } from '$lib/app-status-store'
5+
import { loadSettings, saveSettings, subscribeToSettingsChanges } from '$lib/settings-store'
56
import { pathExists } from '$lib/tauri-commands'
7+
import type { UnlistenFn } from '@tauri-apps/api/event'
68
79
let leftPath = $state('~')
810
let rightPath = $state('~')
911
let focusedPane = $state<'left' | 'right'>('left')
12+
let showHiddenFiles = $state(true)
1013
let initialized = $state(false)
1114
1215
let containerElement: HTMLDivElement | undefined = $state()
1316
let leftPaneRef: FilePane | undefined = $state()
1417
let rightPaneRef: FilePane | undefined = $state()
18+
let unlistenSettings: UnlistenFn | undefined
1519
1620
function handleLeftPathChange(path: string) {
1721
leftPath = path
@@ -56,16 +60,30 @@
5660
activePaneRef?.handleKeyDown(e)
5761
}
5862
59-
onMount(() => {
60-
// Load persisted state
61-
void loadAppStatus(pathExists).then((status) => {
62-
leftPath = status.leftPath
63-
rightPath = status.rightPath
64-
focusedPane = status.focusedPane
65-
initialized = true
63+
onMount(async () => {
64+
// Load persisted state and settings in parallel
65+
const [status, settings] = await Promise.all([loadAppStatus(pathExists), loadSettings()])
66+
67+
leftPath = status.leftPath
68+
rightPath = status.rightPath
69+
focusedPane = status.focusedPane
70+
showHiddenFiles = settings.showHiddenFiles
71+
initialized = true
72+
73+
// Subscribe to settings changes from the backend menu
74+
unlistenSettings = await subscribeToSettingsChanges((newSettings) => {
75+
if (newSettings.showHiddenFiles !== undefined) {
76+
showHiddenFiles = newSettings.showHiddenFiles
77+
// Persist to settings store
78+
void saveSettings({ showHiddenFiles: newSettings.showHiddenFiles })
79+
}
6680
})
6781
})
6882
83+
onDestroy(() => {
84+
unlistenSettings?.()
85+
})
86+
6987
// Focus the container after initialization so keyboard events work
7088
$effect(() => {
7189
if (initialized) {
@@ -88,13 +106,15 @@
88106
bind:this={leftPaneRef}
89107
initialPath={leftPath}
90108
isFocused={focusedPane === 'left'}
109+
{showHiddenFiles}
91110
onPathChange={handleLeftPathChange}
92111
onRequestFocus={handleLeftFocus}
93112
/>
94113
<FilePane
95114
bind:this={rightPaneRef}
96115
initialPath={rightPath}
97116
isFocused={focusedPane === 'right'}
117+
{showHiddenFiles}
98118
onPathChange={handleRightPathChange}
99119
onRequestFocus={handleRightFocus}
100120
/>

src/lib/file-explorer/DualPaneExplorer.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ vi.mock('$lib/tauri-commands', () => ({
2020
openFile: vi.fn().mockResolvedValue(undefined),
2121
}))
2222

23+
// Mock settings-store to avoid Tauri event API dependency in tests
24+
vi.mock('$lib/settings-store', () => ({
25+
loadSettings: vi.fn().mockResolvedValue({
26+
showHiddenFiles: true,
27+
}),
28+
saveSettings: vi.fn().mockResolvedValue(undefined),
29+
subscribeToSettingsChanges: vi.fn().mockResolvedValue(() => {}),
30+
}))
31+
2332
describe('DualPaneExplorer', () => {
2433
it('renders dual pane container', () => {
2534
const target = document.createElement('div')

src/lib/file-explorer/FilePane.svelte

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
initialPath: string
1111
fileService?: FileService
1212
isFocused?: boolean
13+
showHiddenFiles?: boolean
1314
onPathChange?: (path: string) => void
1415
onRequestFocus?: () => void
1516
}
@@ -18,17 +19,28 @@
1819
initialPath,
1920
fileService = defaultFileService,
2021
isFocused = false,
22+
showHiddenFiles = true,
2123
onPathChange,
2224
onRequestFocus,
2325
}: Props = $props()
2426
2527
let currentPath = $state(initialPath)
26-
let files = $state<FileEntry[]>([])
28+
let allFiles = $state<FileEntry[]>([])
2729
let loading = $state(true)
2830
let error = $state<string | null>(null)
2931
let selectedIndex = $state(0)
3032
let fileListRef: FileList | undefined = $state()
3133
34+
// Filter files based on showHiddenFiles setting
35+
// Always keep ".." visible for parent navigation
36+
function filterFiles(entries: FileEntry[], showHidden: boolean): FileEntry[] {
37+
if (showHidden) return entries
38+
return entries.filter((e) => !e.name.startsWith('.') || e.name === '..')
39+
}
40+
41+
// Compute visible files based on showHiddenFiles prop
42+
const files = $derived(filterFiles(allFiles, showHiddenFiles))
43+
3244
// Create ".." entry for parent navigation
3345
function createParentEntry(path: string): FileEntry | null {
3446
if (path === '/') return null
@@ -46,18 +58,22 @@
4658
try {
4759
const entries = await fileService.listDirectory(path)
4860
const parentEntry = createParentEntry(path)
49-
files = parentEntry ? [parentEntry, ...entries] : entries
61+
allFiles = parentEntry ? [parentEntry, ...entries] : entries
5062
5163
// If selectName is provided, find and select that entry
64+
// But only if it's visible (not filtered out)
5265
if (selectName) {
53-
const targetIndex = files.findIndex((f) => f.name === selectName)
66+
const visibleFiles = filterFiles(allFiles, showHiddenFiles)
67+
const targetIndex = visibleFiles.findIndex((f) => f.name === selectName)
68+
// If target is hidden (e.g., navigating up from .config with hidden files off),
69+
// fall back to index 0
5470
selectedIndex = targetIndex >= 0 ? targetIndex : 0
5571
} else {
5672
selectedIndex = 0
5773
}
5874
} catch (e) {
5975
error = e instanceof Error ? e.message : String(e)
60-
files = []
76+
allFiles = []
6177
} finally {
6278
loading = false
6379
}
@@ -120,6 +136,16 @@
120136
}
121137
})
122138
139+
// Reset selection when showHiddenFiles changes and current selection becomes invalid
140+
$effect(() => {
141+
// Re-run when files change (which depends on showHiddenFiles)
142+
if (selectedIndex >= files.length && files.length > 0) {
143+
selectedIndex = 0
144+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
145+
fileListRef?.scrollToIndex(0)
146+
}
147+
})
148+
123149
onMount(() => {
124150
void loadDirectory(currentPath)
125151
})

src/lib/file-explorer/FilePane.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,60 @@ describe('FilePane', () => {
8585

8686
expect(target.textContent).not.toContain('[..]')
8787
})
88+
89+
it('hides hidden files when showHiddenFiles is false', async () => {
90+
const filesWithHidden: FileEntry[] = [
91+
{ name: '.hidden', path: '/test/.hidden', isDirectory: false },
92+
{ name: '.config', path: '/test/.config', isDirectory: true },
93+
{ name: 'visible.txt', path: '/test/visible.txt', isDirectory: false },
94+
]
95+
mockService.setMockData('/test', filesWithHidden)
96+
const target = document.createElement('div')
97+
mount(FilePane, {
98+
target,
99+
props: { initialPath: '/test', fileService: mockService, showHiddenFiles: false },
100+
})
101+
102+
await tick()
103+
await tick()
104+
105+
expect(target.textContent).toContain('visible.txt')
106+
expect(target.textContent).not.toContain('.hidden')
107+
expect(target.textContent).not.toContain('[.config]')
108+
})
109+
110+
it('shows hidden files when showHiddenFiles is true', async () => {
111+
const filesWithHidden: FileEntry[] = [
112+
{ name: '.hidden', path: '/test/.hidden', isDirectory: false },
113+
{ name: 'visible.txt', path: '/test/visible.txt', isDirectory: false },
114+
]
115+
mockService.setMockData('/test', filesWithHidden)
116+
const target = document.createElement('div')
117+
mount(FilePane, {
118+
target,
119+
props: { initialPath: '/test', fileService: mockService, showHiddenFiles: true },
120+
})
121+
122+
await tick()
123+
await tick()
124+
125+
expect(target.textContent).toContain('visible.txt')
126+
expect(target.textContent).toContain('.hidden')
127+
})
128+
129+
it('always shows .. entry even when showHiddenFiles is false', async () => {
130+
const filesWithHidden: FileEntry[] = [{ name: '.hidden', path: '/test/.hidden', isDirectory: false }]
131+
mockService.setMockData('/test', filesWithHidden)
132+
const target = document.createElement('div')
133+
mount(FilePane, {
134+
target,
135+
props: { initialPath: '/test', fileService: mockService, showHiddenFiles: false },
136+
})
137+
138+
await tick()
139+
await tick()
140+
141+
// Parent entry should still be visible
142+
expect(target.textContent).toContain('[..]')
143+
})
88144
})

0 commit comments

Comments
 (0)