diff --git a/src/App.tsx b/src/App.tsx index a12320b..d8d07a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ import { TerminalPanel } from "./components/terminal/TerminalPanel"; import { EditorContainer } from "./components/editor/EditorContainer"; import { CommandPalette } from "./components/ui/CommandPalette"; +import { GlobalSearch } from "./components/ui/GlobalSearch"; import { updater } from "./lib/updater"; import { ContextMenu } from "./components/ui/ContextMenu"; import { ConfirmDialog } from "./components/ui/ConfirmDialog"; @@ -70,6 +71,7 @@ export default function App() { const [settingsSection, setSettingsSection] = useState<"ai" | undefined>(undefined); const [showAIOnboarding, setShowAIOnboarding] = useState(false); const [showPalette, setShowPalette] = useState(false); + const [showSearch, setShowSearch] = useState(false); const [aiChatOpen, setAiChatOpen] = useState(false); const [aiChatMounted, setAiChatMounted] = useState(false); // stays true after first open const [fileSelection, setFileSelection] = useState<{ files: FileEntry[]; folderPath?: string } | null>(null); @@ -93,6 +95,7 @@ export default function App() { const setAiEnabled = useAppStore((s) => s.setAiEnabled); const setTheme = useAppStore((s) => s.setTheme); const saveTabToFile = useAppStore((s) => s.saveTabToFile); + const navigateToNote = useAppStore((s) => s.navigateToNote); const autoUpdateCheck = useAppStore((s) => s.autoUpdateCheck); // Header ✨ button: not set up → onboarding; set up → quick enable/disable toggle. @@ -218,13 +221,20 @@ export default function App() { handleNewWhiteboard, handleOpenMultiple, handleOpenFolder, saveTabToFile, setTheme, toggleAiChat, onOpenSettings: () => setShowSettings(true), + onOpenSearch: () => setShowSearch(true), }); - // Open the palette from the Monaco editor (which swallows Ctrl+Shift+P internally). + // Open the palette / global search from the Monaco editor (which swallows + // Ctrl+Shift+P / Ctrl+Shift+F internally and re-dispatches them as events). useEffect(() => { - const open = () => setShowPalette(true); - window.addEventListener("app:command-palette", open); - return () => window.removeEventListener("app:command-palette", open); + const openPalette = () => setShowPalette(true); + const openSearch = () => setShowSearch(true); + window.addEventListener("app:command-palette", openPalette); + window.addEventListener("app:global-search", openSearch); + return () => { + window.removeEventListener("app:command-palette", openPalette); + window.removeEventListener("app:global-search", openSearch); + }; }, []); useGlobalShortcuts({ @@ -234,6 +244,7 @@ export default function App() { handleNewWhiteboard, onOpenSettings: () => setShowSettings(true), onOpenCommandPalette: () => setShowPalette(true), + onOpenSearch: () => setShowSearch(true), }); const { ctxMenu, closeCtxMenu } = useGlobalContextMenu({ @@ -456,6 +467,14 @@ export default function App() { setShowPalette(false)} /> )} + {showSearch && ( + navigateToNote(tabId, query, line)} + onClose={() => setShowSearch(false)} + /> + )} + {fileSelection && ( { window.dispatchEvent(new CustomEvent("app:command-palette")); }, }); + // ── Ctrl+Shift+F → search across all open notes ────────────────── + editor.addAction({ + id: "smart-note.global-search", + label: "Search in Notes", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyF], + run: () => { window.dispatchEvent(new CustomEvent("app:global-search")); }, + }); // ── Format document: Shift+Alt+F (all languages) ───────────────── editor.addAction({ @@ -451,7 +458,14 @@ export const MonacoMarkdownEditor = React.memo( false // captureMatches ); if (matches.length > 0) { - const range = matches[0].range; + // When a specific line is requested (global search), reveal the match + // on that line; otherwise (graph jump) fall back to the first match. + const wantLine = pendingJump.line; + const chosen = + wantLine != null + ? (matches.find((m) => m.range.startLineNumber === wantLine) ?? matches[0]) + : matches[0]; + const range = chosen.range; isSyncingRef.current = true; ed.revealLineInCenter(range.startLineNumber); ed.setSelection(range); diff --git a/src/components/ui/GlobalSearch.tsx b/src/components/ui/GlobalSearch.tsx new file mode 100644 index 0000000..b43c330 --- /dev/null +++ b/src/components/ui/GlobalSearch.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Search, FileText } from "lucide-react"; +import type { Tab } from "../../types"; +import { searchNotes, type NoteSearchResult, type SearchMatch } from "../../lib/searchNotes"; + +interface Props { + tabs: Tab[]; + /** Jump to a note and reveal the matched line. */ + onJump: (tabId: string, query: string, line: number) => void; + onClose: () => void; +} + +/** Last two path segments, for a compact "folder/file.md" hint. */ +function shortPath(p: string): string { + return p.replace(/\\/g, "/").split("/").slice(-2).join("/"); +} + +const SNIPPET_MAX = 240; + +/** + * Render a matching line as a snippet with every occurrence of `query` + * highlighted. Leading whitespace is dropped and very long lines are windowed + * around the first match so the match is always visible. + */ +function renderSnippet(lineText: string, query: string): React.ReactNode { + const needle = query.trim().toLowerCase(); + let text = lineText.replace(/^\s+/, ""); + if (!needle) return text; + + if (text.length > SNIPPET_MAX) { + const first = text.toLowerCase().indexOf(needle); + const start = first > 80 ? first - 60 : 0; + text = (start > 0 ? "…" : "") + text.slice(start, start + SNIPPET_MAX); + } + + const lower = text.toLowerCase(); + const parts: React.ReactNode[] = []; + let from = 0; + let key = 0; + let i = lower.indexOf(needle, from); + while (i !== -1) { + if (i > from) parts.push(text.slice(from, i)); + parts.push( + + {text.slice(i, i + needle.length)} + , + ); + from = i + needle.length; + i = lower.indexOf(needle, from); + } + parts.push(text.slice(from)); + return parts; +} + +export function GlobalSearch({ tabs, onJump, onClose }: Props) { + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const results = useMemo(() => searchNotes(tabs, query), [tabs, query]); + + // Flatten matches into a single list so arrow keys move across notes too. + const flat = useMemo(() => { + const arr: { result: NoteSearchResult; match: SearchMatch }[] = []; + for (const r of results) for (const m of r.matches) arr.push({ result: r, match: m }); + return arr; + }, [results]); + + useEffect(() => { setActive(0); }, [query]); + useEffect(() => { inputRef.current?.focus(); }, []); + + // Keep the active row in view. + useLayoutEffect(() => { + const el = listRef.current?.querySelector(`[data-idx="${active}"]`); + el?.scrollIntoView({ block: "nearest" }); + }, [active]); + + const jump = (item: { result: NoteSearchResult; match: SearchMatch } | undefined) => { + if (!item) return; + onClose(); + // Defer so the overlay unmounts before the editor steals focus. + setTimeout(() => onJump(item.result.tabId, query, item.match.line), 0); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => Math.min(flat.length - 1, i + 1)); } + else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(0, i - 1)); } + else if (e.key === "Enter") { e.preventDefault(); jump(flat[active]); } + else if (e.key === "Escape") { e.preventDefault(); onClose(); } + }; + + const trimmed = query.trim(); + let idx = -1; // running index into the flat list, assigned as rows render + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+ + setQuery(e.target.value)} + onKeyDown={onKeyDown} + /> +
+ +
+ {trimmed === "" ? ( +
Type to search the content of every open note
+ ) : results.length === 0 ? ( +
No matches for “{trimmed}”
+ ) : ( + <> +
+ {flat.length} match{flat.length === 1 ? "" : "es"} in {results.length} note + {results.length === 1 ? "" : "s"} +
+ + {results.map((r) => ( +
+
+ + {r.title} + {r.filePath && {shortPath(r.filePath)}} + + {r.matches.length}{r.truncated ? "+" : ""} + +
+ + {r.matches.map((m) => { + idx += 1; + const myIdx = idx; + return ( + + ); + })} +
+ ))} + + )} +
+
+
, + document.body, + ); +} diff --git a/src/hooks/useCommands.tsx b/src/hooks/useCommands.tsx index d6723e9..29691c3 100644 --- a/src/hooks/useCommands.tsx +++ b/src/hooks/useCommands.tsx @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { FileText as FileIcon, FilePlus, FolderOpen, Save, Settings as SettingsIcon, Network as NetworkIcon, Palette, MessageSquare as ChatIcon, SquarePen, PenLine, SpellCheck, Heading, + Search as SearchIcon, } from "lucide-react"; import type { Command } from "../components/ui/CommandPalette"; import type { Tab, AppView, ThemeId } from "../types"; @@ -23,6 +24,7 @@ interface UseCommandsDeps { setTheme: (id: ThemeId) => void; toggleAiChat: () => void; onOpenSettings: () => void; + onOpenSearch: () => void; } /** @@ -34,7 +36,7 @@ export function useCommands(deps: UseCommandsDeps): () => Command[] { tabs, activeTabId, aiEnabled, addTab, setActiveTab, setActiveView, handleNewWhiteboard, handleOpenMultiple, handleOpenFolder, - saveTabToFile, setTheme, toggleAiChat, onOpenSettings, + saveTabToFile, setTheme, toggleAiChat, onOpenSettings, onOpenSearch, } = deps; return useCallback((): Command[] => { @@ -57,6 +59,7 @@ export function useCommands(deps: UseCommandsDeps): () => Command[] { cmds.push( { id: "act:new-note", label: "New Note", group: "Actions", icon: , trailing: "Ctrl+N", run: () => addTab() }, { id: "act:new-wb", label: "New Whiteboard", group: "Actions", icon: , trailing: "Ctrl+3", run: handleNewWhiteboard }, + { id: "act:search", label: "Search in Notes", group: "Actions", icon: , trailing: "Ctrl+Shift+F", run: onOpenSearch }, { id: "act:open", label: "Open File(s)", group: "Actions", icon: , trailing: "Ctrl+O", run: handleOpenMultiple }, { id: "act:open-folder", label: "Open Folder", group: "Actions", icon: , run: handleOpenFolder }, { id: "act:save", label: "Save", group: "Actions", icon: , trailing: "Ctrl+S", run: () => { if (activeTabId) saveTabToFile(activeTabId); } }, @@ -88,5 +91,5 @@ export function useCommands(deps: UseCommandsDeps): () => Command[] { } return cmds; - }, [tabs, activeTabId, aiEnabled, addTab, setActiveTab, setActiveView, handleNewWhiteboard, handleOpenMultiple, handleOpenFolder, saveTabToFile, setTheme, toggleAiChat, onOpenSettings]); + }, [tabs, activeTabId, aiEnabled, addTab, setActiveTab, setActiveView, handleNewWhiteboard, handleOpenMultiple, handleOpenFolder, saveTabToFile, setTheme, toggleAiChat, onOpenSettings, onOpenSearch]); } diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index da41baf..4f1dff7 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -9,6 +9,7 @@ interface Options { handleNewWhiteboard: () => void; onOpenSettings: () => void; onOpenCommandPalette: () => void; + onOpenSearch: () => void; } export function useGlobalShortcuts(opts: Options) { @@ -36,12 +37,14 @@ export function useGlobalShortcuts(opts: Options) { tabs, activeTabId, addTab, setActiveTab, saveTabToFile, saveTabToFileAs, requestClose, handleOpenMultiple, handleOpenFolder, handleNewWhiteboard, onOpenSettings, - onOpenCommandPalette, + onOpenCommandPalette, onOpenSearch, } = ref.current; const activeTab = tabs.find((t) => t.id === activeTabId); // Ctrl+Shift+P → command palette (also prevents the native print dialog) if (e.ctrlKey && e.shiftKey && (e.key === "P" || e.key === "p")) { e.preventDefault(); onOpenCommandPalette(); return; } + // Ctrl+Shift+F → search across all open notes + if (e.ctrlKey && e.shiftKey && (e.key === "F" || e.key === "f")) { e.preventDefault(); onOpenSearch(); return; } if (e.ctrlKey && !e.shiftKey && e.key === "n") { e.preventDefault(); addTab(); return; } if (e.ctrlKey && !e.shiftKey && e.key === "w") { e.preventDefault(); if (activeTabId) requestClose(activeTabId); return; } if (e.ctrlKey && !e.shiftKey && e.key === "s") { diff --git a/src/lib/searchNotes.test.ts b/src/lib/searchNotes.test.ts new file mode 100644 index 0000000..55e4b00 --- /dev/null +++ b/src/lib/searchNotes.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { searchNotes } from "./searchNotes"; +import type { Tab } from "../types"; + +/** Minimal Tab factory — only the fields searchNotes reads need to be real. */ +function makeTab(overrides: Partial = {}): Tab { + return { + id: "t1", + kind: "note", + title: "Untitled", + content: "", + isDirty: false, + cursorPosition: { line: 1, column: 1 }, + scrollPosition: 0, + editorMode: "split", + isPinned: false, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +describe("searchNotes", () => { + it("returns nothing for an empty / whitespace query", () => { + const tabs = [makeTab({ content: "hello world" })]; + expect(searchNotes(tabs, "")).toEqual([]); + expect(searchNotes(tabs, " ")).toEqual([]); + }); + + it("matches case-insensitively and reports 1-based line numbers", () => { + const tab = makeTab({ + id: "a", + title: "Notes", + content: "first line\nThe Quick Fox\nlast line", + }); + const res = searchNotes([tab], "quick"); + expect(res).toHaveLength(1); + expect(res[0].tabId).toBe("a"); + expect(res[0].matches).toEqual([{ line: 2, lineText: "The Quick Fox" }]); + }); + + it("returns one match row per matching line", () => { + const tab = makeTab({ content: "bug here\nok\nanother bug\nbug again" }); + const res = searchNotes([tab], "bug"); + expect(res[0].matches.map((m) => m.line)).toEqual([1, 3, 4]); + }); + + it("excludes notes with no match", () => { + const tabs = [ + makeTab({ id: "a", content: "alpha" }), + makeTab({ id: "b", content: "beta" }), + ]; + const res = searchNotes(tabs, "alpha"); + expect(res.map((r) => r.tabId)).toEqual(["a"]); + }); + + it("skips whiteboard tabs and empty notes", () => { + const tabs = [ + makeTab({ id: "wb", kind: "whiteboard", content: "match" }), + makeTab({ id: "empty", content: "" }), + makeTab({ id: "note", content: "match" }), + ]; + const res = searchNotes(tabs, "match"); + expect(res.map((r) => r.tabId)).toEqual(["note"]); + }); + + it("caps matches per note and flags truncation", () => { + const content = Array.from({ length: 10 }, () => "needle").join("\n"); + const tab = makeTab({ content }); + const res = searchNotes([tab], "needle", { maxPerNote: 3 }); + expect(res[0].matches).toHaveLength(3); + expect(res[0].truncated).toBe(true); + }); + + it("does not flag truncation when under the cap", () => { + const tab = makeTab({ content: "needle\nneedle" }); + const res = searchNotes([tab], "needle", { maxPerNote: 5 }); + expect(res[0].truncated).toBe(false); + }); + + it("falls back to 'Untitled' for a blank title", () => { + const tab = makeTab({ title: "", content: "hi" }); + expect(searchNotes([tab], "hi")[0].title).toBe("Untitled"); + }); +}); diff --git a/src/lib/searchNotes.ts b/src/lib/searchNotes.ts new file mode 100644 index 0000000..67fd098 --- /dev/null +++ b/src/lib/searchNotes.ts @@ -0,0 +1,76 @@ +import type { Tab } from "../types"; + +/** A single matching line inside one note. */ +export interface SearchMatch { + /** 1-based line number (matches Monaco's line numbering). */ + line: number; + /** The full raw text of the matching line (UI trims / windows it for display). */ + lineText: string; +} + +/** All matches found inside one note tab. */ +export interface NoteSearchResult { + tabId: string; + title: string; + filePath?: string; + /** Matching lines, capped at `maxPerNote`. */ + matches: SearchMatch[]; + /** True when the note had more matching lines than were kept. */ + truncated: boolean; +} + +export interface SearchOptions { + /** Max matching lines kept per note (default 50). Extra matches set `truncated`. */ + maxPerNote?: number; +} + +/** + * Case-insensitive, literal (non-regex) full-text search across the *content* of + * every open note tab. One result row per matching line. Whiteboard tabs and + * empty notes are skipped. Pure and synchronous so it can be unit-tested and run + * on every keystroke without touching the store, disk, or Tauri. + */ +export function searchNotes( + tabs: Tab[], + rawQuery: string, + opts: SearchOptions = {}, +): NoteSearchResult[] { + const query = rawQuery.trim(); + if (!query) return []; + + const maxPerNote = opts.maxPerNote ?? 50; + const needle = query.toLowerCase(); + const results: NoteSearchResult[] = []; + + for (const tab of tabs) { + if (tab.kind !== "note") continue; // whiteboards have no searchable text + const content = tab.content; + if (!content) continue; + + const lines = content.split("\n"); + const matches: SearchMatch[] = []; + let truncated = false; + + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]; + if (!lineText.toLowerCase().includes(needle)) continue; + if (matches.length >= maxPerNote) { + truncated = true; + break; + } + matches.push({ line: i + 1, lineText }); + } + + if (matches.length > 0) { + results.push({ + tabId: tab.id, + title: tab.title || "Untitled", + filePath: tab.filePath, + matches, + truncated, + }); + } + } + + return results; +} diff --git a/src/store/index.ts b/src/store/index.ts index 59de793..a19fdd2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -192,11 +192,13 @@ export interface AppState { // view activeView: AppView; setActiveView: (view: AppView) => void; - /** Text to find and reveal in the editor when jumping here from the graph. */ - pendingJump: { tabId: string; searchText: string } | null; + /** Text to find and reveal in the editor when jumping here (from the graph or + * global search). `line` (1-based) disambiguates when the text occurs more + * than once — the editor reveals the match on that line instead of the first. */ + pendingJump: { tabId: string; searchText: string; line?: number } | null; clearPendingJump: () => void; /** Switch to editor, activate tab, force split mode, all in one commit. */ - navigateToNote: (tabId: string, searchText?: string) => void; + navigateToNote: (tabId: string, searchText?: string, line?: number) => void; // session sessionReady: boolean; @@ -498,11 +500,11 @@ export const useAppStore = create((set, get) => { pendingJump: null, clearPendingJump() { set({ pendingJump: null }); }, - navigateToNote(tabId, searchText) { + navigateToNote(tabId, searchText, line) { set((s) => ({ activeView: "editor", activeTabId: tabId, - pendingJump: searchText ? { tabId, searchText } : null, + pendingJump: searchText ? { tabId, searchText, line } : null, tabs: s.tabs.map((t) => t.id === tabId && t.editorMode !== "split" ? { ...t, editorMode: "split" as EditorMode } diff --git a/src/styles/ui.css b/src/styles/ui.css index 23a0eb1..5336045 100644 --- a/src/styles/ui.css +++ b/src/styles/ui.css @@ -177,6 +177,94 @@ flex-shrink: 0; } +/* ── Global search (Ctrl+Shift+F) — reuses the cmdk overlay/container ── */ +.gsearch { width: 640px; } +.gsearch__inputrow { + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + border-bottom: 1px solid var(--color-border); +} +.gsearch__icon { color: var(--color-text-muted); flex-shrink: 0; } +.gsearch__input { flex: 1; border: none; border-bottom: none; padding: 14px 0; } +.gsearch__summary { + padding: 8px 12px 4px; + font-size: 11px; + color: var(--color-text-muted); +} +.gsearch__group { margin-bottom: 2px; } +.gsearch__file { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px 4px; + position: sticky; + top: 0; + background: var(--color-bg-secondary); + z-index: 1; +} +.gsearch__fileicon { color: var(--color-text-muted); flex-shrink: 0; } +.gsearch__filename { + font-size: 12.5px; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + flex-shrink: 0; +} +.gsearch__filepath { + font-size: 11px; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.gsearch__count { + font-size: 10.5px; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 0 5px; + flex-shrink: 0; + margin-left: auto; +} +.gsearch__match { + display: flex; + align-items: baseline; + gap: 10px; + width: 100%; + padding: 5px 10px 5px 26px; + border: none; + background: transparent; + color: var(--color-text); + text-align: left; + border-radius: 6px; + cursor: pointer; + font-size: 12.5px; +} +.gsearch__match.is-active { background: color-mix(in srgb, var(--color-primary) 16%, transparent); } +.gsearch__line { + font-size: 11px; + color: var(--color-text-muted); + min-width: 30px; + text-align: right; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} +.gsearch__snippet { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +.gsearch__hl { + background: color-mix(in srgb, var(--color-primary) 35%, transparent); + color: var(--color-text); + border-radius: 2px; +} + /* ══════════════════════════════════════════════════════════════════════════════ TOAST NOTIFICATIONS ══════════════════════════════════════════════════════════════════════════════ */