mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-07 22:09:15 -05:00
Compare commits
2 Commits
main
...
1password-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6421bff3e1 | ||
|
|
f81aee227b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,6 +37,3 @@ tmp
|
||||
.zed
|
||||
codebook.toml
|
||||
target
|
||||
|
||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||
src-tauri/tauri.worktree.conf.json
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -1575,9 +1575,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
|
||||
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
@@ -18483,7 +18483,7 @@
|
||||
"dependencies": {
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"hono": "^4.11.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"dependencies": {
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"hono": "^4.11.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -98,22 +98,14 @@ YAAK_DEV_PORT=${maxDevPort}
|
||||
|
||||
# MCP Server port (main worktree uses 64343)
|
||||
YAAK_PLUGIN_MCP_SERVER_PORT=${maxMcpPort}
|
||||
|
||||
# Database path prefix for worktree isolation
|
||||
YAAK_DB_PATH_PREFIX=worktrees/${worktreeName}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(envLocalPath, envContent, 'utf8');
|
||||
console.log(`Created .env.local with YAAK_DEV_PORT=${maxDevPort} and YAAK_PLUGIN_MCP_SERVER_PORT=${maxMcpPort}`);
|
||||
|
||||
// Create tauri.worktree.conf.json with unique app identifier for complete isolation
|
||||
// This gives each worktree its own app data directory, avoiding the need for DB path prefixes
|
||||
const tauriWorktreeConfig = {
|
||||
identifier: `app.yaak.desktop.dev.${worktreeName}`,
|
||||
productName: `Daak (${worktreeName})`
|
||||
};
|
||||
|
||||
const tauriConfigPath = path.join(process.cwd(), 'src-tauri', 'tauri.worktree.conf.json');
|
||||
fs.writeFileSync(tauriConfigPath, JSON.stringify(tauriWorktreeConfig, null, 2) + '\n', 'utf8');
|
||||
console.log(`Created tauri.worktree.conf.json with identifier: ${tauriWorktreeConfig.identifier}`);
|
||||
|
||||
// Copy gitignored editor config folders from main worktree (.zed, .vscode, .claude, etc.)
|
||||
// This ensures your editor settings, tasks, and configurations are available in the new worktree
|
||||
// without needing to manually copy them or commit them to git.
|
||||
@@ -156,4 +148,12 @@ try {
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
console.log('\n✓ Worktree setup complete! Run `npm run init` to install dependencies.');
|
||||
// Run npm run init to install dependencies and bootstrap
|
||||
console.log('\nRunning npm run init to install dependencies and bootstrap...');
|
||||
try {
|
||||
execSync('npm run init', { stdio: 'inherit' });
|
||||
console.log('\n✓ Worktree setup complete!');
|
||||
} catch (err) {
|
||||
console.error('\n✗ Failed to run npm run init. You may need to run it manually.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,17 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
let app_path = app_handle.path().app_data_dir().unwrap();
|
||||
create_dir_all(app_path.clone()).expect("Problem creating App directory!");
|
||||
|
||||
let db_file_path = app_path.join("db.sqlite");
|
||||
let blob_db_file_path = app_path.join("blobs.sqlite");
|
||||
// Support per-worktree databases via YAAK_DB_PATH_PREFIX env var
|
||||
let db_dir = match std::env::var("YAAK_DB_PATH_PREFIX") {
|
||||
Ok(prefix) if !prefix.is_empty() => {
|
||||
let dir = app_path.join(prefix);
|
||||
create_dir_all(&dir).expect("Problem creating DB directory!");
|
||||
dir
|
||||
}
|
||||
_ => app_path.clone(),
|
||||
};
|
||||
let db_file_path = db_dir.join("db.sqlite");
|
||||
let blob_db_file_path = db_dir.join("blobs.sqlite");
|
||||
|
||||
// Main database pool
|
||||
let manager = SqliteConnectionManager::file(db_file_path);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { fuzzyMatch } from 'fuzzbunny';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
defaultHotkeys,
|
||||
formatHotkeyString,
|
||||
@@ -20,19 +19,11 @@ import { Heading } from '../core/Heading';
|
||||
import { HotkeyRaw } from '../core/Hotkey';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
|
||||
|
||||
const HOLD_KEYS = ['Shift', 'Control', 'Alt', 'Meta'];
|
||||
const LAYOUT_INSENSITIVE_KEYS = [
|
||||
'Equal',
|
||||
'Minus',
|
||||
'BracketLeft',
|
||||
'BracketRight',
|
||||
'Backquote',
|
||||
'Space',
|
||||
];
|
||||
const LAYOUT_INSENSITIVE_KEYS = ['Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote'];
|
||||
|
||||
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
|
||||
function eventToHotkeyString(e: KeyboardEvent): string | null {
|
||||
@@ -67,19 +58,6 @@ function eventToHotkeyString(e: KeyboardEvent): string | null {
|
||||
export function SettingsHotkeys() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredActions = useMemo(() => {
|
||||
if (!filter.trim()) {
|
||||
return hotkeyActions;
|
||||
}
|
||||
return hotkeyActions.filter((action) => {
|
||||
const scope = getHotkeyScope(action).replace(/_/g, ' ');
|
||||
const label = action.replace(/[_.]/g, ' ');
|
||||
const searchText = `${scope} ${label}`;
|
||||
return fuzzyMatch(searchText, filter) != null;
|
||||
});
|
||||
}, [filter]);
|
||||
|
||||
if (settings == null) {
|
||||
return null;
|
||||
@@ -93,14 +71,6 @@ export function SettingsHotkeys() {
|
||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -110,9 +80,8 @@ export function SettingsHotkeys() {
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<TableBody>
|
||||
{hotkeyActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { ErrorBoundary } from '../ErrorBoundary';
|
||||
import { Overlay } from '../Overlay';
|
||||
import { Button } from './Button';
|
||||
import { Hotkey } from './Hotkey';
|
||||
import { Icon, type IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { LoadingIcon } from './LoadingIcon';
|
||||
import { Separator } from './Separator';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
@@ -65,8 +65,6 @@ export type DropdownItemDefault = {
|
||||
waitForOnSelect?: boolean;
|
||||
keepOpenOnSelect?: boolean;
|
||||
onSelect?: () => void | Promise<void>;
|
||||
submenu?: DropdownItem[];
|
||||
icon?: IconProps['icon'];
|
||||
};
|
||||
|
||||
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
|
||||
@@ -277,11 +275,10 @@ interface MenuProps {
|
||||
isOpen: boolean;
|
||||
items: DropdownItem[];
|
||||
triggerRef?: RefObject<HTMLButtonElement | null>;
|
||||
isSubmenu?: boolean;
|
||||
}
|
||||
|
||||
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'>, MenuProps>(
|
||||
(
|
||||
function Menu(
|
||||
{
|
||||
className,
|
||||
isOpen,
|
||||
@@ -292,24 +289,14 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
defaultSelectedIndex,
|
||||
showTriangle,
|
||||
triggerRef,
|
||||
isSubmenu,
|
||||
}: MenuProps,
|
||||
ref,
|
||||
) => {
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useStateWithDeps<number | null>(
|
||||
defaultSelectedIndex ?? -1,
|
||||
[defaultSelectedIndex],
|
||||
);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<{
|
||||
item: DropdownItemDefault;
|
||||
parent: HTMLButtonElement;
|
||||
viaKeyboard?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const mousePosition = useRef({ x: 0, y: 0 });
|
||||
const submenuTimeoutRef = useRef<number | null>(null);
|
||||
const submenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can
|
||||
// have access to the latest value.
|
||||
@@ -321,14 +308,10 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
setFilter('');
|
||||
setActiveSubmenu(null);
|
||||
}, [onClose]);
|
||||
|
||||
// Handle type-ahead filtering (only for the deepest open menu)
|
||||
// Close menu on space bar
|
||||
const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
// Skip if this menu has a submenu open - let the submenu handle typing
|
||||
if (activeSubmenu) return;
|
||||
|
||||
const isCharacter = e.key.length === 1;
|
||||
const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
|
||||
if (isCharacter && !isSpecial) {
|
||||
@@ -345,12 +328,11 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
'Escape',
|
||||
() => {
|
||||
if (!isOpen) return;
|
||||
if (activeSubmenu) setActiveSubmenu(null);
|
||||
else if (filter !== '') setFilter('');
|
||||
if (filter !== '') setFilter('');
|
||||
else handleClose();
|
||||
},
|
||||
{},
|
||||
[isOpen, filter, setFilter, handleClose, activeSubmenu],
|
||||
[isOpen, filter, setFilter, handleClose],
|
||||
);
|
||||
|
||||
const handlePrev = useCallback(
|
||||
@@ -396,40 +378,23 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
useKey(
|
||||
'ArrowUp',
|
||||
(e) => {
|
||||
if (!isOpen || activeSubmenu) return;
|
||||
if (!isOpen) return;
|
||||
e.preventDefault();
|
||||
handlePrev();
|
||||
},
|
||||
{},
|
||||
[isOpen, activeSubmenu],
|
||||
[isOpen],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowDown',
|
||||
(e) => {
|
||||
if (!isOpen || activeSubmenu) return;
|
||||
if (!isOpen) return;
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
},
|
||||
{},
|
||||
[isOpen, activeSubmenu],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowLeft',
|
||||
(e) => {
|
||||
if (!isOpen) return;
|
||||
// Only handle if this menu doesn't have an open submenu
|
||||
// (let the deepest submenu handle the key first)
|
||||
if (activeSubmenu) return;
|
||||
// If this is a submenu, ArrowLeft closes it and returns to parent
|
||||
if (isSubmenu) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{},
|
||||
[isOpen, isSubmenu, activeSubmenu, onClose],
|
||||
[isOpen],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
@@ -472,26 +437,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
}>(() => {
|
||||
if (triggerShape == null) return { container: {}, triangle: {}, menu: {}, upsideDown: false };
|
||||
|
||||
if (isSubmenu) {
|
||||
const parentRect = triggerShape;
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
const spaceRight = docRect.width - parentRect.right;
|
||||
const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
|
||||
|
||||
return {
|
||||
upsideDown: false,
|
||||
container: {
|
||||
top: parentRect.top,
|
||||
left: openLeft ? undefined : parentRect.right,
|
||||
right: openLeft ? docRect.width - parentRect.left : undefined,
|
||||
},
|
||||
menu: {
|
||||
maxHeight: `${docRect.height - parentRect.top - 20}px`,
|
||||
},
|
||||
triangle: {}, // No triangle for submenus
|
||||
};
|
||||
}
|
||||
|
||||
const menuMarginY = 5;
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
const width = triggerShape.right - triggerShape.left;
|
||||
@@ -528,7 +473,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,
|
||||
},
|
||||
};
|
||||
}, [fullWidth, items.length, triggerShape, isSubmenu]);
|
||||
}, [fullWidth, items.length, triggerShape]);
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),
|
||||
@@ -543,237 +488,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
[filteredItems, setSelectedIndex],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowRight',
|
||||
(e) => {
|
||||
if (!isOpen || activeSubmenu) return;
|
||||
const item = filteredItems[selectedIndex ?? -1];
|
||||
if (item?.type !== 'separator' && item?.type !== 'content' && item?.submenu) {
|
||||
e.preventDefault();
|
||||
const parent = document.activeElement as HTMLButtonElement;
|
||||
if (parent) {
|
||||
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
{},
|
||||
[isOpen, activeSubmenu, filteredItems, selectedIndex],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'Enter',
|
||||
(e) => {
|
||||
if (!isOpen || activeSubmenu) return;
|
||||
const item = filteredItems[selectedIndex ?? -1];
|
||||
if (!item || item.type === 'separator' || item.type === 'content') return;
|
||||
e.preventDefault();
|
||||
if (item.submenu) {
|
||||
const parent = document.activeElement as HTMLButtonElement;
|
||||
if (parent) {
|
||||
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
||||
}
|
||||
} else if (item.onSelect) {
|
||||
handleSelect(item);
|
||||
}
|
||||
},
|
||||
{},
|
||||
[isOpen, activeSubmenu, filteredItems, selectedIndex, handleSelect],
|
||||
);
|
||||
|
||||
const handleItemHover = useCallback(
|
||||
(item: DropdownItemDefault, parent: HTMLButtonElement) => {
|
||||
if (submenuTimeoutRef.current) {
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (item.submenu) {
|
||||
setActiveSubmenu({ item, parent });
|
||||
} else if (activeSubmenu) {
|
||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||
const submenuEl = submenuRef.current;
|
||||
if (!submenuEl || !activeSubmenu) {
|
||||
setActiveSubmenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { parent } = activeSubmenu;
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const submenuRect = submenuEl.getBoundingClientRect();
|
||||
const mouse = mousePosition.current;
|
||||
|
||||
if (
|
||||
mouse.x >= submenuRect.left &&
|
||||
mouse.x <= submenuRect.right &&
|
||||
mouse.y >= submenuRect.top &&
|
||||
mouse.y <= submenuRect.bottom
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tolerance = 5;
|
||||
const p1 = { x: parentRect.right, y: parentRect.top - tolerance };
|
||||
const p2 = { x: parentRect.right, y: parentRect.bottom + tolerance };
|
||||
const p3 = { x: submenuRect.left, y: submenuRect.top - tolerance };
|
||||
const p4 = { x: submenuRect.left, y: submenuRect.bottom + tolerance };
|
||||
|
||||
const inTriangle =
|
||||
isPointInTriangle(mouse, p1, p2, p4) || isPointInTriangle(mouse, p1, p3, p4);
|
||||
|
||||
if (!inTriangle) {
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[activeSubmenu],
|
||||
);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
useClickOutside(menuRef, handleClose, triggerRef);
|
||||
|
||||
// Keep focus on menu container when filtering leaves no items
|
||||
useEffect(() => {
|
||||
if (filteredItems.length === 0 && filter && menuRef.current) {
|
||||
menuRef.current.focus();
|
||||
}
|
||||
}, [filteredItems.length, filter]);
|
||||
|
||||
const submenuTriggerShape = useMemo(() => {
|
||||
if (!activeSubmenu) return null;
|
||||
const rect = activeSubmenu.parent.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
};
|
||||
}, [activeSubmenu]);
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent) => {
|
||||
mousePosition.current = { x: event.clientX, y: event.clientY };
|
||||
};
|
||||
|
||||
const menuContent = (
|
||||
<m.div
|
||||
ref={menuRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onContextMenu={(e) => {
|
||||
// Prevent showing any ancestor context menus
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
style={styles.container}
|
||||
className={classNames(
|
||||
className,
|
||||
'x-theme-menu',
|
||||
'outline-none my-1 pointer-events-auto z-40',
|
||||
'fixed',
|
||||
)}
|
||||
>
|
||||
{showTriangle && !isSubmenu && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={styles.triangle}
|
||||
className="bg-surface absolute border-border-subtle border-t border-l"
|
||||
/>
|
||||
)}
|
||||
<VStack
|
||||
style={styles.menu}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-auto bg-surface rounded-md shadow-lg py-1.5 border',
|
||||
'border-border-subtle overflow-y-auto overflow-x-hidden mx-0.5',
|
||||
)}
|
||||
>
|
||||
{filter && (
|
||||
<HStack
|
||||
space={2}
|
||||
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
|
||||
>
|
||||
<Icon icon="search" size="xs" />
|
||||
<div className="text">{filter}</div>
|
||||
</HStack>
|
||||
)}
|
||||
{filteredItems.length === 0 && (
|
||||
<span className="text-text-subtlest text-center px-2 py-1">No matches</span>
|
||||
)}
|
||||
{filteredItems.map((item, i) => {
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<Separator
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else available
|
||||
key={i}
|
||||
className={classNames('my-1.5', item.label ? 'ml-2' : null)}
|
||||
>
|
||||
{item.label}
|
||||
</Separator>
|
||||
);
|
||||
}
|
||||
if (item.type === 'content') {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Needs to be clickable but want to support nested buttons
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine
|
||||
<div key={i} className={classNames('my-1 mx-2 max-w-xs')} onClick={onClose}>
|
||||
{item.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isParentOfActiveSubmenu = activeSubmenu?.item === item;
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
isParentOfActiveSubmenu={isParentOfActiveSubmenu}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
onHover={handleItemHover}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: It's fine
|
||||
key={i}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
{activeSubmenu && (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Container div that cancels hover timeout
|
||||
<div
|
||||
ref={submenuRef}
|
||||
onMouseEnter={() => {
|
||||
if (submenuTimeoutRef.current) {
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
isSubmenu
|
||||
isOpen
|
||||
items={activeSubmenu.item.submenu ?? []}
|
||||
defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}
|
||||
onClose={() => setActiveSubmenu(null)}
|
||||
triggerShape={submenuTriggerShape}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSubmenu) {
|
||||
return menuContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map(
|
||||
@@ -790,9 +507,95 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
||||
{menuContent}
|
||||
</Overlay>
|
||||
{isOpen && (
|
||||
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
||||
<m.div
|
||||
ref={menuRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
onContextMenu={(e) => {
|
||||
// Prevent showing any ancestor context menus
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
style={styles.container}
|
||||
className={classNames(
|
||||
className,
|
||||
'x-theme-menu',
|
||||
'outline-none my-1 pointer-events-auto fixed z-40',
|
||||
)}
|
||||
>
|
||||
{showTriangle && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={styles.triangle}
|
||||
className="bg-surface absolute border-border-subtle border-t border-l"
|
||||
/>
|
||||
)}
|
||||
<VStack
|
||||
style={styles.menu}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-auto bg-surface rounded-md shadow-lg py-1.5 border',
|
||||
'border-border-subtle overflow-y-auto overflow-x-hidden mx-0.5',
|
||||
)}
|
||||
>
|
||||
{filter && (
|
||||
<HStack
|
||||
space={2}
|
||||
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
|
||||
>
|
||||
<Icon icon="search" size="xs" />
|
||||
<div className="text">{filter}</div>
|
||||
</HStack>
|
||||
)}
|
||||
{filteredItems.length === 0 && (
|
||||
<span className="text-text-subtlest text-center px-2 py-1">No matches</span>
|
||||
)}
|
||||
{filteredItems.map((item, i) => {
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<Separator
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else available
|
||||
key={i}
|
||||
className={classNames('my-1.5', item.label ? 'ml-2' : null)}
|
||||
>
|
||||
{item.label}
|
||||
</Separator>
|
||||
);
|
||||
}
|
||||
if (item.type === 'content') {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Needs to be clickable but want to support nested buttons
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine
|
||||
<div key={i} className={classNames('my-1 mx-2 max-w-xs')} onClick={onClose}>
|
||||
{item.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: It's fine
|
||||
key={i}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</m.div>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -803,21 +606,10 @@ interface MenuItemProps {
|
||||
item: DropdownItemDefault;
|
||||
onSelect: (item: DropdownItemDefault) => Promise<void>;
|
||||
onFocus: (item: DropdownItemDefault) => void;
|
||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||
focused: boolean;
|
||||
isParentOfActiveSubmenu?: boolean;
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
className,
|
||||
focused,
|
||||
onFocus,
|
||||
onHover,
|
||||
item,
|
||||
onSelect,
|
||||
isParentOfActiveSubmenu,
|
||||
...props
|
||||
}: MenuItemProps) {
|
||||
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleClick = useCallback(async () => {
|
||||
if (item.waitForOnSelect) setIsLoading(true);
|
||||
@@ -833,10 +625,8 @@ function MenuItem({
|
||||
[item, onFocus],
|
||||
);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const initRef = useCallback(
|
||||
(el: HTMLButtonElement | null) => {
|
||||
buttonRef.current = el;
|
||||
if (el === null) return;
|
||||
if (focused) {
|
||||
setTimeout(() => el.focus(), 0);
|
||||
@@ -845,32 +635,23 @@ function MenuItem({
|
||||
[focused],
|
||||
);
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
onHover(item, e.currentTarget);
|
||||
e.currentTarget.focus();
|
||||
};
|
||||
|
||||
const rightSlot = item.submenu ? (
|
||||
<Icon icon="chevron_right" />
|
||||
) : (
|
||||
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />)
|
||||
);
|
||||
const rightSlot = item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={initRef}
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseEnter={(e) => e.currentTarget.focus()}
|
||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||
disabled={item.disabled}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
justify="start"
|
||||
leftSlot={
|
||||
(isLoading || item.leftSlot || item.icon) && (
|
||||
(isLoading || item.leftSlot) && (
|
||||
<div className={classNames('pr-2 flex justify-start [&_svg]:opacity-70')}>
|
||||
{isLoading ? <LoadingIcon /> : item.icon ? <Icon icon={item.icon} /> : item.leftSlot}
|
||||
{isLoading ? <LoadingIcon /> : item.leftSlot}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -882,7 +663,6 @@ function MenuItem({
|
||||
'h-xs', // More compact
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
|
||||
'focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1',
|
||||
isParentOfActiveSubmenu && 'bg-surface-highlight text rounded',
|
||||
item.color === 'danger' && '!text-danger',
|
||||
item.color === 'primary' && '!text-primary',
|
||||
item.color === 'success' && '!text-success',
|
||||
@@ -907,27 +687,3 @@ function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
|
||||
useHotKey(action ?? null, () => onSelect(item));
|
||||
return null;
|
||||
}
|
||||
|
||||
function sign(
|
||||
p1: { x: number; y: number },
|
||||
p2: { x: number; y: number },
|
||||
p3: { x: number; y: number },
|
||||
) {
|
||||
return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
|
||||
}
|
||||
|
||||
function isPointInTriangle(
|
||||
pt: { x: number; y: number },
|
||||
v1: { x: number; y: number },
|
||||
v2: { x: number; y: number },
|
||||
v3: { x: number; y: number },
|
||||
) {
|
||||
const d1 = sign(pt, v1, v2);
|
||||
const d2 = sign(pt, v2, v3);
|
||||
const d3 = sign(pt, v3, v1);
|
||||
|
||||
const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
|
||||
const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
|
||||
|
||||
return !(has_neg && has_pos);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { startCompletion } from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, historyField, indentWithTab } from '@codemirror/commands';
|
||||
import { foldState, forceParsing } from '@codemirror/language';
|
||||
import type { EditorStateConfig, Extension } from '@codemirror/state';
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
|
||||
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
|
||||
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
|
||||
import { eventMatchesHotkey } from '../../../hooks/useHotKey';
|
||||
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
||||
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
||||
import { editEnvironment } from '../../../lib/editEnvironment';
|
||||
@@ -582,13 +580,7 @@ function getExtensions({
|
||||
blur: () => {
|
||||
onBlur.current?.();
|
||||
},
|
||||
keydown: (e, view) => {
|
||||
// Check if the hotkey matches the editor.autocomplete action
|
||||
if (eventMatchesHotkey(e, 'editor.autocomplete')) {
|
||||
e.preventDefault();
|
||||
startCompletion(view);
|
||||
return true;
|
||||
}
|
||||
keydown: (e) => {
|
||||
onKeyDown.current?.(e);
|
||||
},
|
||||
paste: (e, v) => {
|
||||
|
||||
@@ -184,16 +184,6 @@ export function getLanguageExtension({
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.
|
||||
// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.
|
||||
const filteredCompletionKeymap = completionKeymap.filter((binding) => {
|
||||
const key = binding.key?.toLowerCase() ?? '';
|
||||
const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? '';
|
||||
// Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)
|
||||
const isStartTrigger = key.includes('space') || mac.includes('alt-') || mac.includes('`');
|
||||
return !isStartTrigger;
|
||||
});
|
||||
|
||||
export const baseExtensions = [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
@@ -202,7 +192,6 @@ export const baseExtensions = [
|
||||
autocompletion({
|
||||
tooltipClass: () => 'x-theme-menu',
|
||||
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
|
||||
defaultKeymap: false, // We handle the trigger via configurable hotkeys
|
||||
compareCompletions: (a, b) => {
|
||||
// Don't sort completions at all, only on boost
|
||||
return (a.boost ?? 0) - (b.boost ?? 0);
|
||||
@@ -210,7 +199,7 @@ export const baseExtensions = [
|
||||
}),
|
||||
syntaxHighlighting(syntaxHighlightStyle),
|
||||
syntaxTheme,
|
||||
keymap.of([...historyKeymap, ...filteredCompletionKeymap]),
|
||||
keymap.of([...historyKeymap, ...completionKeymap]),
|
||||
];
|
||||
|
||||
export const readonlyExtensions = [
|
||||
|
||||
@@ -14,7 +14,6 @@ export type HotkeyAction =
|
||||
| 'app.zoom_out'
|
||||
| 'app.zoom_reset'
|
||||
| 'command_palette.toggle'
|
||||
| 'editor.autocomplete'
|
||||
| 'environment_editor.toggle'
|
||||
| 'hotkeys.showHelp'
|
||||
| 'model.create'
|
||||
@@ -42,7 +41,6 @@ const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
|
||||
'app.zoom_out': ['Meta+Minus'],
|
||||
'app.zoom_reset': ['Meta+0'],
|
||||
'command_palette.toggle': ['Meta+k'],
|
||||
'editor.autocomplete': ['Control+Space'],
|
||||
'environment_editor.toggle': ['Meta+Shift+e'],
|
||||
'request.rename': ['Control+Shift+r'],
|
||||
'request.send': ['Meta+Enter', 'Meta+r'],
|
||||
@@ -71,7 +69,6 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
|
||||
'app.zoom_out': ['Control+Minus'],
|
||||
'app.zoom_reset': ['Control+0'],
|
||||
'command_palette.toggle': ['Control+k'],
|
||||
'editor.autocomplete': ['Control+Space'],
|
||||
'environment_editor.toggle': ['Control+Shift+e'],
|
||||
'request.rename': ['F2'],
|
||||
'request.send': ['Control+Enter', 'Control+r'],
|
||||
@@ -125,7 +122,6 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
|
||||
'app.zoom_out': 'Zoom Out',
|
||||
'app.zoom_reset': 'Zoom to Actual Size',
|
||||
'command_palette.toggle': 'Toggle Command Palette',
|
||||
'editor.autocomplete': 'Trigger Autocomplete',
|
||||
'environment_editor.toggle': 'Edit Environments',
|
||||
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
||||
'model.create': 'New Request',
|
||||
@@ -148,14 +144,7 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
|
||||
'workspace_settings.show': 'Open Workspace Settings',
|
||||
};
|
||||
|
||||
const layoutInsensitiveKeys = [
|
||||
'Equal',
|
||||
'Minus',
|
||||
'BracketLeft',
|
||||
'BracketRight',
|
||||
'Backquote',
|
||||
'Space',
|
||||
];
|
||||
const layoutInsensitiveKeys = ['Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote'];
|
||||
|
||||
export const hotkeyActions: HotkeyAction[] = (
|
||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||
@@ -276,20 +265,29 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const executed: string[] = [];
|
||||
for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
|
||||
const enable = typeof options.enable === 'function' ? options.enable() : options.enable;
|
||||
if (enable === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (keysMatchAction(Array.from(currentKeysWithModifiers), action)) {
|
||||
if (!options.allowDefault) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hotkeys = getHotkeys();
|
||||
outer: for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
|
||||
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
|
||||
if (hkAction !== action) {
|
||||
continue;
|
||||
}
|
||||
const enable = typeof options.enable === 'function' ? options.enable() : options.enable;
|
||||
if (enable === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const hkKey of hkKeys) {
|
||||
const keys = hkKey.split('+');
|
||||
if (compareKeys(keys, Array.from(currentKeysWithModifiers))) {
|
||||
if (!options.allowDefault) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
callback(e);
|
||||
executed.push(`${action} ${options.priority ?? 0}`);
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
callback(e);
|
||||
executed.push(`${action} ${options.priority ?? 0}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,16 +336,12 @@ export function formatHotkeyString(trigger: string): string[] {
|
||||
labelParts.push('+');
|
||||
} else if (p === 'Equal') {
|
||||
labelParts.push('=');
|
||||
} else if (p === 'Space') {
|
||||
labelParts.push('Space');
|
||||
} else {
|
||||
labelParts.push(capitalize(p));
|
||||
}
|
||||
} else {
|
||||
if (p === 'Control') {
|
||||
labelParts.push('Ctrl');
|
||||
} else if (p === 'Space') {
|
||||
labelParts.push('Space');
|
||||
} else {
|
||||
labelParts.push(capitalize(p));
|
||||
}
|
||||
@@ -382,39 +376,3 @@ function compareKeys(keysA: string[], keysB: string[]) {
|
||||
.join('::');
|
||||
return sortedA === sortedB;
|
||||
}
|
||||
|
||||
/** Build the full key combination from a KeyboardEvent including modifiers */
|
||||
function getKeysFromEvent(e: KeyboardEvent): string[] {
|
||||
const keys: string[] = [];
|
||||
if (e.altKey) keys.push('Alt');
|
||||
if (e.ctrlKey) keys.push('Control');
|
||||
if (e.metaKey) keys.push('Meta');
|
||||
if (e.shiftKey) keys.push('Shift');
|
||||
|
||||
// Add the actual key (use code for layout-insensitive keys)
|
||||
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
||||
keys.push(keyToAdd);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Check if a set of pressed keys matches any hotkey for the given action */
|
||||
function keysMatchAction(keys: string[], action: HotkeyAction): boolean {
|
||||
const hotkeys = getHotkeys();
|
||||
const hkKeys = hotkeys[action];
|
||||
if (!hkKeys || hkKeys.length === 0) return false;
|
||||
|
||||
for (const hkKey of hkKeys) {
|
||||
const hotkeyParts = hkKey.split('+');
|
||||
if (compareKeys(hotkeyParts, keys)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check if a KeyboardEvent matches a hotkey action */
|
||||
export function eventMatchesHotkey(e: KeyboardEvent, action: HotkeyAction): boolean {
|
||||
const keys = getKeysFromEvent(e);
|
||||
return keysMatchAction(keys, action);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user