mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 20:57:51 +00:00
feat: search settings and link directly to controls
This commit is contained in:
parent
42dc7ec41d
commit
0c3d04183c
7 changed files with 453 additions and 9 deletions
|
|
@ -2645,3 +2645,11 @@ A persistent header status bar that gives you a real-time overview of system hea
|
|||
---
|
||||
|
||||
_Last updated: 2026-03-21 · [Back to README](../README.md)_
|
||||
|
||||
### Find and link to settings
|
||||
|
||||
Use **Search settings** to find a category or control by its label or common terms such as theme, backup, token, and default agent. Arrow keys select a result; Enter opens its existing section and moves focus to the target. Empty results leave focus in the search field. Results respect the section and control permissions. Board Only is a focus preset, not a permission boundary: optional agent and automation results remain available, are marked optional, and follow core results.
|
||||
|
||||
Support links can append a settings fragment to the app URL, for example `/#settings/general/general-appearance`, `/#settings/maintenance/maintenance-backup`, or `/#settings/multi-user/multi-user-api-access`. Selecting a search result updates this fragment so the current URL can be copied. Closing Settings clears it. Links never perform the setting's action, and a control target that is unknown or unavailable falls back to an allowed section.
|
||||
|
||||
Data configures telemetry, budgets, and archiving; Maintenance contains backup, restore, logs, and storage tools; Manage contains task-data management. The existing categories have not moved.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,17 @@ vi.mock('@/hooks/useToast', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@/components/settings/tabs/GeneralTab', () => ({
|
||||
GeneralTab: () => <div>General settings loaded</div>,
|
||||
GeneralTab: () => (
|
||||
<div>
|
||||
General settings loaded
|
||||
<section id="general-appearance">
|
||||
<input aria-label="Fixture theme" />
|
||||
</section>
|
||||
<section id="general-default-agent">
|
||||
<button>Fixture default agent</button>
|
||||
</section>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/settings/tabs/BoardTab', () => ({
|
||||
|
|
@ -113,6 +123,7 @@ vi.mock('@/components/settings/tabs/MultiUserTab', () => ({
|
|||
|
||||
describe('SettingsDialog Mantine shell', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
mocks.saveError = null;
|
||||
mocks.hasPermission.mockReturnValue(true);
|
||||
mocks.productMode.selectedMode = 'advanced';
|
||||
|
|
@ -165,6 +176,57 @@ describe('SettingsDialog Mantine shell', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('finds an existing appearance control and focuses it through keyboard selection', async () => {
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
const input = screen.getByRole('combobox', { name: 'Search settings' });
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value: 'theme' } });
|
||||
expect(screen.getByRole('option', { name: /Theme and appearance/ })).toBeDefined();
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByLabelText('Fixture theme'))
|
||||
);
|
||||
expect(window.location.hash).toBe('#settings/general/general-appearance');
|
||||
});
|
||||
|
||||
it('keeps focus in search for empty results and filters permission-restricted controls', () => {
|
||||
mocks.hasPermission.mockImplementation((permission) => permission !== 'admin:manage');
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
const input = screen.getByRole('combobox', { name: 'Search settings' });
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value: 'token' } });
|
||||
expect(screen.queryByRole('option', { name: /API tokens/ })).toBeNull();
|
||||
expect(screen.getByText(/No matching settings/)).toBeDefined();
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it('identifies optional controls under the Board Only focus preset', () => {
|
||||
mocks.productMode.selectedMode = 'board-only';
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Search settings' }), {
|
||||
target: { value: 'default agent' },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('option', { name: /Default agent.*Optional in Board Only/ })
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('opens a control from a support target after its lazy section mounts', async () => {
|
||||
renderWithProviders(
|
||||
<SettingsDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
defaultTab="general"
|
||||
defaultControl="general-appearance"
|
||||
/>
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByLabelText('Fixture theme'))
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the settings shell with direct Mantine controls', async () => {
|
||||
const { baseElement } = renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
|
||||
|
|
|
|||
31
web/src/__tests__/settings-search-index.test.ts
Normal file
31
web/src/__tests__/settings-search-index.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SETTINGS_CONTROL_INDEX,
|
||||
searchSettings,
|
||||
parseSettingsSupportHash,
|
||||
settingsSupportHash,
|
||||
} from '@/components/settings/settings-search-index';
|
||||
|
||||
describe('settings support destinations', () => {
|
||||
it.each([
|
||||
['backup', 'maintenance-backup'],
|
||||
['theme', 'general-appearance'],
|
||||
['token', 'multi-user-api-access'],
|
||||
['default agent', 'general-default-agent'],
|
||||
])('locates %s', (query, control) => {
|
||||
expect(searchSettings(SETTINGS_CONTROL_INDEX, query, false)[0].controlId).toBe(control);
|
||||
});
|
||||
|
||||
it('round-trips a control link and rejects arbitrary fragments', () => {
|
||||
expect(parseSettingsSupportHash(settingsSupportHash('general', 'general-appearance'))).toEqual({
|
||||
section: 'general',
|
||||
control: 'general-appearance',
|
||||
});
|
||||
expect(parseSettingsSupportHash('#settings/data')).toEqual({
|
||||
section: 'data',
|
||||
control: undefined,
|
||||
});
|
||||
expect(parseSettingsSupportHash('#settings/general/../../outside')).toBeNull();
|
||||
expect(parseSettingsSupportHash('#other')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { parseSettingsSupportHash } from '@/components/settings/settings-search-index';
|
||||
import { flushSync } from 'react-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
|
|
@ -141,6 +142,7 @@ export function Header({
|
|||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState<string | undefined>();
|
||||
const [settingsControl, setSettingsControl] = useState<string | undefined>();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchPreset, setSearchPreset] = useState<SearchPreset | undefined>();
|
||||
// activityOpen removed — sidebar merged into feed (GH-66)
|
||||
|
|
@ -249,8 +251,9 @@ export function Header({
|
|||
}, [openSquadChatPanel, toggleBottomPanel, usesWorkbenchChat]);
|
||||
|
||||
const openSettingsDialog = useCallback(
|
||||
(section?: string) => {
|
||||
(section?: string, control?: string) => {
|
||||
markPanelLoaded('settings');
|
||||
setSettingsControl(control);
|
||||
setSettingsTab(section);
|
||||
setSettingsOpen(true);
|
||||
},
|
||||
|
|
@ -326,12 +329,21 @@ export function Header({
|
|||
|
||||
useEffect(() => {
|
||||
const handleOpenSettings = (event: Event) => {
|
||||
const section = (event as CustomEvent<{ section?: string }>).detail?.section;
|
||||
openSettingsDialog(section);
|
||||
const detail = (event as CustomEvent<{ section?: string; control?: string }>).detail;
|
||||
openSettingsDialog(detail?.section, detail?.control);
|
||||
};
|
||||
|
||||
const openSupportLink = () => {
|
||||
const target = parseSettingsSupportHash(window.location.hash);
|
||||
if (target) openSettingsDialog(target.section, target.control);
|
||||
};
|
||||
openSupportLink();
|
||||
window.addEventListener('hashchange', openSupportLink);
|
||||
window.addEventListener('veritas:open-settings', handleOpenSettings);
|
||||
return () => window.removeEventListener('veritas:open-settings', handleOpenSettings);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', openSupportLink);
|
||||
window.removeEventListener('veritas:open-settings', handleOpenSettings);
|
||||
};
|
||||
}, [openSettingsDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -690,9 +702,19 @@ export function Header({
|
|||
open={settingsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSettingsOpen(open);
|
||||
if (!open) setSettingsTab(undefined);
|
||||
if (!open) {
|
||||
setSettingsTab(undefined);
|
||||
setSettingsControl(undefined);
|
||||
if (parseSettingsSupportHash(window.location.hash))
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
defaultTab={settingsTab}
|
||||
defaultControl={settingsControl}
|
||||
/>
|
||||
)}
|
||||
{loadedPanels.has('chat') && <ChatPanel open={chatOpen} onOpenChange={setChatOpen} />}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ import {
|
|||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
import type { ClientAuthPermission } from '@veritas-kanban/shared';
|
||||
import { SettingsActionGroup, SettingsErrorBoundary } from './shared';
|
||||
import { SettingsSearch } from './SettingsSearch';
|
||||
import {
|
||||
SETTINGS_CONTROL_INDEX,
|
||||
SETTINGS_SECTION_DESCRIPTIONS,
|
||||
settingsSupportHash,
|
||||
type SettingsSearchEntry,
|
||||
} from './settings-search-index';
|
||||
import { ErrorBoundary } from '@/components/shared/ErrorBoundary';
|
||||
|
||||
// Lazy-load tab components
|
||||
|
|
@ -250,12 +257,21 @@ interface SettingsDialogProps {
|
|||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultTab?: string;
|
||||
defaultControl?: string;
|
||||
}
|
||||
|
||||
// ============ Main Settings Dialog ============
|
||||
|
||||
export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialogProps) {
|
||||
export function SettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultTab,
|
||||
defaultControl,
|
||||
}: SettingsDialogProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('general');
|
||||
const [pendingFocus, setPendingFocus] = useState<{ section: string; controlId?: string } | null>(
|
||||
null
|
||||
);
|
||||
// Only mount the visible navigation: CSS-hidden controls are still counted
|
||||
// by the focus trap while a lazy tab has no controls of its own.
|
||||
const showSidebar = useMediaQuery('(min-width: 40em)');
|
||||
|
|
@ -267,6 +283,42 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
[hasPermission]
|
||||
);
|
||||
const isBoardOnly = currentSettings.productMode?.selectedMode === 'board-only';
|
||||
const searchEntries = useMemo(() => {
|
||||
const allowed = new Set(TABS.filter(canUseTab).map((tab) => tab.id));
|
||||
const optionalSections = new Set([
|
||||
'agents',
|
||||
'delegation',
|
||||
'scheduler',
|
||||
'queue-monitors',
|
||||
'reflections',
|
||||
'tool-policies',
|
||||
'enforcement',
|
||||
]);
|
||||
const sections: SettingsSearchEntry[] = TABS.filter(canUseTab).map((tab) => ({
|
||||
id: `section-${tab.id}`,
|
||||
section: tab.id,
|
||||
title: tab.label,
|
||||
description: SETTINGS_SECTION_DESCRIPTIONS[tab.id],
|
||||
optionalInBoardOnly: optionalSections.has(tab.id),
|
||||
}));
|
||||
const controls = SETTINGS_CONTROL_INDEX.filter(
|
||||
(entry) =>
|
||||
allowed.has(entry.section as TabId) &&
|
||||
(!entry.requiredPermission || hasPermission(entry.requiredPermission))
|
||||
);
|
||||
return [...controls, ...sections];
|
||||
}, [canUseTab, hasPermission]);
|
||||
|
||||
const selectSearchEntry = useCallback((entry: SettingsSearchEntry) => {
|
||||
setActiveTab(entry.section as TabId);
|
||||
setPendingFocus({ section: entry.section, controlId: entry.controlId });
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}${settingsSupportHash(entry.section, entry.controlId)}`
|
||||
);
|
||||
}, []);
|
||||
|
||||
const mobileTabOptions = useMemo(
|
||||
() =>
|
||||
SETTINGS_NAVIGATION_GROUPS.map((group) => ({
|
||||
|
|
@ -285,8 +337,12 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
const requestedTab = TABS.find((t) => t.id === defaultTab);
|
||||
if (requestedTab && canUseTab(requestedTab)) {
|
||||
setActiveTab(defaultTab as TabId);
|
||||
const target = searchEntries.find(
|
||||
(entry) => entry.section === defaultTab && entry.controlId === defaultControl
|
||||
);
|
||||
setPendingFocus({ section: requestedTab.id, controlId: target?.controlId });
|
||||
}
|
||||
}, [canUseTab, defaultTab]);
|
||||
}, [canUseTab, defaultTab, defaultControl, open, searchEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentTab = TABS.find((tab) => tab.id === activeTab);
|
||||
|
|
@ -316,6 +372,36 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !pendingFocus || !contentAreaRef.current) return;
|
||||
if (pendingFocus.section !== activeTab) {
|
||||
setPendingFocus(null);
|
||||
return;
|
||||
}
|
||||
const root = contentAreaRef.current;
|
||||
const focusTarget = () => {
|
||||
const section = pendingFocus.controlId
|
||||
? document.getElementById(pendingFocus.controlId)
|
||||
: root;
|
||||
if (!section || !root.contains(section)) return false;
|
||||
const target =
|
||||
section.querySelector<HTMLElement>(
|
||||
'input:not(:disabled), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), a[href]'
|
||||
) ?? section;
|
||||
if (target === section) target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: 'nearest' });
|
||||
setPendingFocus(null);
|
||||
return true;
|
||||
};
|
||||
if (focusTarget()) return;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (focusTarget()) observer.disconnect();
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}, [activeTab, open, pendingFocus]);
|
||||
|
||||
const handleExportSettings = () => {
|
||||
const blob = new Blob([JSON.stringify(currentSettings, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -632,7 +718,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
disabled={!allowed}
|
||||
title={
|
||||
allowed
|
||||
? tab.label
|
||||
? `${tab.label}: ${SETTINGS_SECTION_DESCRIPTIONS[tab.id]}`
|
||||
: `${tab.requiredPermission} permission required`
|
||||
}
|
||||
fullWidth
|
||||
|
|
@ -701,6 +787,13 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
<div className="shrink-0 border-b px-4 py-3">
|
||||
<SettingsSearch
|
||||
entries={searchEntries}
|
||||
boardOnly={isBoardOnly}
|
||||
onSelect={selectSearchEntry}
|
||||
/>
|
||||
</div>
|
||||
{!showSidebar && (
|
||||
<div
|
||||
data-settings-mobile-header
|
||||
|
|
|
|||
100
web/src/components/settings/SettingsSearch.tsx
Normal file
100
web/src/components/settings/SettingsSearch.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { TextInput } from '@mantine/core';
|
||||
import { Search } from 'lucide-react';
|
||||
import { searchSettings, type SettingsSearchEntry } from './settings-search-index';
|
||||
|
||||
export function SettingsSearch({
|
||||
entries,
|
||||
boardOnly,
|
||||
onSelect,
|
||||
}: {
|
||||
entries: SettingsSearchEntry[];
|
||||
boardOnly: boolean;
|
||||
onSelect: (entry: SettingsSearchEntry) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState(0);
|
||||
const listId = useId();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const results = searchSettings(entries, query, boardOnly);
|
||||
const active = Math.min(selected, Math.max(results.length - 1, 0));
|
||||
const choose = (entry: SettingsSearchEntry) => {
|
||||
setQuery('');
|
||||
setSelected(0);
|
||||
onSelect(entry);
|
||||
};
|
||||
useEffect(() => {
|
||||
listRef.current?.children[active]?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [active, query, entries]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<TextInput
|
||||
aria-label="Search settings"
|
||||
placeholder="Search settings…"
|
||||
leftSection={<Search size={16} aria-hidden="true" />}
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={results.length > 0}
|
||||
aria-controls={results.length ? listId : undefined}
|
||||
aria-activedescendant={results.length ? `${listId}-${active}` : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value);
|
||||
setSelected(0);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (results.length)
|
||||
setSelected(
|
||||
(active + (event.key === 'ArrowDown' ? 1 : results.length - 1)) % results.length
|
||||
);
|
||||
} else if (event.key === 'Enter' && results[active]) {
|
||||
event.preventDefault();
|
||||
choose(results[active]);
|
||||
} else if (event.key === 'Escape' && query) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setQuery('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{query.trim() && (
|
||||
<div
|
||||
className="absolute top-full inset-x-0 z-20 mt-1 max-h-64 overflow-auto rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role={results.length ? 'listbox' : undefined}
|
||||
aria-label="Matching settings"
|
||||
>
|
||||
{results.length ? (
|
||||
results.map((entry, index) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
id={`${listId}-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === active}
|
||||
tabIndex={-1}
|
||||
className={`block w-full px-3 py-2 text-left ${index === active ? 'bg-muted' : ''}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => choose(entry)}
|
||||
>
|
||||
<span className="block text-sm font-medium">{entry.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{entry.description}
|
||||
{boardOnly && entry.optionalInBoardOnly ? ' · Optional in Board Only' : ''}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div role="status" className="px-3 py-3 text-sm text-muted-foreground">
|
||||
No matching settings. Try a different term.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
web/src/components/settings/settings-search-index.ts
Normal file
128
web/src/components/settings/settings-search-index.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { ClientAuthPermission } from '@veritas-kanban/shared';
|
||||
|
||||
export interface SettingsSearchEntry {
|
||||
id: string;
|
||||
section: string;
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string;
|
||||
controlId?: string;
|
||||
requiredPermission?: ClientAuthPermission;
|
||||
optionalInBoardOnly?: boolean;
|
||||
}
|
||||
|
||||
export const SETTINGS_SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
general: 'Appearance, product mode, display name and defaults',
|
||||
board: 'Columns, card display and board behavior',
|
||||
tasks: 'Task behavior, verification and editing',
|
||||
agents: 'Agent providers, runtime and routing',
|
||||
data: 'Telemetry retention, budgets and archiving',
|
||||
notifications: 'Delivery channels and alerts',
|
||||
'multi-user': 'Workspace members, devices and API access',
|
||||
'workspace-capabilities': 'Workspace capabilities and connections',
|
||||
delegation: 'Agent coordination and delegation rules',
|
||||
scheduler: 'Scheduled workflow execution',
|
||||
'queue-monitors': 'Workflow queue monitoring',
|
||||
reflections: 'Reusable lessons and extraction',
|
||||
trackers: 'External issue tracker connections',
|
||||
security: 'Password changes and account recovery',
|
||||
'tool-policies': 'Tool access and approval policies',
|
||||
enforcement: 'Quality and completion requirements',
|
||||
'shared-resources': 'Shared references and resources',
|
||||
'doc-freshness': 'Documentation freshness checks',
|
||||
maintenance: 'Backups, restore, logs and storage maintenance',
|
||||
manage: 'Manage stored task data and cleanup',
|
||||
};
|
||||
|
||||
export const SETTINGS_CONTROL_INDEX: SettingsSearchEntry[] = [
|
||||
{
|
||||
id: 'theme',
|
||||
section: 'general',
|
||||
title: 'Theme and appearance',
|
||||
description: 'Choose the appearance for this browser or desktop app',
|
||||
keywords: 'dark light system color',
|
||||
controlId: 'general-appearance',
|
||||
},
|
||||
{
|
||||
id: 'product-mode',
|
||||
section: 'general',
|
||||
title: 'Product mode',
|
||||
description: 'Choose which surfaces and shortcuts to emphasize',
|
||||
keywords: 'board only advanced focus preset',
|
||||
controlId: 'general-product-mode',
|
||||
},
|
||||
{
|
||||
id: 'default-agent',
|
||||
section: 'general',
|
||||
title: 'Default agent',
|
||||
description: 'Agent selected when a task does not specify one',
|
||||
keywords: 'default agent provider',
|
||||
controlId: 'general-default-agent',
|
||||
requiredPermission: 'agent:read',
|
||||
optionalInBoardOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
section: 'maintenance',
|
||||
title: 'Backup and restore',
|
||||
description: 'Export or import a SQLite backup bundle',
|
||||
keywords: 'backup restore export import recovery database',
|
||||
controlId: 'maintenance-backup',
|
||||
requiredPermission: 'backup:read',
|
||||
},
|
||||
{
|
||||
id: 'token',
|
||||
section: 'multi-user',
|
||||
title: 'API tokens',
|
||||
description: 'Manage scoped API access for this workspace',
|
||||
keywords: 'token api key credential access',
|
||||
controlId: 'multi-user-api-access',
|
||||
requiredPermission: 'admin:manage',
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
section: 'data',
|
||||
title: 'Archive settings',
|
||||
description: 'Configure task archiving behavior',
|
||||
keywords: 'archive retention',
|
||||
controlId: 'data-archive',
|
||||
requiredPermission: 'backup:read',
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
section: 'tasks',
|
||||
title: 'Markdown editor',
|
||||
description: 'Configure task description editing',
|
||||
keywords: 'markdown editor preview',
|
||||
controlId: 'task-markdown',
|
||||
},
|
||||
];
|
||||
|
||||
export function settingsSupportHash(section: string, controlId?: string): string {
|
||||
return `#settings/${encodeURIComponent(section)}${controlId ? `/${encodeURIComponent(controlId)}` : ''}`;
|
||||
}
|
||||
|
||||
export function parseSettingsSupportHash(
|
||||
hash: string
|
||||
): { section: string; control?: string } | null {
|
||||
const match = /^#settings\/([a-z][a-z-]*)(?:\/([a-z][a-z-]*))?$/.exec(hash);
|
||||
return match ? { section: match[1], control: match[2] } : null;
|
||||
}
|
||||
|
||||
export function searchSettings(
|
||||
entries: SettingsSearchEntry[],
|
||||
query: string,
|
||||
boardOnly: boolean
|
||||
): SettingsSearchEntry[] {
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return [];
|
||||
return entries
|
||||
.filter((entry) => {
|
||||
const haystack = `${entry.title} ${entry.description} ${entry.keywords ?? ''}`.toLowerCase();
|
||||
return words.every((word) => haystack.includes(word));
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(boardOnly && !!a.optionalInBoardOnly) - Number(boardOnly && !!b.optionalInBoardOnly)
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue