-
Notifications
You must be signed in to change notification settings - Fork 4.1k
feat(web): make the sidebar project filter a searchable combobox #5925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,15 @@ import { resolveEnvModeLabel } from "../BranchToolbar.logic"; | |
| import { createModelSelection } from "@t3tools/shared/model"; | ||
| import { useLocation, useNavigate } from "@tanstack/react-router"; | ||
| import * as Cause from "effect/Cause"; | ||
| import { CopyIcon, FolderIcon, PlusIcon, ServerIcon, SettingsIcon, Trash2Icon } from "lucide-react"; | ||
| import { | ||
| CopyIcon, | ||
| FolderIcon, | ||
| PlusIcon, | ||
| SearchIcon, | ||
| ServerIcon, | ||
| SettingsIcon, | ||
| Trash2Icon, | ||
| } from "lucide-react"; | ||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
|
|
||
| import { useComposerDraftStore } from "../../composerDraftStore"; | ||
|
|
@@ -78,6 +86,7 @@ import { | |
| } from "../projectScriptEditor"; | ||
| import { Button } from "../ui/button"; | ||
| import { Input } from "../ui/input"; | ||
| import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; | ||
| import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| import { stackedThreadToast, toastManager } from "../ui/toast"; | ||
| import { | ||
|
|
@@ -131,6 +140,22 @@ export function ProjectSettingsPanel({ | |
| const groups = useSettingsProjectGroups(); | ||
| const navigate = useNavigate(); | ||
| const currentHash = useLocation({ select: (location) => location.hash }); | ||
| const [searchQuery, setSearchQuery] = useState(""); | ||
| const normalizedQuery = searchQuery.trim().toLowerCase(); | ||
| // Match checkout titles and paths too, so a group is findable by any of | ||
| // the names it goes by, not just the grouped display name. | ||
| const visibleGroups = useMemo(() => { | ||
| if (!normalizedQuery) return groups; | ||
| return groups.filter( | ||
| (group) => | ||
| group.displayName.toLowerCase().includes(normalizedQuery) || | ||
| group.memberProjects.some( | ||
| (member) => | ||
| member.title.toLowerCase().includes(normalizedQuery) || | ||
| member.workspaceRoot.toLowerCase().includes(normalizedQuery), | ||
| ), | ||
| ); | ||
| }, [groups, normalizedQuery]); | ||
|
|
||
| // The index route auto-selects the first project so /settings/projects is | ||
| // never a dead end. Hash is preserved for settings-search jumps. | ||
|
|
@@ -206,7 +231,37 @@ export function ProjectSettingsPanel({ | |
| aria-label="Projects" | ||
| className="w-60 shrink-0 space-y-0.5 overflow-y-auto border-e border-border/40 p-3 pt-10 max-sm:hidden sm:pt-12" | ||
| > | ||
| {groups.map((group) => { | ||
| {groups.length > 0 ? ( | ||
| <InputGroup variant="ghost" className="mb-1 h-8 rounded-md"> | ||
| <InputGroupAddon> | ||
| <SearchIcon className="size-4 text-muted-foreground" /> | ||
| </InputGroupAddon> | ||
| <InputGroupInput | ||
| type="search" | ||
| size="sm" | ||
| value={searchQuery} | ||
| aria-label="Search projects" | ||
| placeholder="Search projects" | ||
| spellCheck={false} | ||
| onChange={(event) => setSearchQuery(event.target.value)} | ||
| onKeyDown={(event) => { | ||
| // Only intercept Escape while filtering, so an empty field | ||
| // still lets the global shortcut close the settings page. | ||
| if (event.key === "Escape" && searchQuery.length > 0) { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| setSearchQuery(""); | ||
| return; | ||
| } | ||
| if (event.key === "Enter") { | ||
| const first = visibleGroups[0]; | ||
| if (first) selectProject(first.projectKey); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enter selects without active filterMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 4c725c4. Configure here. |
||
| }} | ||
| /> | ||
| </InputGroup> | ||
| ) : null} | ||
| {visibleGroups.map((group) => { | ||
| const isActive = group.projectKey === selected?.projectKey; | ||
| return ( | ||
| <button | ||
|
|
@@ -236,6 +291,10 @@ export function ProjectSettingsPanel({ | |
| })} | ||
| {groups.length === 0 ? ( | ||
| <p className="px-2 py-4 text-sm text-muted-foreground">No projects yet.</p> | ||
| ) : visibleGroups.length === 0 ? ( | ||
| <p role="status" className="px-2 py-4 text-sm text-muted-foreground"> | ||
| No matching projects. | ||
| </p> | ||
| ) : null} | ||
| </nav> | ||
| {selected ? ( | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Escape treats whitespace as filtering
Low Severity
Escape interception keys off
searchQuery.length > 0, while filtering uses the trimmednormalizedQuery. With a whitespace-only value the list is unfiltered, but Escape still clears the field and blocks the global settings close shortcut. The nearby comment says Escape is only intercepted while filtering, and the settings sidebar uses trim for the same gate.Reviewed by Cursor Bugbot for commit 4c725c4. Configure here.