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 src/components/dialogs/Dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -45,6 +46,7 @@ export function Dialogs() {
<TerminalDropDialog />
<FileFinderDialog />
<FindInFilesDialog />
<ProjectPickerDialog />
{/* 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. */}
Expand Down
93 changes: 3 additions & 90 deletions src/components/dialogs/FileFinderDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<number>();
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(<b key={i} className="text-[var(--color-accent)] font-semibold">{text[i]}</b>);
} 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);
Expand Down Expand Up @@ -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);
Expand Down
168 changes: 168 additions & 0 deletions src/components/dialogs/ProjectPickerDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);

// Reset query each time the picker opens.
useEffect(() => {
if (open) { setQuery(""); setActiveIdx(0); }
}, [open]);

const results = useMemo<Scored[]>(() => {
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 "<name> <path>" 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<HTMLElement>(`[data-row="${activeIdx}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [activeIdx]);

return (
<Dialog.Root open={open} onOpenChange={(v) => (v ? null : close())}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
<Dialog.Content
className="fixed left-1/2 top-12 z-50 w-[min(760px,92vw)] -translate-x-1/2 overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-1)] shadow-2xl outline-none"
onKeyDown={onKeyDown}
>
<Dialog.Title className="sr-only">New workspace</Dialog.Title>
<Dialog.Description className="sr-only">Search a project to start a new workspace.</Dialog.Description>
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-3 py-2.5">
<Search className="h-4 w-4 shrink-0 text-[var(--color-fg-faint)]" />
<input
autoFocus
value={query}
onChange={e => 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"
/>
</div>
<div ref={listRef} className="max-h-[70vh] overflow-y-auto py-1">
{results.length === 0 && (
<div className="px-3 py-3 text-[13px] text-[var(--color-fg-faint)]">
{query ? "No matching projects" : "No projects"}
</div>
)}
{results.map((r, i) => {
const Icon = r.isMulti ? Layers : FolderGit2;
return (
<button
key={r.id}
data-row={i}
onClick={() => 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)]",
)}
>
<Icon className={cn("h-4 w-4 shrink-0", r.isMulti ? "text-[var(--color-accent)]" : "text-[var(--color-fg-dim)]")} />
<span className="truncate">
<Highlighted text={r.name} matches={r.nameMatches} />
</span>
<span className="ml-2 min-w-0 flex-1 truncate text-[12px] text-[var(--color-fg-faint)]">
<Highlighted text={r.path} matches={r.pathMatches} />
</span>
</button>
);
})}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
8 changes: 8 additions & 0 deletions src/hooks/useShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading