Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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({
Expand All @@ -234,6 +244,7 @@ export default function App() {
handleNewWhiteboard,
onOpenSettings: () => setShowSettings(true),
onOpenCommandPalette: () => setShowPalette(true),
onOpenSearch: () => setShowSearch(true),
});

const { ctxMenu, closeCtxMenu } = useGlobalContextMenu({
Expand Down Expand Up @@ -456,6 +467,14 @@ export default function App() {
<CommandPalette commands={buildCommands()} onClose={() => setShowPalette(false)} />
)}

{showSearch && (
<GlobalSearch
tabs={tabs}
onJump={(tabId, query, line) => navigateToNote(tabId, query, line)}
onClose={() => setShowSearch(false)}
/>
)}

{fileSelection && (
<FileSelectionModal
files={fileSelection.files}
Expand Down
16 changes: 15 additions & 1 deletion src/components/editor/MonacoMarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ export const MonacoMarkdownEditor = React.memo(
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyP],
run: () => { 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({
Expand Down Expand Up @@ -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);
Expand Down
166 changes: 166 additions & 0 deletions src/components/ui/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -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(
<mark key={key++} className="gsearch__hl">
{text.slice(i, i + needle.length)}
</mark>,
);
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<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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<HTMLElement>(`[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(
<div
className="cmdk-overlay"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="cmdk gsearch" role="dialog" aria-label="Search notes">
<div className="gsearch__inputrow">
<Search size={15} className="gsearch__icon" />
<input
ref={inputRef}
className="cmdk__input gsearch__input"
placeholder="Search across all open notes…"
value={query}
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
</div>

<div className="cmdk__list" ref={listRef}>
{trimmed === "" ? (
<div className="cmdk__empty">Type to search the content of every open note</div>
) : results.length === 0 ? (
<div className="cmdk__empty">No matches for “{trimmed}”</div>
) : (
<>
<div className="gsearch__summary">
{flat.length} match{flat.length === 1 ? "" : "es"} in {results.length} note
{results.length === 1 ? "" : "s"}
</div>

{results.map((r) => (
<div key={r.tabId} className="gsearch__group">
<div className="gsearch__file">
<FileText size={13} className="gsearch__fileicon" />
<span className="gsearch__filename">{r.title}</span>
{r.filePath && <span className="gsearch__filepath">{shortPath(r.filePath)}</span>}
<span className="gsearch__count">
{r.matches.length}{r.truncated ? "+" : ""}
</span>
</div>

{r.matches.map((m) => {
idx += 1;
const myIdx = idx;
return (
<button
key={`${r.tabId}:${m.line}`}
type="button"
data-idx={myIdx}
className={`gsearch__match${myIdx === active ? " is-active" : ""}`}
onMouseEnter={() => setActive(myIdx)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => jump({ result: r, match: m })}
>
<span className="gsearch__line">{m.line}</span>
<span className="gsearch__snippet">{renderSnippet(m.lineText, query)}</span>
</button>
);
})}
</div>
))}
</>
)}
</div>
</div>
</div>,
document.body,
);
}
7 changes: 5 additions & 2 deletions src/hooks/useCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +24,7 @@ interface UseCommandsDeps {
setTheme: (id: ThemeId) => void;
toggleAiChat: () => void;
onOpenSettings: () => void;
onOpenSearch: () => void;
}

/**
Expand All @@ -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[] => {
Expand All @@ -57,6 +59,7 @@ export function useCommands(deps: UseCommandsDeps): () => Command[] {
cmds.push(
{ id: "act:new-note", label: "New Note", group: "Actions", icon: <FilePlus size={14} />, trailing: "Ctrl+N", run: () => addTab() },
{ id: "act:new-wb", label: "New Whiteboard", group: "Actions", icon: <SquarePen size={14} />, trailing: "Ctrl+3", run: handleNewWhiteboard },
{ id: "act:search", label: "Search in Notes", group: "Actions", icon: <SearchIcon size={14} />, trailing: "Ctrl+Shift+F", run: onOpenSearch },
{ id: "act:open", label: "Open File(s)", group: "Actions", icon: <FolderOpen size={14} />, trailing: "Ctrl+O", run: handleOpenMultiple },
{ id: "act:open-folder", label: "Open Folder", group: "Actions", icon: <FolderOpen size={14} />, run: handleOpenFolder },
{ id: "act:save", label: "Save", group: "Actions", icon: <Save size={14} />, trailing: "Ctrl+S", run: () => { if (activeTabId) saveTabToFile(activeTabId); } },
Expand Down Expand Up @@ -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]);
}
5 changes: 4 additions & 1 deletion src/hooks/useGlobalShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface Options {
handleNewWhiteboard: () => void;
onOpenSettings: () => void;
onOpenCommandPalette: () => void;
onOpenSearch: () => void;
}

export function useGlobalShortcuts(opts: Options) {
Expand Down Expand Up @@ -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") {
Expand Down
Loading