fix(renderer): L138/L145 clipboard privacy+dedup, M14 state-driven settings search

- L138: gate the clipboard check on the settings `loaded` flag (not just
  clipboardWatch), so it never reads using the pre-load fallback; the effect
  re-runs when loaded flips true so a pre-launch copied link is still offered.
- L145: lastSeen is module-level, so a dismissed clipboard link stays suppressed
  across the hook's remount on a Downloads<->Library tab switch.
- M14: SettingsView renders each card in a React-owned wrapper and drives its
  visibility from a `hidden` state array — the search no longer writes display:none
  onto a card's own DOM node. Matching still reads textContent (full label coverage,
  no per-card keyword upkeep).

typecheck + 268 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:17:23 -04:00
parent 63f0a40f0e
commit 809c99d5bb
3 changed files with 82 additions and 49 deletions
+49 -37
View File
@@ -22,41 +22,52 @@ const useStyles = makeStyles({
}
})
// The cards, in display order. Rendered inside React-owned wrappers so the search
// filter controls each card's visibility via state instead of mutating the card's
// own DOM `style` (M14) — which broke if a card ever set its own inline style.
const CARDS = [
DownloadsCard,
AppearanceCard,
PostProcessingCard,
NetworkCard,
CookiesCard,
CustomCommandsCard,
FilenamesCard,
BackupCard,
DiagnosticsCard,
SoftwareUpdateCard,
AboutCard
]
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
// filter by toggling each card's `display` based on whether its text matches.
// The cards are the root's direct children (one <Card> DOM node each); the
// search box (marked data-settings-search) is skipped. React never sets `style`
// on the cards, so our inline display survives their re-renders.
// Settings search (Phase O). Matching still reads each card's rendered text (so a
// search covers every visible label without hand-maintaining per-card keywords),
// but the result drives a `hidden` state array on the wrappers — React owns the
// visibility, rather than us writing `display:none` onto React's own card nodes (M14).
const [search, setSearch] = useState('')
const [noResults, setNoResults] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
const [hidden, setHidden] = useState<boolean[]>(() => CARDS.map(() => false))
useEffect(() => {
const root = rootRef.current
if (!root) return
const q = search.trim().toLowerCase()
let shown = 0
for (const el of Array.from(root.children) as HTMLElement[]) {
if (el.dataset.settingsSearch !== undefined) continue
const match = !q || (el.textContent ?? '').toLowerCase().includes(q)
el.style.display = match ? '' : 'none'
if (match) shown++
}
setNoResults(q !== '' && shown === 0)
setHidden(
CARDS.map((_, i) => {
if (!q) return false
const text = (cardRefs.current[i]?.textContent ?? '').toLowerCase()
return !text.includes(q)
})
)
}, [search])
const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean)
return (
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
<div data-settings-search>
<ScreenHeader
title="Settings"
description="Folders, formats, network, cookies, and more."
/>
</div>
<div data-settings-search>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="Settings" description="Folders, formats, network, cookies, and more." />
<div>
<Input
input={{ 'aria-label': 'Search settings' }}
value={search}
@@ -67,22 +78,23 @@ export function SettingsView(): React.JSX.Element {
/>
{noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
No settings match "{search}".
No settings match &quot;{search}&quot;.
</Caption1>
)}
</div>
<DownloadsCard />
<AppearanceCard />
<PostProcessingCard />
<NetworkCard />
<CookiesCard />
<CustomCommandsCard />
<FilenamesCard />
<BackupCard />
<DiagnosticsCard />
<SoftwareUpdateCard />
<AboutCard />
{CARDS.map((Card, i) => (
<div
// Static array, never reordered/filtered (cards only hide) — index key is stable.
key={i}
ref={(el) => {
cardRefs.current[i] = el
}}
style={hidden[i] ? { display: 'none' } : undefined}
>
<Card />
</div>
))}
</div>
)
}
+17 -8
View File
@@ -45,6 +45,11 @@ interface ClipboardLink {
* clipboard guess; links pushed via `offer()` bypass it (and the other guards),
* since they're explicit requests rather than guesses.
*/
// Module-level (not a per-instance ref) so a dismissed/accepted link stays
// suppressed across hook remounts — switching Downloads↔Library remounts the hook,
// and the same clipboard link shouldn't be re-offered after being dismissed (L145).
let lastSeen: string | null = null
export function useClipboardLink(
currentValue: string,
filter?: (url: string) => boolean
@@ -57,18 +62,22 @@ export function useClipboardLink(
// focus listener from re-subscribing every render.
const filterRef = useRef(filter)
filterRef.current = filter
// The last link we offered or that the user dismissed — so re-focusing doesn't
// keep re-offering the same one.
const lastSeen = useRef<string | null>(null)
// Mirror the live suggestion so accept/dismiss can read it without depending on
// it — keeps their identity stable across renders.
const suggestionRef = useRef<string | null>(null)
suggestionRef.current = suggestion
// Re-run the check once persisted settings arrive, so a link copied before launch
// is still offered (the mount check is a no-op until `loaded`, L138).
const loaded = useSettings((s) => s.loaded)
useEffect(() => {
let active = true
async function check(): Promise<void> {
if (!useSettings.getState().clipboardWatch || valueRef.current.trim()) return
const s = useSettings.getState()
// Don't touch the clipboard until real settings have loaded (L138): before
// that we'd be acting on the pre-load fallback for clipboardWatch instead of
// the user's actual choice — and we never read it when the watcher is off.
if (!s.loaded || !s.clipboardWatch || valueRef.current.trim()) return
let text = ''
try {
text = (await window.api.readClipboard()) ?? ''
@@ -78,7 +87,7 @@ export function useClipboardLink(
if (!active) return
text = text.trim()
const wanted = looksLikeUrl(text) && (filterRef.current?.(text) ?? true)
if (wanted && text !== lastSeen.current) {
if (wanted && text !== lastSeen) {
setSource('clipboard')
setSuggestion(text)
}
@@ -89,18 +98,18 @@ export function useClipboardLink(
active = false
window.removeEventListener('focus', check)
}
}, [])
}, [loaded])
const accept = useCallback((): string | null => {
const link = suggestionRef.current
if (!link) return null
lastSeen.current = link
lastSeen = link
setSuggestion(null)
return link
}, [])
const dismiss = useCallback((): void => {
lastSeen.current = suggestionRef.current
lastSeen = suggestionRef.current
setSuggestion(null)
}, [])