diff --git a/.gitignore b/.gitignore index fd531785..6b20607b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target .DS_Store -.vscode \ No newline at end of file +.vscode +ignored \ No newline at end of file diff --git a/cardinal/.prettierignore b/cardinal/.prettierignore index 82b7dd09..4f34a6b8 100644 --- a/cardinal/.prettierignore +++ b/cardinal/.prettierignore @@ -1,5 +1,6 @@ node_modules /dist /.tauri-build +/src-tauri/gen /src-tauri/target /src-tauri/.cargo diff --git a/cardinal/package-lock.json b/cardinal/package-lock.json index a94ba9d5..bc306b19 100644 --- a/cardinal/package-lock.json +++ b/cardinal/package-lock.json @@ -17,6 +17,7 @@ "react": "^18", "react-dom": "^18", "react-i18next": "^16.5.0", + "react-tooltip": "^5.30.0", "react-virtualized-auto-sizer": "^1.0.26", "react-window": "^2.2.2", "tauri-plugin-macos-permissions-api": "^2.3.0" @@ -944,6 +945,31 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2135,6 +2161,12 @@ "node": ">= 16" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3421,6 +3453,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-tooltip": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.30.0.tgz", + "integrity": "sha512-Yn8PfbgQ/wmqnL7oBpz1QiDaLKrzZMdSUUdk7nVeGTwzbxCAJiJzR4VSYW+eIO42F1INt57sPUmpgKv0KwJKtg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.1", + "classnames": "^2.3.0" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, "node_modules/react-virtualized-auto-sizer": { "version": "1.0.26", "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", diff --git a/cardinal/package.json b/cardinal/package.json index 2b9128fc..c60c04c2 100644 --- a/cardinal/package.json +++ b/cardinal/package.json @@ -24,6 +24,7 @@ "react": "^18", "react-dom": "^18", "react-i18next": "^16.5.0", + "react-tooltip": "^5.30.0", "react-virtualized-auto-sizer": "^1.0.26", "react-window": "^2.2.2", "tauri-plugin-macos-permissions-api": "^2.3.0" diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml index 9c5dbfc4..30e9ec5e 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -32,7 +32,7 @@ base64 = "0.22" rayon = "1.10" objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSArray", "NSString", "NSURL"] } -objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSPasteboard", "NSPanel", "NSResponder", "NSWindow"] } +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSOpenPanel", "NSPasteboard", "NSPanel", "NSResponder", "NSWindow"] } objc2-quick-look-ui = "0.3" parking_lot = "0.12" tauri-plugin-prevent-default = "4" diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 4fcd2a12..fc05b84a 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -13,10 +13,14 @@ use base64::{Engine as _, engine::general_purpose}; use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf}; use crossbeam_channel::{Receiver, Sender, bounded}; use objc2::{ + MainThreadMarker, rc::{Retained, autoreleasepool}, runtime::ProtocolObject, }; -use objc2_app_kit::{NSPasteboard, NSPasteboardItem, NSPasteboardTypeString, NSPasteboardWriting}; +use objc2_app_kit::{ + NSModalResponse, NSModalResponseOK, NSOpenPanel, NSPasteboard, NSPasteboardItem, + NSPasteboardTypeString, NSPasteboardWriting, +}; use objc2_foundation::{NSArray, NSString, NSURL}; use parking_lot::Mutex; use search_cache::{SearchOptions, SearchOutcome, SearchResultNode, SlabIndex, SlabNodeMetadata}; @@ -474,6 +478,44 @@ pub async fn copy_files_to_clipboard(paths: Vec) { } } +#[tauri::command] +pub async fn pick_folder(app: AppHandle) -> Result, String> { + let (response_tx, response_rx) = bounded::>(1); + if let Err(err) = app.run_on_main_thread(move || { + autoreleasepool(|_| { + let Some(mtm) = MainThreadMarker::new() else { + let _ = response_tx.send(None); + return; + }; + + let panel = NSOpenPanel::openPanel(mtm); + panel.setCanChooseDirectories(true); + panel.setCanChooseFiles(false); + panel.setAllowsMultipleSelection(false); + + let response: NSModalResponse = panel.runModal(); + let selected_path = if response == NSModalResponseOK { + panel + .URLs() + .firstObject() + .and_then(|url| url.path()) + .map(|path| path.to_string()) + } else { + None + }; + + let _ = response_tx.send(selected_path); + }); + }) { + error!("Failed to dispatch folder picker: {err:?}"); + return Err(format!("Failed to open folder picker: {err:?}")); + } + + response_rx + .recv() + .map_err(|err| format!("Failed to receive folder picker response: {err:?}")) +} + fn copy_files_to_clipboard_impl(paths: Vec) -> Result<()> { autoreleasepool(|_| unsafe { let pasteboard = NSPasteboard::generalPasteboard(); diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index d7b34c60..3297335b 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -15,7 +15,7 @@ use cardinal_sdk::EventWatcher; use commands::{ NodeInfoRequest, SearchJob, SearchState, WatchConfigUpdate, activate_main_window, close_quicklook, copy_files_to_clipboard, get_app_status, get_nodes_info, get_sorted_view, - hide_main_window, normalize_watch_config, open_in_finder, open_path, search, + hide_main_window, normalize_watch_config, open_in_finder, open_path, pick_folder, search, set_tray_activation_policy, set_watch_config, start_logic, toggle_main_window, toggle_quicklook, trigger_rescan, update_icon_viewport, update_quicklook, }; @@ -141,6 +141,7 @@ pub fn run() -> Result<()> { toggle_main_window, set_tray_activation_policy, copy_files_to_clipboard, + pick_folder, ]) .build(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/cardinal/src/App.css b/cardinal/src/App.css index cbde9aa9..748ba585 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -122,6 +122,9 @@ main, margin: var(--container-padding) var(--container-padding) 0; padding: 0; width: calc(100% - var(--container-padding) * 2); + display: flex; + flex-direction: column; + gap: 0.55rem; } .search-bar { @@ -136,6 +139,47 @@ main, box-sizing: border-box; } +.search-help-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 60px; + height: 2rem; + padding: 0; + border-radius: 999px; + border: 1px solid rgba(var(--color-accent-rgb), 0.14); + background: rgba(var(--color-accent-rgb), 0.06); + color: var(--color-text); + font-size: 0.92rem; + font-weight: 700; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + color 0.2s ease, + transform 0.15s ease; +} + +.search-help-trigger:hover { + border-color: rgba(var(--color-accent-rgb), 0.28); + background: rgba(var(--color-accent-rgb), 0.12); + color: var(--color-text); + transform: translateY(-1px); +} + +.search-help-trigger:focus-visible { + outline: 2px solid rgba(var(--color-accent-rgb), 0.45); + outline-offset: 2px; +} + +.search-help-trigger--filters { + flex-shrink: 0; +} + +.search-help-trigger--text { + padding: 0.34rem 0.8rem; +} + #search-input { flex: 1; width: auto; @@ -163,6 +207,26 @@ main, flex-shrink: 0; } +.search-filter-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0.34rem 0.8rem; + border-radius: 999px; + border: 1px solid rgba(var(--color-accent-rgb), 0.14); + background: rgba(var(--color-accent-rgb), 0.05); + color: var(--color-text); + font-size: 0.92rem; + font-weight: 600; + box-shadow: none; +} + +.search-filter-toggle:hover:not(:disabled) { + border-color: rgba(var(--color-accent-rgb), 0.28); + background: rgba(var(--color-accent-rgb), 0.1); +} + .search-option { position: relative; display: inline-flex; @@ -226,6 +290,104 @@ main, border: 0; } +.search-filters-bar { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; + padding: 0.45rem 0.55rem; + border-radius: 12px; + border: 1px solid rgba(var(--color-accent-rgb), 0.1); + background: + linear-gradient( + 135deg, + rgba(var(--color-accent-rgb), 0.045), + rgba(var(--color-accent-rgb), 0.02) + ), + var(--color-elevated-bg); + box-sizing: border-box; +} + +.search-filters-group { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.search-filters-help-slot { + display: flex; + align-items: center; + margin-left: auto; + flex-shrink: 0; +} + +.search-filters-separator { + width: 1px; + align-self: stretch; + min-height: 2rem; + background: rgba(var(--color-accent-rgb), 0.14); +} + +.search-filter-button, +.search-filter-select { + min-height: 2rem; + padding: 0.38rem 0.72rem; + border-radius: 999px; + border: 1px solid rgba(var(--color-accent-rgb), 0.14); + background: rgba(var(--color-accent-rgb), 0.05); + color: var(--color-text); + font-size: 0.93rem; + box-shadow: none; +} + +.search-filter-button { + font-weight: 600; +} + +.search-filters-group:first-child .search-filter-button { + width: 110px; + justify-content: flex-start; + text-align: left; +} + +.search-filter-button:hover:not(:disabled), +.search-filter-select:hover { + border-color: rgba(var(--color-accent-rgb), 0.28); + background-color: rgba(var(--color-accent-rgb), 0.09); +} + +.search-filter-button--example { + background: rgba(var(--color-accent-rgb), 0.08); +} + +.search-filter-select-wrap { + position: relative; + display: inline-flex; + align-items: center; + width: 110px; +} + +.search-filter-select { + appearance: none; + cursor: pointer; + width: 100%; + padding-right: 2rem; + background-image: none; + background-repeat: no-repeat; +} + +.search-filter-select-caret { + position: absolute; + right: 0.78rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-muted); + font-size: 0.72rem; + line-height: 1; + pointer-events: none; +} + @media (max-width: 640px) { .search-bar { flex-wrap: wrap; @@ -237,6 +399,15 @@ main, margin-left: 0; padding-top: 0.35rem; } + + .search-filters-separator { + display: none; + } + + .search-filters-help-slot { + width: 100%; + justify-content: flex-end; + } } /* Inputs and buttons (used in search area) */ @@ -1076,6 +1247,10 @@ button:active:not(:disabled) { position: relative; } +.status-icon-button-anchor { + display: inline-flex; +} + .status-icon-button { width: 22px; height: 22px; @@ -1514,6 +1689,238 @@ button:active:not(:disabled) { min-width: 180px; } +.search-help-overlay { + position: fixed; + inset: 0; + z-index: 2100; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: rgba(15, 17, 26, 0.78); + backdrop-filter: blur(6px); +} + +.search-help-card { + width: 100%; + max-width: 760px; + max-height: calc(100vh - 96px); + overflow: auto; + background: var(--color-bg); + color: var(--color-text); + border-radius: 18px; + padding: 28px; + box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28); + text-align: left; + font-size: 1.08rem; + scrollbar-width: none; +} + +.search-help-card::-webkit-scrollbar { + display: none; +} + +.search-help-card__header { + margin-bottom: 20px; +} + +.search-help-card__header-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.search-help-card__eyebrow { + margin: 0 0 6px; + color: var(--color-accent); + font-size: 1.05rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.search-help-card__title { + margin: 0; + font-size: 1.3rem; + color: var(--color-header); +} + +.search-help-card__subtitle { + margin: 2px 0 0; + color: var(--color-text-secondary); + font-size: 1.08rem; +} + +.search-help-content { + display: grid; + gap: 20px; +} + +.search-help-examples-panel, +.search-help-section { + padding: 20px 22px; + border: 1px solid rgba(var(--color-accent-rgb), 0.1); + border-radius: 16px; + background: + linear-gradient(135deg, rgba(var(--color-accent-rgb), 0.06), transparent 45%), + var(--color-elevated-bg); +} + +.search-help-section-copy { + margin-bottom: 14px; +} + +.search-help-examples-panel h2, +.search-help-section h2 { + margin: 0 0 8px; + font-size: 1.2rem; + color: var(--color-header); +} + +.search-help-examples-panel p, +.search-help-section p { + margin: 0 0 12px; + color: var(--color-text-secondary); + font-size: 1.16rem; + line-height: 1.55; +} + +.search-help-list { + margin: 0; + padding-left: 18px; + display: grid; + gap: 8px; +} + +.search-help-list li { + color: var(--color-text); + font-size: 1.08rem; + line-height: 1.55; +} + +.search-help-examples { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.search-help-example { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.4rem 0.7rem; + border-radius: 10px; + border: 1px solid rgba(var(--color-accent-rgb), 0.14); + background: rgba(var(--color-accent-rgb), 0.08); + color: var(--color-text); + font-size: 1.17rem; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + transform 0.15s ease; +} + +.search-help-example:hover { + border-color: rgba(var(--color-accent-rgb), 0.28); + background: rgba(var(--color-accent-rgb), 0.13); + transform: translateY(-1px); +} + +.search-help-example:focus-visible { + outline: 2px solid rgba(var(--color-accent-rgb), 0.45); + outline-offset: 2px; +} + +.search-help-example code { + display: inline-flex; + align-items: center; + font-size: inherit; + pointer-events: none; +} + +.app-tooltip { + max-width: min(520px, calc(100vw - 24px)); + z-index: 2300 !important; + text-align: left; + border-radius: 10px !important; + padding: 10px 12px !important; + background: rgba(24, 30, 42, 0.96) !important; + color: #f7f9fc !important; + border: 1px solid rgba(var(--color-accent-rgb), 0.2); + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.28); + font-size: 0.95rem !important; + line-height: 1.4; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.search-help-tooltip { + max-width: 450px !important; +} + +.search-help-guide-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.search-help-guide-card { + padding: 16px 16px 14px; + border-radius: 14px; + background: rgba(var(--color-accent-rgb), 0.05); + border: 1px solid rgba(var(--color-accent-rgb), 0.08); +} + +.search-help-guide-card h3 { + margin: 0 0 10px; + font-size: 1.02rem; + color: var(--color-header); +} + +.search-help-docs { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--color-accent); + text-decoration: none; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: + color 0.2s ease, + opacity 0.2s ease; +} + +.search-help-docs:hover { + color: rgba(var(--color-accent-rgb), 0.82); +} + +.search-help-docs:focus-visible { + outline: 2px solid rgba(var(--color-accent-rgb), 0.45); + outline-offset: 2px; + border-radius: 8px; +} + +.search-help-docs__icon { + font-size: 0.95em; + line-height: 1; +} + +@media (max-width: 720px) { + .search-help-card__header-main { + flex-direction: column; + align-items: stretch; + } + + .search-help-guide-grid { + grid-template-columns: minmax(0, 1fr); + } +} + /* === Preferences Modal === */ .preferences-overlay { position: fixed; diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index f98ccbe2..96136f6c 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -1,11 +1,14 @@ -import { useRef, useCallback, useEffect, useMemo } from 'react'; +import { useRef, useCallback, useEffect, useMemo, useState } from 'react'; import type { ChangeEvent, CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; import './App.css'; import { FileRow } from './components/FileRow'; +import { AppTooltip } from './components/AppTooltip'; import { SearchBar } from './components/SearchBar'; +import { SearchFiltersBar, type SearchFiltersBarAction } from './components/SearchFiltersBar'; import { FilesTabContent } from './components/FilesTabContent'; import { PermissionOverlay } from './components/PermissionOverlay'; import PreferencesOverlay from './components/PreferencesOverlay'; +import { SearchHelpOverlay } from './components/SearchHelpOverlay'; import StatusBar from './components/StatusBar'; import type { SearchResultItem } from './types/search'; import { useColumnResize } from './hooks/useColumnResize'; @@ -30,6 +33,9 @@ import { useAppPreferences } from './hooks/useAppPreferences'; import { useAppWindowListeners } from './hooks/useAppWindowListeners'; import { useFilesTabEffects } from './hooks/useFilesTabEffects'; import { useFilesTabState } from './hooks/useFilesTabState'; +import { applySearchToolbarQueryAction } from './utils/searchToolbarQuery'; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); function App() { const { @@ -83,6 +89,7 @@ function App() { const { activeTab, + setActiveTab, isSearchFocused, handleSearchFocus, handleSearchBlur, @@ -102,6 +109,8 @@ function App() { isActive: activeTab === 'events', eventFilterQuery, }); + const [isSearchHelpOpen, setIsSearchHelpOpen] = useState(false); + const [areSearchFiltersVisible, setAreSearchFiltersVisible] = useState(true); // Centralized selection management for the virtualized files list. // Provides memoized helpers for click/keyboard selection and keeps Quick Look hooks fed. @@ -326,13 +335,71 @@ function App() { t('app.fullDiskAccess.steps.three'), ]; const openSettingsLabel = t('app.fullDiskAccess.openSettings'); + const searchHelpLabel = t('search.help.open'); + const searchFiltersToggleLabel = areSearchFiltersVisible + ? t('search.filterBar.toggle.hide') + : t('search.filterBar.toggle.show'); const resultsContainerClassName = `results-container${ isSearchFocused ? ' results-container--search-focused' : '' }`; + const getLiveSearchQuery = useCallback( + () => searchInputRef.current?.value ?? searchInputValue, + [searchInputValue], + ); + const handleApplySearchExample = useCallback( + (example: string) => { + const baseQuery = getLiveSearchQuery(); + const examplePattern = new RegExp(`(^|\\s+)${escapeRegExp(example)}(?=\\s+|$)`, 'g'); + const dedupedQuery = baseQuery.replace(examplePattern, ' ').replace(/\s+/g, ' ').trimEnd(); + const nextQuery = `${dedupedQuery} ${example}`; + + setActiveTab('files'); + submitFilesQuery(nextQuery, { immediate: true }); + + requestAnimationFrame(() => { + const input = searchInputRef.current; + if (!input) { + return; + } + + input.focus(); + input.setSelectionRange(nextQuery.length, nextQuery.length); + }); + }, + [getLiveSearchQuery, setActiveTab, submitFilesQuery], + ); + const focusSearchInputAtEnd = useCallback((nextQuery: string) => { + requestAnimationFrame(() => { + const input = searchInputRef.current; + if (!input) { + return; + } + + input.focus(); + input.setSelectionRange(nextQuery.length, nextQuery.length); + }); + }, []); + const handleApplySearchFilter = useCallback( + (action: SearchFiltersBarAction) => { + const baseQuery = getLiveSearchQuery(); + const nextQuery = applySearchToolbarQueryAction(baseQuery, action); + if (nextQuery === baseQuery) { + focusSearchInputAtEnd(nextQuery); + return; + } + + submitFilesQuery(nextQuery, { immediate: true }); + focusSearchInputAtEnd(nextQuery); + }, + [focusSearchInputAtEnd, getLiveSearchQuery, submitFilesQuery], + ); return ( <> -
+
setAreSearchFiltersVisible((currentValue) => !currentValue) + : undefined + } onFocus={handleSearchFocus} onBlur={handleSearchBlur} + filtersBar={ + activeTab === 'files' && areSearchFiltersVisible ? ( + setIsSearchHelpOpen(true)} + helpButtonLabel={searchHelpLabel} + /> + ) : undefined + } />
{activeTab === 'events' ? ( @@ -409,6 +491,11 @@ function App() { onReset={handleResetPreferences} themeResetToken={preferencesResetToken} /> + setIsSearchHelpOpen(false)} + onApplyExample={handleApplySearchExample} + /> {showFullDiskAccessOverlay && ( )} + ); } diff --git a/cardinal/src/components/AppTooltip.tsx b/cardinal/src/components/AppTooltip.tsx new file mode 100644 index 00000000..e4474652 --- /dev/null +++ b/cardinal/src/components/AppTooltip.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Tooltip } from 'react-tooltip'; +import 'react-tooltip/dist/react-tooltip.css'; +import { APP_TOOLTIP_ID } from '../utils/tooltip'; + +export function AppTooltip(): React.JSX.Element { + return ( + + ); +} diff --git a/cardinal/src/components/ColumnHeader.tsx b/cardinal/src/components/ColumnHeader.tsx index 7de4f9a8..c258f13f 100644 --- a/cardinal/src/components/ColumnHeader.tsx +++ b/cardinal/src/components/ColumnHeader.tsx @@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent } from 'react'; import type { ColumnKey } from '../constants'; import type { SortKey, SortState } from '../types/sort'; import { useTranslation } from 'react-i18next'; +import { getTooltipAttributes } from '../utils/tooltip'; const columns: Array<{ key: ColumnKey; labelKey: string; className: string }> = [ { key: 'filename', labelKey: 'columns.filename', className: 'filename-text' }, @@ -59,17 +60,20 @@ export const ColumnHeader = forwardRef( indicatorClasses.push('sort-indicator--active'); } - const title = sortDisabled ? sortDisabledTooltip || undefined : undefined; + const tooltipContent = sortDisabled ? sortDisabledTooltip : null; return ( - +
-

+

{t('watchRoot.label')}

@@ -231,7 +232,7 @@ export function PreferencesOverlay({
-

+

{t('ignorePaths.label')}

diff --git a/cardinal/src/components/SearchBar.tsx b/cardinal/src/components/SearchBar.tsx index bc2c853a..c2bc4f00 100644 --- a/cardinal/src/components/SearchBar.tsx +++ b/cardinal/src/components/SearchBar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { ChangeEvent, FocusEventHandler } from 'react'; +import { getTooltipAttributes } from '../utils/tooltip'; type SearchBarProps = { inputRef: React.Ref; @@ -10,8 +11,11 @@ type SearchBarProps = { caseSensitive: boolean; onToggleCaseSensitive: (event: ChangeEvent) => void; caseSensitiveLabel: string; + filtersToggleLabel?: string; + onToggleFilters?: () => void; onFocus: FocusEventHandler; onBlur: FocusEventHandler; + filtersBar?: React.ReactNode; }; export function SearchBar({ @@ -23,8 +27,11 @@ export function SearchBar({ caseSensitive, onToggleCaseSensitive, caseSensitiveLabel, + filtersToggleLabel, + onToggleFilters, onFocus, onBlur, + filtersBar, }: SearchBarProps): React.JSX.Element { return (
@@ -44,7 +51,7 @@ export function SearchBar({ onBlur={onBlur} />
-
+ {filtersBar}
); } diff --git a/cardinal/src/components/SearchFiltersBar.tsx b/cardinal/src/components/SearchFiltersBar.tsx new file mode 100644 index 00000000..3e64e133 --- /dev/null +++ b/cardinal/src/components/SearchFiltersBar.tsx @@ -0,0 +1,256 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getTooltipAttributes } from '../utils/tooltip'; +import { pickFolder } from '../utils/pickFolder'; + +export type SearchFiltersBarAction = + | { kind: 'append-token'; token: string } + | { kind: 'merge-extension'; extension: string } + | { kind: 'replace-family'; family: 'kind' | 'type' | 'date' | 'size' | 'scope'; token: string }; + +export type SearchFiltersBarProps = { + onApplyAction: (action: SearchFiltersBarAction) => void; + onOpenHelp: () => void; + helpButtonLabel: string; +}; + +type FilterOption = { + label: string; + value: string; +}; + +type FilterSelectProps = { + ariaLabel: string; + tooltip: string; + options: FilterOption[]; + onChange: (event: React.ChangeEvent) => void; +}; + +const FilterSelect = ({ + ariaLabel, + tooltip, + options, + onChange, +}: FilterSelectProps): React.JSX.Element => ( +
+ + +
+); + +const quoteFilterArgument = (value: string): string => { + if (!/[\s";]/.test(value)) { + return value; + } + + const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escapedValue}"`; +}; + +export const SearchFiltersBar = ({ + onApplyAction, + onOpenHelp, + helpButtonLabel, +}: SearchFiltersBarProps): React.JSX.Element => { + const { t } = useTranslation(); + const inFolderTooltip = t('search.filterBar.tooltips.scope.in'); + const parentFolderTooltip = t('search.filterBar.tooltips.scope.parent'); + const kindTooltip = t('search.filterBar.tooltips.kind'); + const typeTooltip = t('search.filterBar.tooltips.type'); + const extensionTooltip = t('search.filterBar.tooltips.extension'); + const dateTooltip = t('search.filterBar.tooltips.date'); + const sizeTooltip = t('search.filterBar.tooltips.size'); + const regexTooltip = t('search.filterBar.tooltips.regexExample'); + + const kindOptions: FilterOption[] = [ + { label: t('search.filterBar.kind.files'), value: 'file:' }, + { label: t('search.filterBar.kind.folders'), value: 'folder:' }, + ]; + const typeOptions: FilterOption[] = [ + { label: t('search.filterBar.type.picture'), value: 'type:picture' }, + { label: t('search.filterBar.type.video'), value: 'type:video' }, + { label: t('search.filterBar.type.audio'), value: 'type:audio' }, + { label: t('search.filterBar.type.document'), value: 'type:doc' }, + { label: t('search.filterBar.type.presentation'), value: 'type:presentation' }, + { label: t('search.filterBar.type.spreadsheet'), value: 'type:spreadsheet' }, + { label: t('search.filterBar.type.pdf'), value: 'type:pdf' }, + { label: t('search.filterBar.type.archive'), value: 'type:archive' }, + { label: t('search.filterBar.type.code'), value: 'type:code' }, + { label: t('search.filterBar.type.executable'), value: 'type:exe' }, + ]; + const extensionOptions: FilterOption[] = [ + { label: 'PDF', value: 'pdf' }, + { label: 'MD', value: 'md' }, + { label: 'TXT', value: 'txt' }, + { label: 'JSON', value: 'json' }, + { label: 'TS', value: 'ts' }, + { label: 'TSX', value: 'tsx' }, + { label: 'RS', value: 'rs' }, + { label: 'JPG', value: 'jpg' }, + { label: 'PNG', value: 'png' }, + ]; + const dateOptions: FilterOption[] = [ + { label: t('search.filterBar.date.modifiedToday'), value: 'dm:today' }, + { label: t('search.filterBar.date.modifiedThisWeek'), value: 'dm:thisweek' }, + { label: t('search.filterBar.date.modifiedLastMonth'), value: 'dm:lastmonth' }, + { label: t('search.filterBar.date.createdToday'), value: 'dc:today' }, + { label: t('search.filterBar.date.createdThisWeek'), value: 'dc:thisweek' }, + { label: t('search.filterBar.date.createdLastMonth'), value: 'dc:lastmonth' }, + ]; + const sizeOptions: FilterOption[] = [ + { label: t('search.filterBar.size.empty'), value: 'size:empty' }, + { label: t('search.filterBar.size.tiny'), value: 'size:tiny' }, + { label: t('search.filterBar.size.small'), value: 'size:small' }, + { label: t('search.filterBar.size.medium'), value: 'size:medium' }, + { label: t('search.filterBar.size.large'), value: 'size:large' }, + { label: t('search.filterBar.size.huge'), value: 'size:huge' }, + { label: t('search.filterBar.size.rangeOneToTenMb'), value: 'size:1mb..10mb' }, + { label: t('search.filterBar.size.overHundredMb'), value: 'size:>100mb' }, + ]; + + const handleScopePick = useCallback( + async (scopePrefix: 'in' | 'parent') => { + const selectedPath = await pickFolder(); + if (!selectedPath) { + return; + } + + const quotedPath = quoteFilterArgument(selectedPath); + onApplyAction({ + kind: 'replace-family', + family: 'scope', + token: `${scopePrefix}:${quotedPath}`, + }); + }, + [onApplyAction], + ); + + const handleReplaceSelection = useCallback( + (event: React.ChangeEvent, family: 'kind' | 'type' | 'date' | 'size') => { + const selectedToken = event.target.value; + event.target.value = ''; + if (!selectedToken) { + return; + } + + onApplyAction({ kind: 'replace-family', family, token: selectedToken }); + }, + [onApplyAction], + ); + + const handleExtensionSelection = useCallback( + (event: React.ChangeEvent) => { + const selectedExtension = event.target.value; + event.target.value = ''; + if (!selectedExtension) { + return; + } + + onApplyAction({ kind: 'merge-extension', extension: selectedExtension }); + }, + [onApplyAction], + ); + + return ( +
+
+ + +
+ +
+ ); +}; diff --git a/cardinal/src/components/SearchHelpOverlay.tsx b/cardinal/src/components/SearchHelpOverlay.tsx new file mode 100644 index 00000000..2955de0b --- /dev/null +++ b/cardinal/src/components/SearchHelpOverlay.tsx @@ -0,0 +1,247 @@ +import React, { useEffect, useMemo } from 'react'; +import { openUrl } from '@tauri-apps/plugin-opener'; +import { useTranslation } from 'react-i18next'; +import { getTooltipAttributes } from '../utils/tooltip'; + +const SEARCH_SYNTAX_DOCS_URL = + 'https://github.com/cardisoft/cardinal/blob/master/doc/pub/search-syntax.md'; + +type SearchExample = { + query: string; + description: string; +}; + +export type SearchHelpOverlayProps = { + open: boolean; + onClose: () => void; + onApplyExample: (example: string) => void; +}; + +export function SearchHelpOverlay({ + open, + onClose, + onApplyExample, +}: SearchHelpOverlayProps): React.JSX.Element | null { + const { t } = useTranslation(); + + useEffect(() => { + if (!open) { + return; + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + const fileExamples = useMemo( + () => [ + { + query: 'test report', + description: t('search.help.exampleDescriptions.plainWord'), + }, + { + query: '*.sqlite', + description: t('search.help.exampleDescriptions.extensionWildcard'), + }, + { + query: 'content:"TODO"', + description: t('search.help.exampleDescriptions.content'), + }, + { + query: 'audio:', + description: t('search.help.exampleDescriptions.typeMacro'), + }, + { + query: 'dm:today', + description: t('search.help.exampleDescriptions.todayModified'), + }, + { + query: 'ext:pdf', + description: t('search.help.exampleDescriptions.extensionFilter'), + }, + { + query: 'size:1mb..10mb', + description: t('search.help.exampleDescriptions.sizeRange'), + }, + { + query: 'src/**/Cargo.toml', + description: t('search.help.exampleDescriptions.globstarPath'), + }, + { + query: 'regex:^README\\.md$', + description: t('search.help.exampleDescriptions.regex'), + }, + { + query: '"Application Support"', + description: t('search.help.exampleDescriptions.quotedPhrase'), + }, + { + query: 'type:picture', + description: t('search.help.exampleDescriptions.typeFilter'), + }, + { + query: 'in:/Users/demo/Projects', + description: t('search.help.exampleDescriptions.inScope'), + }, + { + query: 'parent:/Users/demo/Documents', + description: t('search.help.exampleDescriptions.parentScope'), + }, + { + query: 'nosubfolders:/Users/demo/Projects', + description: t('search.help.exampleDescriptions.noSubfoldersScope'), + }, + ], + [t], + ); + const guideGroups = useMemo( + () => [ + { + title: t('search.help.groups.matching.title'), + items: [ + t('search.help.files.fullPath'), + t('search.help.files.words'), + t('search.help.files.phrases'), + t('search.help.files.pathSegments'), + ], + }, + { + title: t('search.help.groups.logic.title'), + items: [ + t('search.help.files.implicitAnd'), + t('search.help.files.orOperator'), + t('search.help.files.notOperator'), + t('search.help.files.grouping'), + t('search.help.files.precedence'), + ], + }, + { + title: t('search.help.groups.filters.title'), + items: [ + t('search.help.filters.fileFolder'), + t('search.help.filters.extensions'), + t('search.help.filters.types'), + t('search.help.filters.typeMacros'), + t('search.help.filters.scope'), + t('search.help.filters.dates'), + t('search.help.filters.size'), + t('search.help.filters.regexContent'), + ], + }, + { + title: t('search.help.groups.patterns.title'), + items: [ + t('search.help.files.expandOnTilde'), + t('search.help.files.wildcards'), + t('search.help.files.globstar'), + t('search.help.files.literalWildcards'), + t('search.help.files.segmentAnchors'), + t('search.help.files.caseSensitivity'), + ], + }, + ], + [t], + ); + + if (!open) { + return null; + } + + const handleOverlayClick = (event: React.MouseEvent): void => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + const handleOpenDocs = async (): Promise => { + try { + await openUrl(SEARCH_SYNTAX_DOCS_URL); + } catch (error) { + console.error('Failed to open search syntax docs', error); + } + }; + + const handleApplyExample = (example: string): void => { + onApplyExample(example); + }; + + const handleDocsClick = (event: React.MouseEvent): void => { + event.preventDefault(); + void handleOpenDocs(); + }; + + return ( +
+
+
+
+
+

{t('search.help.title')}

+
+ + {t('search.help.openDocs')} + + +
+
+ +
+
+
+

{t('search.help.examplesTitle')}

+

{t('search.help.subtitle')}

+

{t('search.help.examplesDescription')}

+
+
+ {fileExamples.map((example) => ( + + ))} +
+
+ +
+
+

{t('search.help.guideTitle')}

+

{t('search.help.files.description')}

+
+
+ {guideGroups.map((group) => ( +
+

{group.title}

+
    + {group.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/cardinal/src/components/StatusBar.tsx b/cardinal/src/components/StatusBar.tsx index fc39dd6b..25821cb0 100644 --- a/cardinal/src/components/StatusBar.tsx +++ b/cardinal/src/components/StatusBar.tsx @@ -3,6 +3,7 @@ import type { CSSProperties } from 'react'; import type { AppLifecycleStatus } from '../types/ipc'; import { useTranslation } from 'react-i18next'; import { openPreferences } from '../utils/openPreferences'; +import { getTooltipAttributes } from '../utils/tooltip'; export type StatusTabKey = 'files' | 'events'; @@ -112,7 +113,7 @@ const StatusBar = ({ {lifecycleMeta.icon} @@ -149,47 +150,52 @@ const StatusBar = ({ })}
- - + + - - {t('statusBar.aria.settings')} - + + {t('statusBar.aria.settings')} + +
{t('statusBar.searchLabel')} - + {searchDisplay}
diff --git a/cardinal/src/components/__tests__/SearchBar.test.tsx b/cardinal/src/components/__tests__/SearchBar.test.tsx new file mode 100644 index 00000000..c500fbd0 --- /dev/null +++ b/cardinal/src/components/__tests__/SearchBar.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SearchBar } from '../SearchBar'; + +const baseProps = { + inputRef: { current: null }, + placeholder: 'Search', + value: '', + onChange: vi.fn(), + onKeyDown: vi.fn(), + caseSensitive: false, + onToggleCaseSensitive: vi.fn(), + caseSensitiveLabel: 'Toggle case-sensitive matching', + filtersToggleLabel: 'Show filters', + onToggleFilters: vi.fn(), + onFocus: vi.fn(), + onBlur: vi.fn(), +}; + +describe('SearchBar', () => { + it('shows a filters toggle button when provided', () => { + const onToggleFilters = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Show filters' })); + + expect(onToggleFilters).toHaveBeenCalledTimes(1); + }); + + it('keeps the case sensitivity toggle available', () => { + render(); + + expect(screen.getByRole('checkbox', { name: 'Toggle case-sensitive matching' })).toBeVisible(); + }); +}); diff --git a/cardinal/src/components/__tests__/SearchFiltersBar.test.tsx b/cardinal/src/components/__tests__/SearchFiltersBar.test.tsx new file mode 100644 index 00000000..4ffa413a --- /dev/null +++ b/cardinal/src/components/__tests__/SearchFiltersBar.test.tsx @@ -0,0 +1,132 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { SearchFiltersBar } from '../SearchFiltersBar'; + +const pickFolderMock = vi.fn(); + +vi.mock('../../utils/pickFolder', () => ({ + pickFolder: () => pickFolderMock(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('SearchFiltersBar', () => { + it('sends extension selections as merge actions', async () => { + const user = userEvent.setup(); + const onApplyAction = vi.fn(); + const onOpenHelp = vi.fn(); + + render( + , + ); + + await user.selectOptions( + screen.getByRole('combobox', { name: 'search.filterBar.extension.label' }), + 'pdf', + ); + + expect(onApplyAction).toHaveBeenCalledWith({ + kind: 'merge-extension', + extension: 'pdf', + }); + }); + + it('replaces single-choice filter families from dropdowns', async () => { + const user = userEvent.setup(); + const onApplyAction = vi.fn(); + const onOpenHelp = vi.fn(); + + render( + , + ); + + await user.selectOptions( + screen.getByRole('combobox', { name: 'search.filterBar.type.label' }), + 'type:code', + ); + + expect(onApplyAction).toHaveBeenCalledWith({ + kind: 'replace-family', + family: 'type', + token: 'type:code', + }); + }); + + it('quotes picked folder paths when applying scope filters', async () => { + const user = userEvent.setup(); + const onApplyAction = vi.fn(); + const onOpenHelp = vi.fn(); + pickFolderMock.mockResolvedValue('/Users/demo/Application Support'); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'search.filterBar.scope.in' })); + + await waitFor(() => { + expect(onApplyAction).toHaveBeenCalledWith({ + kind: 'replace-family', + family: 'scope', + token: 'in:"/Users/demo/Application Support"', + }); + }); + }); + + it('opens search help from the trailing info button', async () => { + const user = userEvent.setup(); + const onApplyAction = vi.fn(); + const onOpenHelp = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Show search help' })); + + expect(onOpenHelp).toHaveBeenCalledTimes(1); + }); + + it('adds tooltip descriptions to filter controls', () => { + const onApplyAction = vi.fn(); + const onOpenHelp = vi.fn(); + + render( + , + ); + + expect(screen.getByRole('button', { name: 'search.filterBar.scope.in' })).toHaveAttribute( + 'data-tooltip-content', + 'search.filterBar.tooltips.scope.in', + ); + expect(screen.getByRole('combobox', { name: 'search.filterBar.type.label' })).toHaveAttribute( + 'data-tooltip-content', + 'search.filterBar.tooltips.type', + ); + }); +}); diff --git a/cardinal/src/components/__tests__/SearchHelpOverlay.test.tsx b/cardinal/src/components/__tests__/SearchHelpOverlay.test.tsx new file mode 100644 index 00000000..f4151de3 --- /dev/null +++ b/cardinal/src/components/__tests__/SearchHelpOverlay.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SearchHelpOverlay } from '../SearchHelpOverlay'; + +const openUrlMock = vi.fn(); +const onApplyExampleMock = vi.fn(); +const onCloseMock = vi.fn(); + +vi.mock('@tauri-apps/plugin-opener', () => ({ + openUrl: (...args: unknown[]) => openUrlMock(...args), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('SearchHelpOverlay', () => { + it('opens the GitHub docs link', async () => { + render(); + + fireEvent.click(screen.getByRole('link', { name: 'search.help.openDocs' })); + + expect(openUrlMock).toHaveBeenCalledWith( + 'https://github.com/cardisoft/cardinal/blob/master/doc/pub/search-syntax.md', + ); + }); + + it('applies an example to the files search when clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'test report' })); + + expect(onApplyExampleMock).toHaveBeenCalledWith('test report'); + expect(onCloseMock).not.toHaveBeenCalled(); + }); + + it('shows a descriptive tooltip for each example', () => { + render(); + + expect(screen.getByRole('button', { name: 'test report' })).toHaveAttribute( + 'data-tooltip-content', + 'search.help.exampleDescriptions.plainWord', + ); + }); +}); diff --git a/cardinal/src/i18n/resources/en-US.json b/cardinal/src/i18n/resources/en-US.json index 4259994c..dc6ad2b4 100644 --- a/cardinal/src/i18n/resources/en-US.json +++ b/cardinal/src/i18n/resources/en-US.json @@ -6,6 +6,144 @@ }, "options": { "caseSensitive": "Toggle case-sensitive matching" + }, + "help": { + "open": "Show search help", + "close": "Close search help", + "openDocs": "Open Full Docs on GitHub", + "title": "Search & Filter Guide", + "headerLine": "Quick syntax reference for the files search box.", + "subtitle": "Cardinal can match file paths, folder paths, filenames, folder names, and file contents using plain terms, quoted phrases, wildcards, operators, and filters.", + "examplesTitle": "Examples", + "examplesDescription": "Each example shows one syntax feature. Click any example to add it to the files search box", + "exampleDescriptions": { + "plainWord": "A space means AND, so this matches file paths or folder paths containing both test and report", + "quotedPhrase": "Matches file paths or folder paths containing the exact phrase Application Support, including the space", + "extensionWildcard": "Matches files whose filename ends with .sqlite", + "extensionFilter": "Matches files whose filename extension is pdf", + "typeFilter": "Matches files in the picture category", + "typeMacro": "Matches files in the audio category using the audio: shortcut", + "globstarPath": "Matches files named Cargo.toml anywhere under a src folder tree", + "todayModified": "Matches files or folders modified today", + "sizeRange": "Matches files whose size is between 1 MB and 10 MB", + "regex": "Uses a regular expression to match a file or folder name segment named README.md exactly", + "content": "Matches files whose contents contain the word TODO", + "parentScope": "Matches only direct children of /Users/demo/Documents", + "inScope": "Matches any descendant under /Users/demo/Projects", + "noSubfoldersScope": "Matches the folder itself plus direct file children under /Users/demo/Projects, without descending into nested subfolders" + }, + "guideTitle": "How the syntax works", + "files": { + "title": "Files tab syntax", + "description": "Use the main search box to match file paths, folder paths, filenames, folder names, and file contents with flexible syntax.", + "fullPath": "Plain search terms are checked against the full indexed file path or folder path, not just the filename.", + "words": "A plain word like report matches any file path or folder path containing that text.", + "phrases": "A quoted phrase like \"Application Support\" matches file paths or folder paths containing that exact text, including spaces.", + "wildcards": "In file and folder path matching, '*' matches zero or more characters, '?' matches exactly one character, and '**' crosses folder levels when used between slashes.", + "globstar": "'**' crosses folder levels in file or folder paths when used as its own path segment, like src/**/Cargo.toml.", + "literalWildcards": "Quote a token like \"*.rs\" if you want the literal characters '*' or '?' matched in a file path or folder path.", + "pathSegments": "Use '/' inside a term to match file or folder path segments, prefixes, suffixes, or exact folder names.", + "segmentAnchors": "In file or folder path matching, /foo is a segment prefix, foo/ is a segment suffix, and /foo/ matches an exact folder name.", + "implicitAnd": "A space means both terms must appear somewhere in the matched file path or folder path.", + "orOperator": "Use OR or '|' when either term can appear in the matched file path or folder path.", + "notOperator": "Use NOT or a leading '!' to exclude files or folders whose path matches that term.", + "grouping": "Use parentheses '(...)' or angle brackets '<...>' to group file or folder path conditions and filters together.", + "precedence": "When you combine file or folder path conditions, NOT is applied first, OR next, and AND last.", + "caseSensitivity": "The Aa toggle changes case-sensitive matching for file and folder path text, content: text, and regex: patterns.", + "expandOnTilde": "The '~' character expands to your home folder." + }, + "filters": { + "title": "Common filters", + "description": "These are some of the most useful filters you can combine with normal search terms.", + "fileFolder": "file: keeps only file results, and folder: keeps only folder results.", + "extensions": "ext: matches files by filename extension, like ext:jpg;png.", + "types": "type: matches files whose extension belongs to a category like picture, code, or archive.", + "typeMacros": "audio:, video:, doc:, and exe: are shortcuts for common type: categories, and each can take an optional argument.", + "dates": "dm: and dc: filter modified and created timestamps using keywords, absolute dates, ranges, or comparisons.", + "size": "size: filters by file size using comparisons, ranges, or keywords like tiny and empty.", + "scope": "parent:, in:/infolder:, and nosubfolders: restrict results under an absolute folder path.", + "regexContent": "regex: matches a regular expression against a file or folder name segment, and content: matches text inside file contents." + }, + "groups": { + "matching": { + "title": "Matching text" + }, + "patterns": { + "title": "Patterns and wildcards" + }, + "logic": { + "title": "Boolean logic" + }, + "filters": { + "title": "Structured filters" + } + } + }, + "filterBar": { + "ariaLabel": "Search filters", + "toggle": { + "show": "Show filters", + "hide": "Hide filters" + }, + "scope": { + "in": "In Folder", + "parent": "Parent Folder", + "noSubfolders": "No Subfolders" + }, + "tooltips": { + "kind": "Choose whether results should be files only or folders only", + "type": "Insert a type: filter such as picture, audio, code, or archive", + "extension": "Merge selected file extensions into one ext: filter, like ext:pdf;md", + "date": "Insert a modified/created date filter such as dm:thisweek or dc:today", + "size": "Insert a size: filter such as size:huge or size:1mb..10mb", + "regexExample": "Append a sample regex filter: regex:^README\\.md$", + "scope": { + "in": "Pick a folder and search anywhere under that folder", + "parent": "Pick a folder and limit results to its direct child files and folders" + } + }, + "kind": { + "label": "Kind", + "files": "Files", + "folders": "Folders" + }, + "type": { + "label": "Type", + "picture": "Picture", + "video": "Video", + "audio": "Audio", + "document": "Document", + "presentation": "Presentation", + "spreadsheet": "Spreadsheet", + "pdf": "PDF", + "archive": "Archive", + "code": "Code", + "executable": "Executable" + }, + "extension": { + "label": "Extension" + }, + "date": { + "label": "Date", + "modifiedToday": "Modified Today", + "modifiedThisWeek": "Modified This Week", + "modifiedLastMonth": "Modified Last Month", + "createdToday": "Created Today", + "createdThisWeek": "Created This Week", + "createdLastMonth": "Created Last Month" + }, + "size": { + "label": "Size", + "empty": "Empty", + "tiny": "Tiny", + "small": "Small", + "medium": "Medium", + "large": "Large", + "huge": "Huge", + "rangeOneToTenMb": "1 MB to 10 MB", + "overHundredMb": "Over 100 MB" + }, + "regexExample": "Regex" } }, "stateDisplay": { diff --git a/cardinal/src/utils/__tests__/searchToolbarQuery.test.ts b/cardinal/src/utils/__tests__/searchToolbarQuery.test.ts new file mode 100644 index 00000000..68b81060 --- /dev/null +++ b/cardinal/src/utils/__tests__/searchToolbarQuery.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { applySearchToolbarQueryAction } from '../searchToolbarQuery'; + +describe('applySearchToolbarQueryAction', () => { + it('appends a token to the end of the query', () => { + expect( + applySearchToolbarQueryAction('report draft', { + kind: 'append-token', + token: 'regex:^README\\.md$', + }), + ).toBe('report draft regex:^README\\.md$'); + }); + + it('merges extensions into a single ext token while keeping the first ext position', () => { + expect( + applySearchToolbarQueryAction('report ext:pdf notes ext:md', { + kind: 'merge-extension', + extension: 'rs', + }), + ).toBe('report ext:pdf;md;rs notes'); + }); + + it('replaces type filters in place while removing previous top-level type tokens', () => { + expect( + applySearchToolbarQueryAction('report type:code audio:podcast size:small', { + kind: 'replace-family', + family: 'type', + token: 'type:picture', + }), + ).toBe('report type:picture size:small'); + }); + + it('replaces date filters across modified and created families in place', () => { + expect( + applySearchToolbarQueryAction('report dm:today size:huge dc:lastmonth', { + kind: 'replace-family', + family: 'date', + token: 'dc:thisweek', + }), + ).toBe('report dc:thisweek size:huge'); + }); + + it('keeps unrelated filters in place when replacing another family', () => { + expect( + applySearchToolbarQueryAction('report dm:thisweek size:huge', { + kind: 'replace-family', + family: 'size', + token: 'size:small', + }), + ).toBe('report dm:thisweek size:small'); + }); + + it('does not rewrite grouped expressions while still appending the new family token', () => { + expect( + applySearchToolbarQueryAction('report (type:code | type:doc)', { + kind: 'replace-family', + family: 'type', + token: 'type:picture', + }), + ).toBe('report (type:code | type:doc) type:picture'); + }); +}); diff --git a/cardinal/src/utils/pickFolder.ts b/cardinal/src/utils/pickFolder.ts new file mode 100644 index 00000000..e2f5ceb1 --- /dev/null +++ b/cardinal/src/utils/pickFolder.ts @@ -0,0 +1,11 @@ +import { invoke } from '@tauri-apps/api/core'; + +export const pickFolder = async (): Promise => { + try { + const selectedPath = await invoke('pick_folder'); + return selectedPath ?? undefined; + } catch (error) { + console.error('Failed to pick folder', error); + return undefined; + } +}; diff --git a/cardinal/src/utils/searchToolbarQuery.ts b/cardinal/src/utils/searchToolbarQuery.ts new file mode 100644 index 00000000..738662b0 --- /dev/null +++ b/cardinal/src/utils/searchToolbarQuery.ts @@ -0,0 +1,261 @@ +const EXTENSION_PREFIX = 'ext:'; + +type ToolbarReplaceFamily = 'kind' | 'type' | 'date' | 'size' | 'scope'; + +export type SearchToolbarQueryAction = + | { kind: 'append-token'; token: string } + | { kind: 'merge-extension'; extension: string } + | { kind: 'replace-family'; family: ToolbarReplaceFamily; token: string }; + +const FAMILY_PREFIXES: Record = { + kind: ['file:', 'folder:'], + type: ['type:', 'audio:', 'video:', 'doc:', 'exe:'], + date: ['dm:', 'datemodified:', 'dc:', 'datecreated:'], + size: ['size:'], + scope: ['parent:', 'infolder:', 'in:'], +}; + +export const applySearchToolbarQueryAction = ( + query: string, + action: SearchToolbarQueryAction, +): string => { + switch (action.kind) { + case 'append-token': + return joinTokens([...splitTopLevelTokens(query), action.token]); + case 'merge-extension': + return mergeExtensionToken(query, action.extension); + case 'replace-family': + return replaceFamilyToken(query, action.family, action.token); + } +}; + +const replaceFamilyToken = (query: string, family: ToolbarReplaceFamily, token: string): string => { + const prefixes = FAMILY_PREFIXES[family]; + const tokens = splitTopLevelTokens(query); + + return replaceTokenFamily( + tokens, + (part) => prefixes.some((prefix) => startsWithPrefix(part, prefix)), + token, + ); +}; + +const mergeExtensionToken = (query: string, extension: string): string => { + const normalizedExtension = extension.trim().toLowerCase(); + if (!normalizedExtension) { + return query.trim(); + } + + const tokens = splitTopLevelTokens(query); + const existingExtensions = tokens + .filter((token) => startsWithPrefix(token, EXTENSION_PREFIX)) + .flatMap((token) => parseExtensionToken(token.slice(EXTENSION_PREFIX.length))); + + const mergedExtensions = dedupeCaseInsensitive([...existingExtensions, normalizedExtension]); + const nextExtensionToken = `${EXTENSION_PREFIX}${mergedExtensions + .map(formatExtensionValue) + .join(';')}`; + + return replaceTokenFamily( + tokens, + (token) => startsWithPrefix(token, EXTENSION_PREFIX), + nextExtensionToken, + ); +}; + +const replaceTokenFamily = ( + tokens: string[], + shouldReplace: (token: string) => boolean, + nextToken: string, +): string => { + const nextTokens: string[] = []; + let insertedReplacement = false; + + for (const token of tokens) { + if (!shouldReplace(token)) { + nextTokens.push(token); + continue; + } + + if (!insertedReplacement) { + nextTokens.push(nextToken); + insertedReplacement = true; + } + } + + if (!insertedReplacement) { + nextTokens.push(nextToken); + } + + return joinTokens(nextTokens); +}; + +const dedupeCaseInsensitive = (values: string[]): string[] => { + const seen = new Set(); + const deduped: string[] = []; + + for (const value of values) { + const normalizedValue = value.trim().toLowerCase(); + if (!normalizedValue || seen.has(normalizedValue)) { + continue; + } + + seen.add(normalizedValue); + deduped.push(normalizedValue); + } + + return deduped; +}; + +const parseExtensionToken = (rawValue: string): string[] => { + const values: string[] = []; + let current = ''; + let inQuote = false; + let isEscaped = false; + + for (const character of rawValue) { + if (inQuote) { + if (isEscaped) { + current += character; + isEscaped = false; + continue; + } + + if (character === '\\') { + isEscaped = true; + continue; + } + + if (character === '"') { + inQuote = false; + continue; + } + + current += character; + continue; + } + + if (character === '"') { + inQuote = true; + continue; + } + + if (character === ';') { + values.push(current.trim()); + current = ''; + continue; + } + + current += character; + } + + values.push(current.trim()); + return values.filter(Boolean); +}; + +const formatExtensionValue = (value: string): string => { + if (/^[a-z0-9._+-]+$/i.test(value)) { + return value; + } + + const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escapedValue}"`; +}; + +const startsWithPrefix = (token: string, prefix: string): boolean => + token.toLowerCase().startsWith(prefix); + +const joinTokens = (tokens: string[]): string => + tokens + .map((token) => token.trim()) + .filter(Boolean) + .join(' '); + +const isWhitespace = (value: string): boolean => /\s/.test(value); + +const isAngleGroupStart = (query: string, index: number, tokenStart: number): boolean => { + if (index !== tokenStart) { + const previousCharacter = query[index - 1]; + if (!isWhitespace(previousCharacter) && !['(', '|', '<'].includes(previousCharacter)) { + return false; + } + } + + const nextCharacter = query[index + 1]; + return nextCharacter !== undefined && !isWhitespace(nextCharacter) && nextCharacter !== '='; +}; + +export const splitTopLevelTokens = (query: string): string[] => { + const tokens: string[] = []; + let tokenStart = -1; + let parenDepth = 0; + let angleDepth = 0; + let inQuote = false; + let isEscaped = false; + + for (let index = 0; index < query.length; index += 1) { + const character = query[index]; + + if (tokenStart === -1) { + if (isWhitespace(character)) { + continue; + } + + tokenStart = index; + } + + if (inQuote) { + if (isEscaped) { + isEscaped = false; + continue; + } + + if (character === '\\') { + isEscaped = true; + continue; + } + + if (character === '"') { + inQuote = false; + } + + continue; + } + + if (character === '"') { + inQuote = true; + continue; + } + + if (character === '(') { + parenDepth += 1; + continue; + } + + if (character === ')' && parenDepth > 0) { + parenDepth -= 1; + continue; + } + + if (character === '<' && isAngleGroupStart(query, index, tokenStart)) { + angleDepth += 1; + continue; + } + + if (character === '>' && angleDepth > 0) { + angleDepth -= 1; + continue; + } + + if (parenDepth === 0 && angleDepth === 0 && isWhitespace(character)) { + tokens.push(query.slice(tokenStart, index)); + tokenStart = -1; + } + } + + if (tokenStart !== -1) { + tokens.push(query.slice(tokenStart)); + } + + return tokens.filter(Boolean); +}; diff --git a/cardinal/src/utils/tooltip.ts b/cardinal/src/utils/tooltip.ts new file mode 100644 index 00000000..dcdcb36b --- /dev/null +++ b/cardinal/src/utils/tooltip.ts @@ -0,0 +1,12 @@ +export const APP_TOOLTIP_ID = 'cardinal-app-tooltip'; + +export const getTooltipAttributes = (content?: string | null): Record => { + if (!content) { + return {}; + } + + return { + 'data-tooltip-id': APP_TOOLTIP_ID, + 'data-tooltip-content': content, + }; +};