This commit is contained in:
+65
-7
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { LocalTask, type Visibility } from './api';
|
||||
import { useUrlState } from './hooks/useUrlState';
|
||||
@@ -41,6 +41,10 @@ import { UserFolderTab } from './components/userfolder/UserFolderTab';
|
||||
import { HelpPage } from './pages/HelpPage';
|
||||
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
|
||||
import type { ConsoleStatus } from './lib/ssh-console-types';
|
||||
import { CommandPalette } from './components/command/CommandPalette';
|
||||
import { shouldOpenForKeyEvent, type CommandContext } from './lib/command-palette';
|
||||
import { setThemePref } from './lib/theme';
|
||||
import { shouldAutoFocus } from './lib/live-workspace';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -135,6 +139,23 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
'ui.focusedChatPx',
|
||||
null,
|
||||
);
|
||||
// Live workspace: auto-enter focused layout while a browser/ssh tab is open.
|
||||
// A manual toggle/exit wins for the current view via `override`, which is
|
||||
// KEYED to the current (tab,task): when the key changes the stale override is
|
||||
// ignored at derivation time (no effect), so re-entering a live tab
|
||||
// re-auto-focuses immediately with no stale-frame flicker.
|
||||
const [override, setOverride] = useState<{ key: string; value: boolean } | null>(null);
|
||||
const overrideKey = `${detailTab}:${localTaskId ?? ''}`;
|
||||
// Key-match at derivation time = the leaving frame is immediately correct
|
||||
// (no stale flash). The effect then clears the stale override so RETURNING to
|
||||
// the same (tab,task) later re-auto-focuses instead of re-applying it.
|
||||
const activeOverride = override && override.key === overrideKey ? override.value : null;
|
||||
const isFocused =
|
||||
activeOverride ?? (shouldAutoFocus(detailTab, localTaskId != null) || detailWidth === 'focused');
|
||||
|
||||
useEffect(() => {
|
||||
setOverride((o) => (o && o.key === overrideKey ? o : null));
|
||||
}, [overrideKey]);
|
||||
const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
|
||||
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -148,7 +169,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
|
||||
// Shared navigation handler used by both TopBar and NavDrawer.
|
||||
// Guards against discarding unsaved edits before switching pages.
|
||||
const handleNavigatePage = (p: PageId) => {
|
||||
const handleNavigatePage = useCallback((p: PageId) => {
|
||||
if (p === page) {
|
||||
setNavDrawerOpen(false);
|
||||
return;
|
||||
@@ -156,7 +177,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
if (!confirmDiscardUnsaved()) return;
|
||||
setUrlState(prev => ({ ...prev, page: p }));
|
||||
setNavDrawerOpen(false);
|
||||
};
|
||||
}, [page, setUrlState]);
|
||||
|
||||
const edgeSwipe = useEdgeSwipe({
|
||||
enabled: compactMode && !navDrawerOpen,
|
||||
@@ -189,6 +210,38 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
// its own when mounted.
|
||||
const localTasksQuery = useLocalTaskList();
|
||||
|
||||
// ⌘K command palette
|
||||
const [cmdkOpen, setCmdkOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const el = document.activeElement as Element | null;
|
||||
const inTerminal = !!el?.closest('.xterm');
|
||||
// Monaco / contenteditable bind ⌘K/Ctrl-K as chord prefixes — don't steal.
|
||||
const inEditor = !!el?.closest('.monaco-editor, [contenteditable="true"]');
|
||||
if (shouldOpenForKeyEvent(e, { inTerminal, inEditor })) {
|
||||
e.preventDefault();
|
||||
setCmdkOpen((o) => !o);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, []);
|
||||
|
||||
const cmdkCtx: CommandContext = useMemo(() => ({
|
||||
navigatePage: handleNavigatePage,
|
||||
openTask: (id: number) => {
|
||||
// Opening a task can change page (e.g. from settings) → run the same
|
||||
// unsaved-changes guard handleNavigatePage uses, so we don't silently
|
||||
// discard an in-progress edit.
|
||||
if (!confirmDiscardUnsaved()) return;
|
||||
setUrlState((prev) => ({ ...prev, page: 'tasks', taskId: id }));
|
||||
},
|
||||
setTheme: setThemePref,
|
||||
navItems: visibleNav.map((n) => ({ id: n.id, label: n.label })),
|
||||
tasks: (localTasksQuery.data ?? []).map((t) => ({ id: t.id, title: t.title })),
|
||||
}), [handleNavigatePage, setUrlState, visibleNav, localTasksQuery.data]);
|
||||
|
||||
// ブラウザ通知設定 (localStorage) — 設定 UI は NotificationsForm が管理
|
||||
const [notifyEnabled] = useLocalStorageState<boolean>('notify.enabled', true);
|
||||
const [notifyEvents] = useLocalStorageState<NotifyEventSettings>(
|
||||
@@ -315,10 +368,14 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
pathSegments: fileBrowser.pathSegments,
|
||||
loading: localTaskQuery.isLoading,
|
||||
detailTab: overrides?.detailTab ?? detailTab,
|
||||
detailWidth,
|
||||
detailWidth: (isFocused ? 'focused' : 'normal') as 'normal' | 'focused',
|
||||
showWidthToggle: overrides?.showWidthToggle ?? true,
|
||||
onTabChange: overrides?.onTabChange ?? (t => setUrlState(prev => ({ ...prev, detailTab: t }))),
|
||||
onWidthToggle: () => setDetailWidth(prev => prev === 'focused' ? 'normal' : 'focused'),
|
||||
onWidthToggle: () => {
|
||||
const next = !isFocused;
|
||||
setOverride({ key: overrideKey, value: next });
|
||||
setDetailWidth(next ? 'focused' : 'normal');
|
||||
},
|
||||
onClose: overrides?.onClose ?? (() => setUrlState(prev => ({ ...prev, taskId: null, detailTab: 'overview' }))),
|
||||
onDelete: handleDelete,
|
||||
onSectionChange: fileBrowser.setSection,
|
||||
@@ -335,7 +392,6 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
// Layout calculation
|
||||
const sidebarWidth = 'clamp(240px, 22vw, 280px)';
|
||||
const detailPanelWidth = 'clamp(280px, 26vw, 440px)'; // normal mode 時のみ使用
|
||||
const isFocused = detailWidth === 'focused';
|
||||
const RAIL_PX = 48;
|
||||
const HANDLE_PX = 4;
|
||||
const MIN_CHAT_PX = 280;
|
||||
@@ -384,6 +440,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
onOpenDrawer={openNavDrawer}
|
||||
hamburgerButtonRef={hamburgerRef}
|
||||
navDrawerOpen={navDrawerOpen}
|
||||
onOpenCommandK={() => setCmdkOpen(true)}
|
||||
/>
|
||||
|
||||
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
||||
@@ -521,7 +578,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
? <TaskListPanel
|
||||
{...taskListProps}
|
||||
mode="rail"
|
||||
onExitFocused={() => setDetailWidth('normal')}
|
||||
onExitFocused={() => { setOverride({ key: overrideKey, value: false }); setDetailWidth('normal'); }}
|
||||
/>
|
||||
: <div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} mode="list" /></div>
|
||||
}
|
||||
@@ -616,6 +673,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
logoUrl={branding.logoUrl}
|
||||
returnFocusRef={hamburgerRef}
|
||||
/>
|
||||
<CommandPalette open={cmdkOpen} onClose={() => setCmdkOpen(false)} ctx={cmdkCtx} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildCommands, filterCommands, groupCommands,
|
||||
type CommandContext, type CommandItem,
|
||||
} from '../../lib/command-palette';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
ctx: CommandContext;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose, ctx }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const openerRef = useRef<Element | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
|
||||
const allCommands = useMemo(() => buildCommands(ctx), [ctx]);
|
||||
const results = useMemo(() => filterCommands(allCommands, query), [allCommands, query]);
|
||||
const groups = useMemo(() => groupCommands(results), [results]);
|
||||
|
||||
useEffect(() => {
|
||||
const dlg = dialogRef.current;
|
||||
if (!dlg) return;
|
||||
if (open && !dlg.open) {
|
||||
openerRef.current = document.activeElement;
|
||||
setQuery('');
|
||||
setHighlight(0);
|
||||
dlg.showModal();
|
||||
inputRef.current?.focus();
|
||||
} else if (!open && dlg.open) {
|
||||
dlg.close();
|
||||
const o = openerRef.current;
|
||||
if (o instanceof HTMLElement && o.isConnected) o.focus();
|
||||
else document.body.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dlg = dialogRef.current;
|
||||
if (!dlg) return;
|
||||
const onCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||
const onClick = (e: MouseEvent) => { if (e.target === dlg) onClose(); };
|
||||
dlg.addEventListener('cancel', onCancel);
|
||||
dlg.addEventListener('click', onClick);
|
||||
return () => { dlg.removeEventListener('cancel', onCancel); dlg.removeEventListener('click', onClick); };
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => { setHighlight(0); }, [query]);
|
||||
|
||||
const flat = results;
|
||||
const highlightedId = flat[highlight]?.id;
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, flat.length - 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const item = flat[highlight];
|
||||
if (item) { item.run(); onClose(); }
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedId) return;
|
||||
document.getElementById(`cmdk-opt-${highlightedId}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightedId]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="コマンドパレット"
|
||||
className="m-0 mt-[12vh] mx-auto w-[min(560px,92vw)] rounded-xl border border-hairline bg-surface text-ink shadow-2xl p-0 backdrop:bg-black/40"
|
||||
>
|
||||
<div className="p-2 border-b border-hairline">
|
||||
<input
|
||||
ref={inputRef}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="cmdk-listbox"
|
||||
aria-label="コマンド・タスクを検索"
|
||||
aria-activedescendant={highlightedId ? `cmdk-opt-${highlightedId}` : undefined}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="コマンド・タスクを検索…"
|
||||
className="w-full h-9 px-2 bg-transparent outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div id="cmdk-listbox" role="listbox" className="max-h-[52vh] overflow-y-auto p-1">
|
||||
{flat.length === 0 && <div role="status" className="px-3 py-6 text-center text-sm text-muted">該当なし</div>}
|
||||
{groups.map((g) => (
|
||||
<div key={g.group} role="group" aria-label={g.label}>
|
||||
<div className="section-label px-2 pt-2 pb-1">{g.label}</div>
|
||||
{g.items.map((item: CommandItem) => {
|
||||
const active = item.id === highlightedId;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
id={`cmdk-opt-${item.id}`}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseMove={() => setHighlight(flat.indexOf(item))}
|
||||
onClick={() => { item.run(); onClose(); }}
|
||||
className={`flex items-center justify-between gap-2 px-2.5 h-9 rounded-md cursor-pointer text-sm ${
|
||||
active ? 'bg-surface-2 text-ink' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.hint && <span className="text-2xs text-muted flex-shrink-0">{item.hint}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { type ThemePref, readStoredTheme, setThemePref } from '../../lib/theme';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { type ThemePref, isThemePref, readStoredTheme, setThemePref, THEME_CHANGE_EVENT } from '../../lib/theme';
|
||||
|
||||
const ICON_PROPS = {
|
||||
width: 14,
|
||||
@@ -56,6 +56,17 @@ const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
|
||||
export function ThemeToggle() {
|
||||
const [pref, setPref] = useState<ThemePref>(() => readStoredTheme());
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (e: Event) => {
|
||||
// Prefer the pref carried in the event (works even if localStorage write
|
||||
// failed); fall back to stored value for plain Event dispatches.
|
||||
const detail = (e as CustomEvent<unknown>).detail;
|
||||
setPref(isThemePref(detail) ? detail : readStoredTheme());
|
||||
};
|
||||
window.addEventListener(THEME_CHANGE_EVENT, sync);
|
||||
return () => window.removeEventListener(THEME_CHANGE_EVENT, sync);
|
||||
}, []);
|
||||
|
||||
const choose = (value: ThemePref) => {
|
||||
setPref(value);
|
||||
setThemePref(value);
|
||||
|
||||
@@ -14,6 +14,7 @@ interface TopBarProps {
|
||||
onOpenDrawer: () => void;
|
||||
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||
navDrawerOpen?: boolean;
|
||||
onOpenCommandK?: () => void;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
||||
@@ -69,6 +70,7 @@ export function TopBar({
|
||||
onOpenDrawer,
|
||||
hamburgerButtonRef,
|
||||
navDrawerOpen = false,
|
||||
onOpenCommandK,
|
||||
}: TopBarProps) {
|
||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
|
||||
@@ -138,6 +140,17 @@ export function TopBar({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenCommandK && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCommandK}
|
||||
aria-label="コマンドパレットを開く"
|
||||
title="コマンドパレット (⌘K)"
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2 h-7 rounded-md border border-hairline text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
|
||||
>
|
||||
<span aria-hidden>⌘K</span>
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
{user && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { buildCommands, filterCommands, groupCommands, shouldOpenForKeyEvent, type CommandContext } from './command-palette';
|
||||
|
||||
function ctx(over: Partial<CommandContext> = {}): CommandContext {
|
||||
return {
|
||||
navigatePage: vi.fn(), openTask: vi.fn(), setTheme: vi.fn(),
|
||||
navItems: [{ id: 'tasks', label: 'タスク' }, { id: 'settings', label: '設定' }],
|
||||
tasks: [{ id: 407, title: 'PR 407 fix' }, { id: 12, title: 'UI resize' }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildCommands', () => {
|
||||
it('emits nav + 3 theme + task commands with correct ids/keywords', () => {
|
||||
const items = buildCommands(ctx());
|
||||
expect(items.filter(i => i.id.startsWith('nav:')).length).toBe(2);
|
||||
expect(items.filter(i => i.id.startsWith('theme:')).length).toBe(3);
|
||||
const task = items.find(i => i.id === 'task:407')!;
|
||||
expect(task.group).toBe('task');
|
||||
expect(task.keywords).toBe('#407');
|
||||
expect(task.hint).toBe('#407');
|
||||
});
|
||||
it('run() invokes the right context fn', () => {
|
||||
const c = ctx();
|
||||
const items = buildCommands(c);
|
||||
items.find(i => i.id === 'nav:settings')!.run();
|
||||
expect(c.navigatePage).toHaveBeenCalledWith('settings');
|
||||
items.find(i => i.id === 'theme:dark')!.run();
|
||||
expect(c.setTheme).toHaveBeenCalledWith('dark');
|
||||
items.find(i => i.id === 'task:407')!.run();
|
||||
expect(c.openTask).toHaveBeenCalledWith(407);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCommands', () => {
|
||||
it('empty query: all nav/theme + first 5 tasks', () => {
|
||||
const many = ctx({ tasks: Array.from({ length: 9 }, (_, i) => ({ id: i + 1, title: `t${i + 1}` })) });
|
||||
const out = filterCommands(buildCommands(many), '');
|
||||
expect(out.filter(i => i.group === 'task').length).toBe(5);
|
||||
expect(out.filter(i => i.group === 'nav').length).toBe(2 + 3);
|
||||
});
|
||||
it('matches label and #id, prefix ranks first', () => {
|
||||
const out = filterCommands(buildCommands(ctx()), '設定');
|
||||
expect(out[0].id).toBe('nav:settings');
|
||||
const byId = filterCommands(buildCommands(ctx()), '#407');
|
||||
expect(byId.some(i => i.id === 'task:407')).toBe(true);
|
||||
});
|
||||
it('no match → empty', () => {
|
||||
expect(filterCommands(buildCommands(ctx()), 'zzzzz')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupCommands', () => {
|
||||
it('orders nav before task and drops empty groups', () => {
|
||||
const groups = groupCommands(filterCommands(buildCommands(ctx()), ''));
|
||||
expect(groups.map(g => g.group)).toEqual(['nav', 'task']);
|
||||
const navOnly = groupCommands(filterCommands(buildCommands(ctx()), '設定'));
|
||||
expect(navOnly.map(g => g.group)).toEqual(['nav']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldOpenForKeyEvent', () => {
|
||||
const base = { metaKey: false, ctrlKey: false, key: 'k', isComposing: false };
|
||||
it('opens on meta+k and ctrl+k', () => {
|
||||
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: false, inEditor: false })).toBe(true);
|
||||
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: false, inEditor: false })).toBe(true);
|
||||
});
|
||||
it('ignores IME composition and non-k and bare k', () => {
|
||||
expect(shouldOpenForKeyEvent({ ...base, metaKey: true, isComposing: true }, { inTerminal: false, inEditor: false })).toBe(false);
|
||||
expect(shouldOpenForKeyEvent({ ...base, metaKey: true, key: 'j' }, { inTerminal: false, inEditor: false })).toBe(false);
|
||||
expect(shouldOpenForKeyEvent({ ...base, key: 'k' }, { inTerminal: false, inEditor: false })).toBe(false);
|
||||
});
|
||||
it('does not steal ctrl+k from a focused terminal, but meta+k still works', () => {
|
||||
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: true, inEditor: false })).toBe(false);
|
||||
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: true, inEditor: false })).toBe(true);
|
||||
});
|
||||
it('never steals from a rich editor (Monaco/contenteditable): both combos blocked', () => {
|
||||
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: false, inEditor: true })).toBe(false);
|
||||
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: false, inEditor: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { PageId } from './urlState';
|
||||
import type { ThemePref } from './theme';
|
||||
|
||||
export type CommandGroup = 'nav' | 'task';
|
||||
|
||||
export interface CommandItem {
|
||||
id: string;
|
||||
group: CommandGroup;
|
||||
label: string;
|
||||
keywords?: string;
|
||||
hint?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export interface CommandContext {
|
||||
navigatePage: (p: PageId) => void;
|
||||
openTask: (id: number) => void;
|
||||
setTheme: (pref: ThemePref) => void;
|
||||
navItems: Array<{ id: PageId; label: string }>;
|
||||
tasks: Array<{ id: number; title: string }>;
|
||||
}
|
||||
|
||||
const THEMES: Array<{ pref: ThemePref; label: string }> = [
|
||||
{ pref: 'system', label: 'テーマ: システム' },
|
||||
{ pref: 'light', label: 'テーマ: ライト' },
|
||||
{ pref: 'dark', label: 'テーマ: ダーク' },
|
||||
];
|
||||
|
||||
const EMPTY_TASK_LIMIT = 5;
|
||||
|
||||
export function buildCommands(ctx: CommandContext): CommandItem[] {
|
||||
const nav: CommandItem[] = ctx.navItems.map((n) => ({
|
||||
id: `nav:${n.id}`, group: 'nav', label: n.label, run: () => ctx.navigatePage(n.id),
|
||||
}));
|
||||
const theme: CommandItem[] = THEMES.map((t) => ({
|
||||
id: `theme:${t.pref}`, group: 'nav', label: t.label, run: () => ctx.setTheme(t.pref),
|
||||
}));
|
||||
const tasks: CommandItem[] = ctx.tasks.map((t) => ({
|
||||
id: `task:${t.id}`, group: 'task', label: t.title, keywords: `#${t.id}`, hint: `#${t.id}`,
|
||||
run: () => ctx.openTask(t.id),
|
||||
}));
|
||||
return [...nav, ...theme, ...tasks];
|
||||
}
|
||||
|
||||
function score(hay: string, q: string): number {
|
||||
const i = hay.indexOf(q);
|
||||
if (i === -1) return 0;
|
||||
if (i === 0) return 3;
|
||||
if (hay.includes(' ' + q)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function filterCommands(items: CommandItem[], query: string): CommandItem[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
const nav = items.filter((i) => i.group === 'nav');
|
||||
const tasks = items.filter((i) => i.group === 'task').slice(0, EMPTY_TASK_LIMIT);
|
||||
return [...nav, ...tasks];
|
||||
}
|
||||
return items
|
||||
.map((i) => ({ i, s: score(`${i.label} ${i.keywords ?? ''}`.toLowerCase(), q) }))
|
||||
.filter((x) => x.s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map((x) => x.i);
|
||||
}
|
||||
|
||||
export function groupCommands(
|
||||
items: CommandItem[],
|
||||
): Array<{ group: CommandGroup; label: string; items: CommandItem[] }> {
|
||||
const order: Array<{ group: CommandGroup; label: string }> = [
|
||||
{ group: 'nav', label: 'ナビゲーション' },
|
||||
{ group: 'task', label: 'タスク' },
|
||||
];
|
||||
return order
|
||||
.map((g) => ({ ...g, items: items.filter((i) => i.group === g.group) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
export function shouldOpenForKeyEvent(
|
||||
e: { metaKey: boolean; ctrlKey: boolean; key: string; isComposing?: boolean },
|
||||
opts: { inTerminal: boolean; inEditor: boolean },
|
||||
): boolean {
|
||||
if (e.isComposing) return false;
|
||||
if (e.key.toLowerCase() !== 'k') return false;
|
||||
if (!(e.metaKey || e.ctrlKey)) return false;
|
||||
// Rich editors bind ⌘K/Ctrl-K as a chord prefix (Monaco, contenteditable):
|
||||
// never steal either combo from them.
|
||||
if (opts.inEditor) return false;
|
||||
// Terminal: only Ctrl-K is the shell binding (kill-line); ⌘K is free to open.
|
||||
if (e.ctrlKey && !e.metaKey && opts.inTerminal) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shouldAutoFocus } from './live-workspace';
|
||||
|
||||
describe('shouldAutoFocus', () => {
|
||||
it('true only for browser/ssh tabs with a selected task', () => {
|
||||
expect(shouldAutoFocus('browser', true)).toBe(true);
|
||||
expect(shouldAutoFocus('ssh', true)).toBe(true);
|
||||
});
|
||||
it('false for non-live tabs', () => {
|
||||
expect(shouldAutoFocus('overview', true)).toBe(false);
|
||||
expect(shouldAutoFocus('files', true)).toBe(false);
|
||||
expect(shouldAutoFocus('trace', true)).toBe(false);
|
||||
expect(shouldAutoFocus('activity', true)).toBe(false);
|
||||
});
|
||||
it('false when no task is selected', () => {
|
||||
expect(shouldAutoFocus('browser', false)).toBe(false);
|
||||
expect(shouldAutoFocus('ssh', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DetailTabId } from './urlState';
|
||||
|
||||
/**
|
||||
* Whether the live-workspace auto-focus should be active: only when a task is
|
||||
* selected AND the active detail tab is a live view (the AI browser or SSH
|
||||
* console), which benefit from the wider focused layout.
|
||||
*/
|
||||
export function shouldAutoFocus(detailTab: DetailTabId, hasTask: boolean): boolean {
|
||||
return hasTask && (detailTab === 'browser' || detailTab === 'ssh');
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export function systemPrefersDark(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia(DARK_MQ).matches;
|
||||
}
|
||||
|
||||
export const THEME_CHANGE_EVENT = 'maestro:theme-pref-changed';
|
||||
|
||||
/**
|
||||
* Persist a new preference and apply it to the document immediately. Used by
|
||||
* the theme toggle UI. The pure resolution lives in resolveTheme.
|
||||
@@ -50,6 +52,14 @@ export function systemPrefersDark(): boolean {
|
||||
export function setThemePref(pref: ThemePref): void {
|
||||
writeStoredTheme(pref);
|
||||
applyTheme(document.documentElement, resolveTheme(pref, systemPrefersDark()));
|
||||
try {
|
||||
// Carry the chosen pref in the event so listeners reflect it even if the
|
||||
// localStorage write above failed (private mode) — re-reading storage
|
||||
// would otherwise fall back to 'system' and desync the toggle.
|
||||
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: pref }));
|
||||
} catch {
|
||||
/* non-browser context */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user