diff --git a/src/components/dialogs/Dialogs.tsx b/src/components/dialogs/Dialogs.tsx index 0765a4f..9288bd3 100644 --- a/src/components/dialogs/Dialogs.tsx +++ b/src/components/dialogs/Dialogs.tsx @@ -18,6 +18,7 @@ import { ConfirmDialog } from "./ConfirmDialog"; import { TerminalDropDialog } from "./TerminalDropDialog"; import { FileFinderDialog } from "./FileFinderDialog"; import { FindInFilesDialog } from "./FindInFilesDialog"; +import { ProjectPickerDialog } from "./ProjectPickerDialog"; import { Loader2 } from "lucide-react"; export function Dialogs() { @@ -45,6 +46,7 @@ export function Dialogs() { + {/* Blocking work overlay: shown while a slow IPC call is in flight (archive workspace, etc.). Click-blocks the whole window so users don't fire the action twice mid-wait. */} diff --git a/src/components/dialogs/FileFinderDialog.tsx b/src/components/dialogs/FileFinderDialog.tsx index a54b3e4..efcaa51 100644 --- a/src/components/dialogs/FileFinderDialog.tsx +++ b/src/components/dialogs/FileFinderDialog.tsx @@ -9,6 +9,7 @@ import { useUI } from "@/store/ui"; import { useApp } from "@/store/app"; import { workspaceListFilesForFinder } from "@/lib/ipc"; import { fileIconUrl } from "@/lib/explorer/iconResolver"; +import { fuzzyMatch, Highlighted } from "@/lib/fuzzy"; import { cn } from "@/lib/utils"; const MAX_RESULTS = 50; @@ -20,94 +21,6 @@ interface Scored { matches: number[]; } -/** - * Single-term match. Tries substring first (so "review" highlights the - * full contiguous "Review", not scattered r-e-v-i-e-w chars greedily - * picked from earlier in the path) — preferring occurrences inside the - * basename and at word boundaries. Falls back to subsequence. - */ -function matchTerm(s: string, lower: string, term: string, slash: number): { score: number; matches: number[] } | null { - const t = term.toLowerCase(); - // Substring fast path: scan all occurrences, pick the best by - // basename-ness + word-boundary-ness. Contiguous matches always beat - // scattered subsequence matches (huge consecutive bonus below). - let bestSubstr: { idx: number; score: number } | null = null; - for (let i = lower.indexOf(t); i !== -1; i = lower.indexOf(t, i + 1)) { - let sc = 100 + t.length * 9; // baseline: contiguous run of t.length chars - const prev = i > 0 ? s[i - 1] : "/"; - if (prev === "/" || prev === "-" || prev === "_" || prev === ".") sc += 10; - if (i > slash) sc += 8; - sc -= i * 0.05; // mild earlier-is-better tiebreak - if (!bestSubstr || sc > bestSubstr.score) bestSubstr = { idx: i, score: sc }; - } - if (bestSubstr) { - const matches: number[] = []; - for (let i = 0; i < t.length; i++) matches.push(bestSubstr.idx + i); - return { score: bestSubstr.score, matches }; - } - // Subsequence fallback for typos / out-of-order chars. - const matches: number[] = []; - let qi = 0; - let prevMatch = -2; - let score = 0; - for (let i = 0; i < lower.length && qi < t.length; i++) { - if (lower[i] !== t[qi]) continue; - matches.push(i); - let bonus = 1; - if (i === prevMatch + 1) bonus += 8; - const prev = i > 0 ? s[i - 1] : "/"; - if (prev === "/" || prev === "-" || prev === "_" || prev === ".") bonus += 4; - if (i > slash) bonus += 2; - if (s[i] === term[qi]) bonus += 1; - score += bonus; - prevMatch = i; - qi++; - } - if (qi < t.length) return null; - return { score, matches }; -} - -/** - * Fuzzy match. Splits the query on whitespace into AND-ed terms — every - * term must subsequence-match (Sublime / fzf convention: "components - * broa" finds files containing both). Each term gets its own independent - * pass so the order/positions of terms don't have to line up. - */ -function fuzzyScore(s: string, query: string): Scored | null { - if (!query) return { path: s, score: 0, matches: [] }; - const terms = query.split(/\s+/).filter(Boolean); - if (!terms.length) return { path: s, score: 0, matches: [] }; - const lower = s.toLowerCase(); - const slash = s.lastIndexOf("/"); - const allMatches = new Set(); - let total = 0; - for (const t of terms) { - const r = matchTerm(s, lower, t, slash); - if (!r) return null; - total += r.score; - for (const m of r.matches) allMatches.add(m); - } - total -= s.length * 0.01; - return { path: s, score: total, matches: [...allMatches].sort((a, b) => a - b) }; -} - -function Highlighted({ text, matches }: { text: string; matches: number[] }) { - if (!matches.length) return <>{text}>; - const set = new Set(matches); - const out: React.ReactNode[] = []; - let buf = ""; - for (let i = 0; i < text.length; i++) { - if (set.has(i)) { - if (buf) { out.push(buf); buf = ""; } - out.push({text[i]}); - } else { - buf += text[i]; - } - } - if (buf) out.push(buf); - return <>{out}>; -} - export function FileFinderDialog() { const wsId = useUI(s => s.fileFinderWsId); const close = useUI(s => s.closeFileFinder); @@ -151,8 +64,8 @@ export function FileFinderDialog() { } const scored: Scored[] = []; for (const f of files) { - const s = fuzzyScore(f, query); - if (s) scored.push(s); + const m = fuzzyMatch(f, query); + if (m) scored.push({ path: f, score: m.score, matches: m.matches }); } scored.sort((a, b) => b.score - a.score); return scored.slice(0, MAX_RESULTS); diff --git a/src/components/dialogs/ProjectPickerDialog.tsx b/src/components/dialogs/ProjectPickerDialog.tsx new file mode 100644 index 0000000..59d084b --- /dev/null +++ b/src/components/dialogs/ProjectPickerDialog.tsx @@ -0,0 +1,168 @@ +// ⌘N global project picker — fuzzy-search any loaded project (by name + path) +// and open the standard New Workspace dialog for it. Built for the +// hundreds-of-projects case where scrolling the sidebar to find the `+` is +// the bottleneck. Selecting a row just calls openNewWorkspace(project.id) — +// the exact same flow as the sidebar `+` → "New git worktree". + +import { useEffect, useMemo, useRef, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { Search, Layers, FolderGit2 } from "lucide-react"; +import { useUI } from "@/store/ui"; +import { useApp } from "@/store/app"; +import { fuzzyMatch, Highlighted } from "@/lib/fuzzy"; +import { cn } from "@/lib/utils"; + +const MAX_RESULTS = 15; + +interface Scored { + id: string; + name: string; + path: string; + isMulti: boolean; + score: number; + /** Match indexes relative to the name. */ + nameMatches: number[]; + /** Match indexes relative to the path. */ + pathMatches: number[]; +} + +export function ProjectPickerDialog() { + const open = useUI(s => s.projectPickerOpen); + const close = useUI(s => s.closeProjectPicker); + const openNewWorkspace = useUI(s => s.openNewWorkspace); + const projects = useApp(s => s.projects); + + const [query, setQuery] = useState(""); + const [activeIdx, setActiveIdx] = useState(0); + const listRef = useRef(null); + + // Reset query each time the picker opens. + useEffect(() => { + if (open) { setQuery(""); setActiveIdx(0); } + }, [open]); + + const results = useMemo(() => { + const toScored = (p: typeof projects[number], score: number, nameMatches: number[], pathMatches: number[]): Scored => ({ + id: p.id, + name: p.name, + path: p.root_path, + isMulti: (p.type ?? "single") === "multi", + score, + nameMatches, + pathMatches, + }); + if (!query) { + // No filter: keep sidebar order so the list isn't empty before typing. + return projects.slice(0, MAX_RESULTS).map(p => toScored(p, 0, [], [])); + } + const scored: Scored[] = []; + for (const p of projects) { + // Match against " " so a query can hit either; split the + // match indexes back into name- and path-relative sets for highlighting. + const hay = `${p.name} ${p.root_path}`; + const m = fuzzyMatch(hay, query); + if (!m) continue; + const nameLen = p.name.length; + const pathStart = nameLen + 1; + const nameMatches = m.matches.filter(i => i < nameLen); + const pathMatches = m.matches.filter(i => i >= pathStart).map(i => i - pathStart); + scored.push(toScored(p, m.score, nameMatches, pathMatches)); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, MAX_RESULTS); + }, [projects, query]); + + // Keep the active index in range whenever results change. + useEffect(() => { setActiveIdx(0); }, [query]); + + function pick(id: string) { + // Close the picker first, then open the New Workspace dialog so the two + // modals never stack. + close(); + openNewWorkspace(id); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIdx(i => Math.min(i + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIdx(i => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const r = results[activeIdx]; + if (r) pick(r.id); + } else if (e.key === "Escape") { + e.preventDefault(); + close(); + } + } + + // Scroll the active row into view on keyboard nav. + useEffect(() => { + const el = listRef.current?.querySelector(`[data-row="${activeIdx}"]`); + el?.scrollIntoView({ block: "nearest" }); + }, [activeIdx]); + + return ( + (v ? null : close())}> + + + + New workspace + Search a project to start a new workspace. + + + setQuery(e.target.value)} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + autoComplete="off" + placeholder="Search a project to start a new workspace" + className="w-full bg-transparent pl-1 text-[14px] text-[var(--color-fg)] placeholder:text-[var(--color-fg-faint)] focus:outline-none" + /> + + + {results.length === 0 && ( + + {query ? "No matching projects" : "No projects"} + + )} + {results.map((r, i) => { + const Icon = r.isMulti ? Layers : FolderGit2; + return ( + pick(r.id)} + onMouseMove={() => setActiveIdx(i)} + className={cn( + "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px]", + i === activeIdx + ? "bg-[var(--color-bg-2)] text-[var(--color-fg)]" + : "text-[var(--color-fg)]", + )} + > + + + + + + + + + ); + })} + + + + + ); +} diff --git a/src/hooks/useShortcuts.ts b/src/hooks/useShortcuts.ts index 5161781..5073675 100644 --- a/src/hooks/useShortcuts.ts +++ b/src/hooks/useShortcuts.ts @@ -109,6 +109,14 @@ export function useShortcuts() { return; } + // ⌘N → global project picker. Fires from anywhere (no active + // workspace needed) so you can start a new workspace without first + // selecting one. No `isTyping` guard, same as the file finder. + case "new-workspace-quick": + e.preventDefault(); + useUI.getState().openProjectPicker(); + return; + // ⌘, → open settings (macOS convention). case "open-settings": e.preventDefault(); diff --git a/src/lib/fuzzy.tsx b/src/lib/fuzzy.tsx new file mode 100644 index 0000000..eb00129 --- /dev/null +++ b/src/lib/fuzzy.tsx @@ -0,0 +1,100 @@ +// Shared Sublime/fzf-style fuzzy matcher + highlight component, used by the +// ⌘P file finder and the ⌘N project picker. Substring-first (so "review" +// highlights the contiguous "Review" rather than scattering chars), with +// word-boundary / basename boosts and a subsequence fallback for typos. + +export interface FuzzyMatch { + score: number; + /** Indexes into the source string that matched a query char — for bolding. */ + matches: number[]; +} + +/** + * Single-term match. Tries substring first — preferring occurrences inside the + * basename (right of the last `slash`) and at word boundaries. Falls back to a + * subsequence scan. `slash` is the index of the last path separator, or -1 for + * strings without one (so every char counts as basename). + */ +function matchTerm(s: string, lower: string, term: string, slash: number): FuzzyMatch | null { + const t = term.toLowerCase(); + // Substring fast path: scan all occurrences, pick the best by + // basename-ness + word-boundary-ness. Contiguous matches always beat + // scattered subsequence matches (huge consecutive bonus below). + let bestSubstr: { idx: number; score: number } | null = null; + for (let i = lower.indexOf(t); i !== -1; i = lower.indexOf(t, i + 1)) { + let sc = 100 + t.length * 9; // baseline: contiguous run of t.length chars + const prev = i > 0 ? s[i - 1] : "/"; + if (prev === "/" || prev === "-" || prev === "_" || prev === ".") sc += 10; + if (i > slash) sc += 8; + sc -= i * 0.05; // mild earlier-is-better tiebreak + if (!bestSubstr || sc > bestSubstr.score) bestSubstr = { idx: i, score: sc }; + } + if (bestSubstr) { + const matches: number[] = []; + for (let i = 0; i < t.length; i++) matches.push(bestSubstr.idx + i); + return { score: bestSubstr.score, matches }; + } + // Subsequence fallback for typos / out-of-order chars. + const matches: number[] = []; + let qi = 0; + let prevMatch = -2; + let score = 0; + for (let i = 0; i < lower.length && qi < t.length; i++) { + if (lower[i] !== t[qi]) continue; + matches.push(i); + let bonus = 1; + if (i === prevMatch + 1) bonus += 8; + const prev = i > 0 ? s[i - 1] : "/"; + if (prev === "/" || prev === "-" || prev === "_" || prev === ".") bonus += 4; + if (i > slash) bonus += 2; + if (s[i] === term[qi]) bonus += 1; + score += bonus; + prevMatch = i; + qi++; + } + if (qi < t.length) return null; + return { score, matches }; +} + +/** + * Fuzzy match. Splits the query on whitespace into AND-ed terms — every + * term must match (Sublime / fzf convention: "components broa" finds strings + * containing both). Each term gets its own independent pass so the + * order/positions of terms don't have to line up. Returns `{score:0,matches:[]}` + * for an empty query so callers can treat "no filter" uniformly. + */ +export function fuzzyMatch(s: string, query: string): FuzzyMatch | null { + if (!query) return { score: 0, matches: [] }; + const terms = query.split(/\s+/).filter(Boolean); + if (!terms.length) return { score: 0, matches: [] }; + const lower = s.toLowerCase(); + const slash = s.lastIndexOf("/"); + const allMatches = new Set(); + let total = 0; + for (const t of terms) { + const r = matchTerm(s, lower, t, slash); + if (!r) return null; + total += r.score; + for (const m of r.matches) allMatches.add(m); + } + total -= s.length * 0.01; + return { score: total, matches: [...allMatches].sort((a, b) => a - b) }; +} + +/** Bolds the matched character ranges within `text`. */ +export function Highlighted({ text, matches }: { text: string; matches: number[] }) { + if (!matches.length) return <>{text}>; + const set = new Set(matches); + const out: React.ReactNode[] = []; + let buf = ""; + for (let i = 0; i < text.length; i++) { + if (set.has(i)) { + if (buf) { out.push(buf); buf = ""; } + out.push({text[i]}); + } else { + buf += text[i]; + } + } + if (buf) out.push(buf); + return <>{out}>; +} diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 09b41af..f2bfa32 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -41,6 +41,7 @@ export type ShortcutId = | "new-right-split-terminal" | "terminal-copy" | "terminal-paste" + | "new-workspace-quick" | "open-settings" | "open-shortcuts" | "file-finder" @@ -122,6 +123,8 @@ export const SHORTCUT_DEFS: ShortcutDef[] = [ defaultBinding: B("v", { cmd: true, shift: true }) }, // General + { id: "new-workspace-quick", group: "General", label: "New workspace…", + hint: "Search a project and start a new workspace", defaultBinding: B("n", { cmd: true }) }, { id: "open-settings", group: "General", label: "Open settings", defaultBinding: B(",", { cmd: true }) }, { id: "open-shortcuts", group: "General", label: "Open keyboard shortcuts", diff --git a/src/store/ui.ts b/src/store/ui.ts index c719ef7..d01a681 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -74,6 +74,9 @@ interface UIState { /** ⌘P file finder — workspace id to scope the search to; null = closed. * Lives here so opening doesn't churn the workspace tree. */ fileFinderWsId: string | null; + /** Global fuzzy project picker (⌘N) — search any loaded project and + * start a new workspace for it without scrolling the sidebar. */ + projectPickerOpen: boolean; /** ⇧⌘F find-in-files dialog — workspace id, null = closed. */ findInFilesWsId: string | null; /** Global "blocking work in flight" message. Shows a centered loader over @@ -132,6 +135,8 @@ interface UIState { closeSandbox: () => void; openFileFinder: (wsId: string) => void; closeFileFinder: () => void; + openProjectPicker: () => void; + closeProjectPicker: () => void; openFindInFiles: (wsId: string) => void; closeFindInFiles: () => void; setBusy: (msg: string | null) => void; @@ -203,6 +208,7 @@ export const useUI = create(set => ({ sandboxForWsId: null, fileFinderWsId: null, findInFilesWsId: null, + projectPickerOpen: false, busyMessage: null, runScriptRequest: null, fileTreeNonce: 0, @@ -236,6 +242,8 @@ export const useUI = create(set => ({ closeFileFinder: () => set({ fileFinderWsId: null }), openFindInFiles: (wsId) => set({ findInFilesWsId: wsId }), closeFindInFiles: () => set({ findInFilesWsId: null }), + openProjectPicker: () => set({ projectPickerOpen: true }), + closeProjectPicker:() => set({ projectPickerOpen: false }), setBusy: (msg) => set({ busyMessage: msg }), reloadFileTree: () => set(s => ({ fileTreeNonce: s.fileTreeNonce + 1 })), setNotifyRoute: (route) => set({