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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com).

### Added

- Global search: press Cmd+P (or File → Go to File…) to find notes by name, by path, or by a phrase inside them. Results show a matching excerpt, and selecting one opens the note. Thanks [@zcuric](https://github.com/zcuric)! [#159](https://github.com/bholmesdev/hubble.md/pull/159)

### Changed

### Fixed
Expand Down
84 changes: 84 additions & 0 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
DesktopUpdateState,
DirectoryListing,
MenuState,
SearchFileResult,
WorkspaceConfig,
} from "../src/desktopApi/types";
import {
Expand All @@ -35,6 +36,13 @@ import {
markdownAssetFolderPath,
withMarkdownExtension,
} from "../src/lib/filePath";
import {
findMatchesInContent,
SEARCH_CONCURRENCY,
SEARCH_MAX_FILE_BYTES,
SEARCH_MAX_RESULT_FILES,
SEARCH_MIN_QUERY_LENGTH,
} from "../src/lib/searchContent";
import { setupTerminalIpc } from "./terminal";
import {
loadZoomFactor,
Expand Down Expand Up @@ -154,6 +162,9 @@ const watchers = new Map<string, FSWatcher>();
const grantedFiles = new Set<string>();
const grantedRoots = new Set<string>();
let grantsLoaded = false;
// An AbortSignal cannot cross IPC, so a superseded search is abandoned by
// comparing its id against the newest one between files.
let latestSearchRequestId = 0;

const ignoreConfigFiles = [".gitignore", ".ignore"];
const ignoredWorkspaceDirs = new Set([".git", "dist", "node_modules"]);
Expand Down Expand Up @@ -778,6 +789,14 @@ function buildMenu() {
click: () => sendToRenderer("desktop:menu-show-workspace-switcher"),
},
{ type: "separator" },
{
id: "go-to-file",
label: "Go to File...",
accelerator: "CmdOrCtrl+P",
enabled: menuState.hasWorkspace,
click: () => sendToRenderer("desktop:menu-go-to-file"),
},
{ type: "separator" },
{
id: "sync-workspace",
label: "Sync Workspace",
Expand Down Expand Up @@ -1278,6 +1297,71 @@ function registerIpc() {
},
);

ipcMain.handle(
"desktop:search-file-contents",
async (_event, { requestId, paths, query }) => {
latestSearchRequestId = requestId;
const needle = String(query ?? "").trim();
const empty = { requestId, results: [], truncated: false };
if (needle.length < SEARCH_MIN_QUERY_LENGTH) return empty;

// The renderer hands us the sidebar snapshot's paths, so search sees
// exactly what the sidebar sees (ADR-0008) and main never re-walks.
const candidates = (paths as string[]).filter(hasMarkdownExtension);
const results: SearchFileResult[] = [];
const isStale = () => requestId !== latestSearchRequestId;
let cursor = 0;
let capped = false;

async function worker() {
while (true) {
if (isStale()) return;
if (results.length >= SEARCH_MAX_RESULT_FILES) {
capped = true;
return;
}
const index = cursor;
cursor += 1;
if (index >= candidates.length) return;

const candidate = candidates[index];
try {
const resolved = assertGranted(candidate);
const stat = await fs.stat(resolved);
if (!stat.isFile() || stat.size > SEARCH_MAX_FILE_BYTES) continue;
const content = await fs.readFile(resolved, "utf8");
const matches = findMatchesInContent(content, needle);
if (matches.length > 0) results.push({ path: candidate, matches });
} catch {}
}
}

await Promise.all(
Array.from(
{ length: Math.min(SEARCH_CONCURRENCY, candidates.length) },
worker,
),
);
if (isStale()) return empty;

return {
requestId,
// The worker pool finishes out of order; sort so equal-ranked results
// do not jitter between keystrokes.
results: results
.slice(0, SEARCH_MAX_RESULT_FILES)
.sort((a, b) => a.path.localeCompare(b.path)),
// Workers can push a few past the cap between awaits; the slice hides
// them, so they must count as truncation. `capped` alone is not
// enough of a signal in the other direction: a scan that finished
// with exactly the cap dropped nothing.
truncated:
(capped && cursor < candidates.length) ||
results.length > SEARCH_MAX_RESULT_FILES,
};
},
);

ipcMain.handle(
"desktop:write-file-text",
async (_event, { path: filePath, bytes }) => {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const desktopApi = {
}),
readFileText: (path) =>
ipcRenderer.invoke("desktop:read-file-text", { path }),
searchFileContents: (input) =>
ipcRenderer.invoke("desktop:search-file-contents", input),
detectHubbleSkills: (workspacePath) =>
ipcRenderer.invoke("desktop:detect-hubble-skills", { workspacePath }),
writeFileText: (path, content) => {
Expand Down Expand Up @@ -105,6 +107,7 @@ const desktopApi = {
subscribe("desktop:menu-copy-as-markdown", callback),
onMenuShowWorkspaceSwitcher: (callback) =>
subscribe("desktop:menu-show-workspace-switcher", callback),
onMenuGoToFile: (callback) => subscribe("desktop:menu-go-to-file", callback),
onMenuSyncWorkspace: (callback) =>
subscribe("desktop:menu-sync-workspace", callback),
onMenuToggleTerminal: (callback) =>
Expand Down
45 changes: 44 additions & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import {
Button,
classifyHref,
EditorView,
GlobalSearchPalette,
Input,
MarkdownSourceEditor,
type PaletteFile,
type WikiTarget,
} from "@hubble.md/ui";
import { useStoreValue } from "@simplestack/store/react";
import { keymatch } from "keymatch";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import MingcutePencilLine from "~icons/mingcute/pencil-line";
import { HtmlAppEmptyState } from "./components/HtmlAppEmptyState";
Expand Down Expand Up @@ -100,6 +102,23 @@ async function revealPath(path: string | null) {
}
}

let nextSearchRequestId = 0;

/**
* Content search reads the sidebar snapshot's paths rather than asking main to
* re-crawl, so search and the sidebar always agree on what exists (ADR-0008).
*/
async function searchFileContents(query: string) {
nextSearchRequestId += 1;
const { files } = workspaceStore.get();
const { results, truncated } = await desktopApi.searchFileContents({
requestId: nextSearchRequestId,
paths: files.map((file) => file.path),
query,
});
return { results, truncated };
}

function App() {
const state = useStoreValue(viewerStore);
const workspacePath = useStoreValue(workspacePathStore);
Expand All @@ -119,6 +138,17 @@ function App() {
null,
);
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const workspaceFiles = useStoreValue(workspaceStore).files;
const paletteFiles: PaletteFile[] = useMemo(
() =>
workspaceFiles.map((file) => ({
path: file.path,
relativePath: relativeWorkspacePath(file.path, workspacePath ?? null),
modifiedAt: file.modified_at,
})),
[workspaceFiles, workspacePath],
);

const readyVersion =
updateState?.status === "ready"
Expand Down Expand Up @@ -242,6 +272,11 @@ function App() {
if (!workspaceStore.get().workspacePath) return;
event.preventDefault();
setWorkspaceSwitcherOpen(true);
} else if (keymatch(event, "CmdOrCtrl+P")) {
if (!workspaceStore.get().workspacePath) return;
event.preventDefault();
// The File menu accelerator fires too, but opening is idempotent.
setSearchOpen(true);
} else if (keymatch(event, "CmdOrCtrl+Shift+N")) {
event.preventDefault();
await openWorkspaceWithSidebar();
Expand Down Expand Up @@ -315,6 +350,7 @@ function App() {
desktopApi.onMenuShowWorkspaceSwitcher(() =>
setWorkspaceSwitcherOpen(true),
),
desktopApi.onMenuGoToFile(() => setSearchOpen(true)),
desktopApi.onMenuSyncWorkspace(() => void refreshFiles()),
desktopApi.onMenuToggleTerminal(() => toggleTerminal()),
desktopApi.onMenuGoBack(() => void goBack()),
Expand Down Expand Up @@ -453,6 +489,13 @@ function App() {
<TerminalPanel />
</section>
</div>
<GlobalSearchPalette
open={searchOpen}
onOpenChange={setSearchOpen}
files={paletteFiles}
onSelectFile={(path) => void loadPath(path)}
searchContents={searchFileContents}
/>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<ChatAboutNoteSettingsSection />
{updateState ? (
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/desktopApi/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,35 @@ export type HtmlAppFileEntry = {
size: number;
};

export type SearchContentMatch = {
/** 1-indexed line number within the file. */
line: number;
/** Trimmed line, windowed around the match, with ellipses when clipped. */
excerpt: string;
matchStart: number;
matchEnd: number;
};

export type SearchFileResult = {
path: string;
matches: SearchContentMatch[];
};

export type SearchFileContentsInput = {
/** Monotonic per-renderer id. Main abandons a search once it is superseded. */
requestId: number;
/** Candidate paths, taken from the sidebar snapshot. Main never re-walks. */
paths: string[];
query: string;
};

export type SearchFileContentsOutput = {
requestId: number;
results: SearchFileResult[];
/** True when the file cap was hit before every candidate was scanned. */
truncated: boolean;
};

export type PersistPastedImageInput = {
filePath: string;
bytes: number[];
Expand Down Expand Up @@ -90,6 +119,9 @@ export type DesktopApi = {
config: WorkspaceConfig,
): Promise<void>;
readFileText(path: string): Promise<string>;
searchFileContents(
input: SearchFileContentsInput,
): Promise<SearchFileContentsOutput>;
detectHubbleSkills(workspacePath: string): Promise<boolean>;
writeFileText(path: string, content: string): Promise<void>;
createFolder(path: string): Promise<void>;
Expand Down Expand Up @@ -136,6 +168,7 @@ export type DesktopApi = {
onMenuOpenSettings(callback: () => void): Unsubscribe;
onMenuCopyAsMarkdown(callback: () => void): Unsubscribe;
onMenuShowWorkspaceSwitcher(callback: () => void): Unsubscribe;
onMenuGoToFile(callback: () => void): Unsubscribe;
onMenuSyncWorkspace(callback: () => void): Unsubscribe;
onMenuToggleTerminal(callback: () => void): Unsubscribe;
onMenuGoBack(callback: () => void): Unsubscribe;
Expand Down
Loading
Loading