Improve GitHub list search

This commit is contained in:
Jinjing
2026-05-15 12:31:10 -07:00
parent 60fdd11114
commit eda71c6b85
4 changed files with 163 additions and 6 deletions
+57
View File
@@ -0,0 +1,57 @@
# GitHub List Search
## What the code does today
- `TaskPage` has GitHub Items search input with Enter-to-apply and X-to-clear; it does not claim Cmd/Ctrl+F.
- `ProjectViewWrapper` has a local input (`ProjectSearchInput`) with Enter/blur apply and Esc restore+blur.
- Project X button is **reset to saved view filter**, not clear-to-empty.
- `queryOverride` semantics are:
- `undefined`: use `selectedView.filter`
- `''`: explicit empty filter for this fetch
- any other string: override filter for this fetch
- Shared type docs currently claim `''` and `undefined` are equivalent; that is incorrect.
## Critical corrections
1. Shortcut behavior is missing, not partially implemented.
- Add explicit Cmd/Ctrl+F capture in both GitHub Items and Projects modes.
- Use platform check (`navigator.userAgent.includes('Mac') ? metaKey : ctrlKey`) and never hardcode `metaKey`.
- Do not capture while dialogs/modals or editable overlays are active.
2. Project clear behavior in this doc was wrong.
- Current code resets to saved filter; GitHub-like clear should apply `queryOverride: ''`.
- Keep Esc as the “revert to last applied” interaction.
3. `queryOverride` contract in shared types is wrong.
- Update `GetProjectViewTableArgs.queryOverride` docs to match main-process behavior and cache key behavior.
## Edge cases this must explicitly handle
- IME composition: keep Enter suppression for composing text in GitHub Items and Projects.
- Repeated Cmd/Ctrl+F: always focus the active search box and select all text.
- Mode gating: never steal find when task source is not GitHub, or GitHub mode is not the visible mode.
- View switch race: do not let late responses overwrite the visible search state for a newer view.
- External mutations and multi-window drift: local optimistic edits and per-window cache are non-authoritative; refresh must remain available and predictable.
## Consistency and concurrency notes
- Renderer cache TTL for project tables is 60s; user-initiated apply already forces fetch (`force: true`), which is correct.
- Inflight dedupe is per-renderer process only. Separate windows can race and show temporarily different snapshots.
- No push invalidation exists for GitHub-side external changes; consistency is pull-based (refresh/fetch).
- `queryOverride` is part of cache keys; this is required so saved-filter and empty-filter results do not clobber each other.
## Feasibility check
- “One call” claims are not valid for most flows:
- `getProjectViewTable` can require multiple GraphQL pages for views, fields, and items.
- Discovery and view resolution are paginated.
- “Free” claims are not valid:
- Every apply/refresh consumes GitHub API budget; forced fetch intentionally bypasses fresh cache.
## Required doc-aligned implementation outcomes
1. Cmd/Ctrl+F focuses/selects GitHub Items search when GitHub Items mode is visible.
2. Cmd/Ctrl+F focuses/selects Project search when Project view mode is visible.
3. Project clear button applies empty override (`''`) immediately.
4. Esc in Project search restores last applied query and blurs.
5. Shared type comment for `queryOverride` matches runtime semantics.
+56
View File
@@ -869,6 +869,7 @@ export default function TaskPage(): React.JSX.Element {
const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery)
const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery)
const taskSearchInputRef = useRef<HTMLInputElement>(null)
const [activeTaskPreset, setActiveTaskPreset] = useState<TaskViewPresetId | null>(
defaultTaskViewPreset
)
@@ -1645,6 +1646,59 @@ export default function TaskPage(): React.JSX.Element {
[handleApplyTaskSearch]
)
useEffect(() => {
if (
taskSource !== 'github' ||
githubMode !== 'items' ||
dialogWorkItem ||
drawerLinearIssue ||
newIssueOpen ||
newLinearIssueOpen ||
activeModal !== 'none'
) {
return
}
const onKeyDown = (event: KeyboardEvent): void => {
const isMac = navigator.userAgent.includes('Mac')
const modifierPressed = isMac ? event.metaKey : event.ctrlKey
if (!modifierPressed || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'f') {
return
}
const input = taskSearchInputRef.current
if (!input) {
return
}
const target = event.target
if (
target instanceof HTMLElement &&
target !== input &&
(target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target.isContentEditable)
) {
return
}
event.preventDefault()
event.stopPropagation()
input.focus()
input.select()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [
activeModal,
dialogWorkItem,
drawerLinearIssue,
githubMode,
newIssueOpen,
newLinearIssueOpen,
taskSource
])
const openComposerForItem = useCallback(
(item: GitHubWorkItem): void => {
const linkedWorkItem: LinkedWorkItemSummary = {
@@ -2285,6 +2339,8 @@ export default function TaskPage(): React.JSX.Element {
<div className="relative min-w-[320px] flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={taskSearchInputRef}
data-github-items-search-input
value={taskSearchInput}
onChange={handleTaskSearchChange}
onKeyDown={handleTaskSearchKeyDown}
@@ -2,7 +2,7 @@
// Why: top-level container for Project mode. Handles the picker, header,
// filter label, count pill, Open-in-GitHub, and all Interaction States
// documented in the design doc.
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ExternalLink,
Loader,
@@ -799,6 +799,7 @@ function ProjectSearchInput({
}): React.JSX.Element {
const initial = appliedOverride !== undefined ? appliedOverride : viewFilter
const [value, setValue] = useState<string>(initial)
const inputRef = useRef<HTMLInputElement>(null)
const applied = appliedOverride !== undefined ? appliedOverride : viewFilter
const dirty = value !== applied
@@ -808,14 +809,55 @@ function ProjectSearchInput({
onApply(next === viewFilter ? undefined : next)
}
useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
const isMac = navigator.userAgent.includes('Mac')
const modifierPressed = isMac ? event.metaKey : event.ctrlKey
if (!modifierPressed || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'f') {
return
}
if (document.querySelector('[role="dialog"]')) {
return
}
const input = inputRef.current
if (!input) {
return
}
const target = event.target
if (
target instanceof HTMLElement &&
target !== input &&
(target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target.isContentEditable)
) {
return
}
event.preventDefault()
event.stopPropagation()
input.focus()
input.select()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [])
return (
<div className="relative min-w-[280px] flex-1 max-w-xl">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
data-github-project-search-input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.nativeEvent.isComposing) {
return
}
e.preventDefault()
apply(value)
} else if (e.key === 'Escape') {
@@ -835,13 +877,14 @@ function ProjectSearchInput({
dirty && 'border-amber-500/50'
)}
/>
{value && value !== viewFilter ? (
{value ? (
<button
type="button"
aria-label="Reset to view filter"
aria-label="Clear search"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setValue(viewFilter)
apply(viewFilter)
setValue('')
apply('')
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
+2 -1
View File
@@ -338,7 +338,8 @@ export type GetProjectViewTableArgs = {
viewName?: string
/** Ephemeral GitHub-search-syntax query that replaces the view's filter for
* this fetch only. The view's stored filter on GitHub is not modified.
* Empty string and undefined both mean "use the view's filter as-is". */
* `undefined` uses the view's saved filter; `''` explicitly clears it for
* this fetch and gets a distinct renderer cache key. */
queryOverride?: string
}