diff --git a/README.md b/README.md index 099040b..5939485 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ cloud, or a bare editor with no live preview at all. MarkPad is the small, local-first alternative — a native desktop app that opens quickly, keeps every file on your machine, and shows your Markdown rendered side -by side as you type. A recent-files sidebar, folder-wide content search, a +by side as you type. A recent-files sidebar, open-file and folder-wide search, a one-click formatting toolbar, light/dark theming, optional auto-save, and real OS file-association handling make it usable day to day, without the bloat. @@ -60,11 +60,11 @@ side-by-side work: - **Live split-pane preview.** Edit Markdown in a line-numbered editor on the left, see it rendered on the right. - **Synced scrolling.** In split view the panes follow each other — scroll either one and the other tracks the same part of the document, staying aligned even across tall images and long code blocks. - **Find in the active document.** Use the toolbar magnifier or `Ctrl/⌘+F` to open MarkPad's own search bar instead of the webview's full-interface find. Every match is highlighted with a live position/count; `Enter` and `Shift+Enter` move forward and backward with wraparound, and `Escape` closes search. Searching from preview-only mode reveals the editor so the active match stays visible. -- **Search text across a folder.** Open the dedicated search sidebar from the toolbar or with `Ctrl/⌘+Shift+F`, choose a workspace folder, and search every Markdown, JSON, and YAML file beneath it. Results are grouped by file with relative paths, matching line numbers, and text previews; selecting one opens the file and places the cursor on that line. +- **Search across open files or a folder.** Open the dedicated search sidebar from the toolbar or with `Ctrl/⌘+Shift+F`. It searches every file currently open in MarkPad by default — including unsaved drafts and edits — or you can switch to a selected folder and scan every Markdown, JSON, and YAML file beneath it. Results are grouped by file with paths, matching line numbers, and text previews; selecting one activates the file and places the cursor on that line. - MarkPad file search sidebar: the workspace picker and search field are ready to search Markdown, JSON, and YAML files, with the Search files toolbar button active + MarkPad file search sidebar with Open files selected: a search for open file finds three matching lines across two unsaved drafts beside the editor and preview - **In-document link navigation.** Headings get anchor ids, so clicking an in-page link in the preview — like a table of contents `[Section](#section)` — smooth-scrolls to that heading within the preview pane. diff --git a/docs/images/search-dark.png b/docs/images/search-dark.png index 8f8d2b5..484cc11 100644 Binary files a/docs/images/search-dark.png and b/docs/images/search-dark.png differ diff --git a/docs/images/search-light.png b/docs/images/search-light.png index 4531ae0..d63b1a4 100644 Binary files a/docs/images/search-light.png and b/docs/images/search-light.png differ diff --git a/src/App.tsx b/src/App.tsx index 4820d35..8876485 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,9 +50,13 @@ import { type SearchStatus, } from "./lib/documentSearch"; import { + DEFAULT_WORKSPACE_SEARCH_SCOPE, isWorkspaceSearchShortcut, + searchOpenFiles, searchWorkspace, + type OpenFileSearchSource, type WorkspaceSearchFile, + type WorkspaceSearchScope, type WorkspaceSearchStatus, } from "./lib/workspaceSearch"; import { @@ -110,7 +114,7 @@ type ItemSnapshot = { type SidebarView = "recents" | "workspaceSearch"; type PendingLocation = { - path: string; + itemId: ItemId; lineNumber: number; }; @@ -190,7 +194,7 @@ function EmptyState({ modKey }: { modKey: string }) { {modKey}+S Save current file {modKey}+Shift+F - Search files in a folder + Search open files or a folder @@ -221,6 +225,8 @@ function App() { EMPTY_SEARCH_STATUS, ); const [workspaceRoot, setWorkspaceRoot] = useState(null); + const [workspaceSearchScope, setWorkspaceSearchScope] = + useState(DEFAULT_WORKSPACE_SEARCH_SCOPE); const [workspaceSearchQuery, setWorkspaceSearchQuery] = useState(""); const [workspaceSearchFiles, setWorkspaceSearchFiles] = useState< WorkspaceSearchFile[] @@ -504,7 +510,7 @@ function App() { const location = pendingLocation; if ( location === null || - activeItem?.path !== location.path || + activeItem?.id !== location.itemId || !activeItem.loaded ) { return; @@ -516,7 +522,7 @@ function App() { ); }); return () => cancelAnimationFrame(frame); - }, [activeItem?.loaded, activeItem?.path, activeText, pendingLocation]); + }, [activeItem?.id, activeItem?.loaded, activeText, pendingLocation]); // Prune per-item bookkeeping for items no longer in the list. useEffect(() => { @@ -1110,6 +1116,16 @@ function App() { setWorkspaceSearchFocusRequest((request) => request + 1); } + function handleWorkspaceSearchScopeChange(next: WorkspaceSearchScope) { + if (next === workspaceSearchScope) return; + workspaceSearchSeqRef.current++; + setWorkspaceSearchScope(next); + setWorkspaceSearchFiles([]); + setWorkspaceSearchStatus("idle"); + setWorkspaceSearchError(null); + setWorkspaceSearchFocusRequest((request) => request + 1); + } + function handleWorkspaceSearchQueryChange(next: string) { workspaceSearchSeqRef.current++; setWorkspaceSearchQuery(next); @@ -1119,9 +1135,51 @@ function App() { } async function handleRunWorkspaceSearch() { - const rootPath = workspaceRoot; const query = workspaceSearchQuery.trim(); - if (rootPath === null || query.length === 0) return; + if (query.length === 0) return; + + if (workspaceSearchScope === "openFiles") { + const openItems = itemsRef.current; + if (openItems.length === 0) return; + + const sequence = ++workspaceSearchSeqRef.current; + setWorkspaceSearchStatus("searching"); + setWorkspaceSearchError(null); + const sources = ( + await Promise.all( + openItems.map( + async (item): Promise => { + if (item.loaded) { + return { + itemId: item.id, + name: item.name, + path: item.path, + content: item.text, + }; + } + if (item.path === null) return null; + const result = await openTextFileByPath(item.path); + return result.kind === "ok" + ? { + itemId: item.id, + name: item.name, + path: item.path, + content: result.content, + } + : null; + }, + ), + ) + ).filter((source): source is OpenFileSearchSource => source !== null); + if (sequence !== workspaceSearchSeqRef.current) return; + + setWorkspaceSearchFiles(searchOpenFiles(sources, query)); + setWorkspaceSearchStatus("complete"); + return; + } + + const rootPath = workspaceRoot; + if (rootPath === null) return; const sequence = ++workspaceSearchSeqRef.current; setWorkspaceSearchStatus("searching"); @@ -1140,15 +1198,20 @@ function App() { } async function handleWorkspaceSearchResult( - path: string, + file: WorkspaceSearchFile, lineNumber: number, ) { if (viewMode !== "editor") { handleSetViewMode("editor"); } - const openedId = await openPaths([path]); + let openedId = file.itemId ?? null; + if (openedId !== null) { + await activateItem(openedId); + } else if (file.path !== null) { + openedId = await openPaths([file.path]); + } if (openedId !== null) { - setPendingLocation({ path, lineNumber }); + setPendingLocation({ itemId: openedId, lineNumber }); } } @@ -1312,17 +1375,20 @@ function App() { /> ) : ( void handleChooseWorkspaceRoot()} onQueryChange={handleWorkspaceSearchQueryChange} onSearch={() => void handleRunWorkspaceSearch()} - onSelectResult={(path, lineNumber) => - void handleWorkspaceSearchResult(path, lineNumber) + onSelectResult={(file, lineNumber) => + void handleWorkspaceSearchResult(file, lineNumber) } onClose={handleCloseWorkspaceSearch} /> diff --git a/src/components/WorkspaceSearchPanel.tsx b/src/components/WorkspaceSearchPanel.tsx index 9e32416..e872350 100644 --- a/src/components/WorkspaceSearchPanel.tsx +++ b/src/components/WorkspaceSearchPanel.tsx @@ -2,20 +2,24 @@ import { useEffect, useMemo, useRef } from "react"; import { countWorkspaceSearchMatches, type WorkspaceSearchFile, + type WorkspaceSearchScope, type WorkspaceSearchStatus, } from "../lib/workspaceSearch"; type WorkspaceSearchPanelProps = { + scope: WorkspaceSearchScope; rootPath: string | null; + openFileCount: number; query: string; files: WorkspaceSearchFile[]; status: WorkspaceSearchStatus; error: string | null; focusRequest: number; + onScopeChange: (scope: WorkspaceSearchScope) => void; onChooseFolder: () => void; onQueryChange: (query: string) => void; onSearch: () => void; - onSelectResult: (path: string, lineNumber: number) => void; + onSelectResult: (file: WorkspaceSearchFile, lineNumber: number) => void; onClose: () => void; }; @@ -60,6 +64,25 @@ function FolderIcon() { ); } +function FilesIcon() { + return ( + + ); +} + function CloseIcon() { return ( countWorkspaceSearchMatches(files), [files], ); + const scopeReady = + scope === "openFiles" ? openFileCount > 0 : rootPath !== null; + const scopeLabel = scope === "openFiles" ? "open files" : "folder"; useEffect(() => { - if (!rootPath) return; + if (!scopeReady) return; inputRef.current?.focus(); inputRef.current?.select(); - }, [focusRequest, rootPath]); + }, [focusRequest, scopeReady]); const searched = status === "complete"; const searching = status === "searching"; - const canSearch = rootPath !== null && query.trim().length > 0 && !searching; + const canSearch = scopeReady && query.trim().length > 0 && !searching; return (
- + ); + })} +
+ + {scope === "folder" ? ( + + + + + + ) : ( +
+ + + + + + In MarkPad + + + {openFileCount} {openFileCount === 1 ? "open file" : "open files"} + + +
+ )}
0 + ? "Search open files…" + : "Open a file first" + : rootPath + ? "Search this folder…" + : "Choose a folder first" + } autoComplete="off" spellCheck={false} onChange={(event) => onQueryChange(event.target.value)} @@ -217,7 +300,7 @@ export default function WorkspaceSearchPanel({
{searching - ? "Searching workspace" + ? `Searching ${scopeLabel}` : searched ? `${totalMatches} results in ${files.length} files` : ""} @@ -230,9 +313,21 @@ export default function WorkspaceSearchPanel({ ) : searching ? (
- Scanning workspace… + Searching {scopeLabel}… +
+ ) : scope === "openFiles" && openFileCount === 0 ? ( +
+
+ +
+

+ No files are open +

+

+ Open or create a file, then search its current contents here. +

- ) : rootPath === null ? ( + ) : scope === "folder" && rootPath === null ? (
@@ -251,13 +346,16 @@ export default function WorkspaceSearchPanel({ No results found

- Nothing in this workspace matches “{query.trim()}”. + {scope === "openFiles" + ? `Nothing in the open files matches “${query.trim()}”.` + : `Nothing in this folder matches “${query.trim()}”.`}

) : !searched ? (
- Type a word or phrase, then press Enter to search every supported - file in this folder. + {scope === "openFiles" + ? "Type a word or phrase, then press Enter to search the current contents of every open file." + : "Type a word or phrase, then press Enter to search every supported file in this folder."}
) : (
@@ -267,7 +365,7 @@ export default function WorkspaceSearchPanel({
{files.map((file) => (
@@ -282,7 +380,7 @@ export default function WorkspaceSearchPanel({ {file.relativePath} @@ -297,9 +395,7 @@ export default function WorkspaceSearchPanel({