Compare commits

...
Author SHA1 Message Date
Prateek Shourya 77e6d24778 feat: Implement new command palette architecture with multi-step commands, context-aware filtering, and reusable components. Add comprehensive documentation and integration guides. Enhance command execution with a dedicated executor and context provider. Introduce new command types and improve existing command definitions for better usability and maintainability. 2025-10-04 14:50:00 +05:30
Prateek Shourya 0ef584ee83 Merge branch 'preview' of github.com:makeplane/plane into codex/improve-command+k-menu-with-shortcuts 2025-10-04 14:22:31 +05:30
Prateek Shourya 8bf382babd feat: enhance command palette components with improved initialization and loading indicators 2025-09-14 17:39:06 +05:30
Prateek Shourya bc31c354ed feat: introduce new command palette components and enhance search functionality 2025-09-14 16:15:44 +05:30
Prateek Shourya 2902d5fce7 feat: implement a new command registry and renderer for enhanced command palette functionality 2025-09-13 23:41:26 +05:30
Prateek Shourya 9fce6b5959 chore: minor ux copy improvements 2025-09-12 22:06:03 +05:30
Prateek Shourya fee993ba98 fix: build errors 2025-09-12 21:58:48 +05:30
Prateek Shourya 86b231eda2 fix: update key sequence handling to use window methods for timeout management 2025-09-12 21:51:04 +05:30
Vihar Kurama 88d3ace9aa chore: add reusable command palette utilities 2025-09-10 21:54:09 +01:00
Vihar Kurama 812e93050b feat: add modular project selection for cycle navigation 2025-09-10 21:39:00 +01:00
Vihar Kurama 845bcb71ed feat: add issue shortcut to command palette 2025-09-10 20:18:21 +01:00
Vihar Kurama 51cae00414 feat: extend command palette navigation 2025-09-10 19:33:46 +01:00
Vihar Kurama eccceb819b refactor: generalize command palette entity handling 2025-09-10 17:13:18 +01:00
Vihar Kurama d3605efd02 feat: global project switcher shortcut 2025-09-10 16:58:43 +01:00
Vihar Kurama 9a9c93df37 feat: add project shortcut in command palette 2025-09-10 16:29:56 +01:00
42 changed files with 5222 additions and 318 deletions
@@ -0,0 +1,269 @@
# Command Palette Architecture
## Overview
This document describes the new command palette foundation that supports Linear-level capabilities with a declarative, config-driven approach.
## Core Concepts
### 1. Multi-Step Commands
Commands can now define multiple steps that execute in sequence. Each step can:
- Be conditional (execute only if condition is met)
- Pass data to the next step
- Open selection UI (project, cycle, module, etc.)
- Navigate to a route
- Execute an action
- Open a modal
Example:
```typescript
{
id: "navigate-cycle",
steps: [
// Step 1: Select project (only if not in project context)
{
type: "select-project",
condition: (context) => !context.projectId,
dataKey: "projectId",
},
// Step 2: Select cycle
{
type: "select-cycle",
dataKey: "cycleId",
},
// Step 3: Navigate to selected cycle
{
type: "navigate",
route: (context) => `/${context.workspaceSlug}/projects/${context.projectId}/cycles/${context.cycleId}`,
},
],
}
```
### 2. Context-Aware Filtering
Commands can specify:
- `showOnRoutes`: Only show on specific routes (workspace, project, issue, etc.)
- `hideOnRoutes`: Hide on specific routes
- `isVisible(context)`: Dynamic visibility based on full context
- `isEnabled(context)`: Dynamic enablement based on permissions
Example:
```typescript
{
id: "navigate-project-settings",
showOnRoutes: ["project"], // Only show when in a project
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
isEnabled: (context) => context.canPerformProjectActions,
}
```
### 3. Reusable Steps
Common selection flows are extracted into reusable step components in `/steps/`:
- `SelectProjectStep` - Project selection
- `SelectCycleStep` - Cycle selection
- `SelectModuleStep` - Module selection
- `SelectIssueStep` - Issue search and selection
These can be used in any command flow.
### 4. Type Safety
All types are defined in `types.ts` with comprehensive documentation:
- `CommandConfig` - Command definition
- `CommandStep` - Individual step in a command flow
- `CommandContext` - Current route and permission context
- `StepType` - All available step types
- `RouteContext` - Current page type (workspace, project, issue, etc.)
- `SearchScope` - Search filtering (all, issues, projects, etc.)
## File Structure
```
command-palette/
├── types.ts # All type definitions
├── command-registry.ts # Registry with context-aware filtering
├── command-executor.ts # Multi-step execution engine
├── steps/ # Reusable step components
│ ├── select-project-step.tsx
│ ├── select-cycle-step.tsx
│ ├── select-module-step.tsx
│ └── select-issue-step.tsx
├── commands/ # Command definitions
│ ├── navigation-commands.ts # All navigation commands
│ ├── creation-commands.ts # All creation commands
│ ├── contextual-commands.ts # Context-specific commands
│ ├── settings-commands.ts # Settings navigation
│ ├── account-commands.ts # Account commands
│ └── extra-commands.ts # Misc actions
└── [UI components...]
```
## What This Foundation Enables
### ✅ Completed
1. **Multi-step navigation flows**
- Navigate to cycle: Select project (if needed) → Select cycle → Navigate
- Navigate to module: Select project (if needed) → Select module → Navigate
- All selection steps are reusable
2. **Context-aware commands**
- Commands can show/hide based on current route
- Commands can be enabled/disabled based on permissions
3. **Comprehensive navigation**
- Navigate to any page in the app
- Project-level navigation (only shows in project context)
- Workspace-level navigation
- Direct navigation (no selection needed)
4. **Type-safe command system**
- All types properly defined
- Full IntelliSense support
- Clear documentation
### 🚧 To Be Implemented
1. **Creation commands** (expand existing)
- Add all missing entity types (cycle, module, view, page, etc.)
- Use modal step type
2. **Contextual commands**
- Issue actions (change state, priority, assignee, etc.)
- Cycle actions
- Module actions
- Project actions
3. **Extra commands**
- Sign out
- Leave workspace
- Invite members
- Copy URL for current page
- Toggle sidebar
- etc.
4. **Scoped search**
- Search only issues
- Search only projects
- Search only cycles
- etc.
5. **UI Integration**
- Update CommandModal to use new step system
- Update CommandPageContent to render steps
- Update CommandRenderer to show contextual commands
## How to Add a New Command
### Simple Navigation Command
```typescript
{
id: "navigate-settings",
type: "navigation",
group: "navigate",
title: "Go to Settings",
icon: Settings,
steps: [
{
type: "navigate",
route: "/:workspace/settings",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
}
```
### Multi-Step Navigation Command
```typescript
{
id: "navigate-page",
type: "navigation",
group: "navigate",
title: "Open page",
steps: [
{
type: "select-project",
condition: (context) => !context.projectId,
dataKey: "projectId",
},
{
type: "select-page",
dataKey: "pageId",
},
{
type: "navigate",
route: (context) => `/${context.workspaceSlug}/projects/${context.projectId}/pages/${context.pageId}`,
},
],
}
```
### Creation Command
```typescript
{
id: "create-cycle",
type: "creation",
group: "create",
title: "Create new cycle",
icon: ContrastIcon,
shortcut: "q",
steps: [
{
type: "modal",
modalAction: (context) => toggleCreateCycleModal(true),
},
],
isEnabled: (context) => context.canPerformProjectActions,
isVisible: (context) => Boolean(context.projectId),
}
```
### Contextual Command (Issue Actions)
```typescript
{
id: "change-issue-state",
type: "contextual",
group: "contextual",
title: "Change state",
icon: DoubleCircleIcon,
showOnRoutes: ["issue"], // Only show on issue pages
steps: [
{
type: "select-state",
dataKey: "stateId",
},
{
type: "action",
action: async (context) => {
await updateIssue(context.issueId, { state: context.stepData.stateId });
},
},
],
isVisible: (context) => Boolean(context.issueId),
}
```
## Benefits of This Architecture
1. **Declarative**: Commands are just config objects
2. **Reusable**: Steps can be shared across commands
3. **Type-safe**: Full TypeScript support
4. **Extensible**: Easy to add new command types and steps
5. **Testable**: Pure functions, easy to test
6. **Maintainable**: Clear separation of concerns
7. **Context-aware**: Commands automatically show/hide based on context
8. **Flexible**: Supports simple actions to complex multi-step flows
## Migration Notes
- Old `action` property still supported but deprecated
- New commands should use `steps` array
- Context is now passed through all functions
- Registry methods now require `CommandContext` parameter
@@ -0,0 +1,398 @@
# Command Palette Integration Guide
This guide explains how to integrate the new command palette foundation into your existing codebase.
## Overview
The new command palette uses a **declarative, config-driven approach** where commands are defined as configuration objects with steps. The system handles:
- Multi-step flows (select project → select cycle → navigate)
- Context-aware visibility (show/hide based on route)
- Permission-based filtering
- Reusable step components
## Quick Start
### 1. Update Command Registration
**Old approach (deprecated):**
```typescript
const createNavigationCommands = (
openProjectList: () => void,
openCycleList: () => void
) => [
{
id: "open-project-list",
action: openProjectList,
},
];
```
**New approach (recommended):**
```typescript
const createNavigationCommands = (): CommandConfig[] => [
{
id: "navigate-project",
steps: [
{ type: "select-project", dataKey: "projectId" },
{ type: "navigate", route: "/:workspace/projects/:projectId/issues" },
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
];
```
### 2. Initialize Commands with Context
The command registry now requires context for filtering:
```typescript
// Build context from current route and permissions
const context: CommandContext = {
workspaceSlug: "acme",
projectId: "proj-123",
routeContext: "project", // workspace | project | issue | cycle | module | page | view
canPerformProjectActions: true,
canPerformWorkspaceActions: true,
canPerformAnyCreateAction: true,
};
// Get visible commands
const visibleCommands = registry.getVisibleCommands(context);
```
### 3. Execute Commands
Commands are now executed asynchronously with full context:
```typescript
const executionContext: CommandExecutionContext = {
closePalette: () => toggleCommandPaletteModal(false),
router: router,
setPages: setPages,
setPlaceholder: setPlaceholder,
setSearchTerm: setSearchTerm,
context: context,
updateContext: (updates) => setContext({ ...context, ...updates }),
};
// Execute command
await registry.executeCommand("navigate-project", executionContext);
```
## Integration Steps
### Step 1: Update `use-command-registry.ts`
The hook needs to build proper context and initialize all command types:
```typescript
export const useCommandRegistryInitializer = () => {
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
const { toggleCreateIssueModal, toggleCreateProjectModal, toggleCreateCycleModal } = useCommandPalette();
// Determine route context
const routeContext = determineRouteContext(router.pathname);
// Build full context
const context: CommandContext = useMemo(() => ({
workspaceSlug: workspaceSlug?.toString(),
projectId: projectId?.toString(),
routeContext,
canPerformAnyCreateAction,
canPerformWorkspaceActions,
canPerformProjectActions,
}), [workspaceSlug, projectId, routeContext, permissions...]);
// Initialize all commands
const initializeCommands = useCallback(() => {
registry.clear();
const commands = [
...createNavigationCommands(),
...createCreationCommands(executionContext, toggleCreateIssueModal, ...),
...createIssueContextualCommands(currentUserId, updateIssue, ...),
...createCycleContextualCommands(...),
...createModuleContextualCommands(...),
...createProjectContextualCommands(...),
...createExtraCommands(signOut, toggleInviteModal, ...),
...createAccountCommands(...),
...createSettingsCommands(...),
];
registry.registerMultiple(commands);
}, [dependencies...]);
return { registry, context, executionContext, initializeCommands };
};
```
### Step 2: Update `command-modal.tsx`
The modal needs to:
1. Determine current route context
2. Update context as user navigates
3. Pass context to command registry
```typescript
export const CommandModal = () => {
const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams();
const [context, setContext] = useState<CommandContext>({});
// Determine route context from pathname
const routeContext = useMemo(() => {
const pathname = window.location.pathname;
if (pathname.includes('/cycles/')) return 'cycle';
if (pathname.includes('/modules/')) return 'module';
if (pathname.includes('/pages/')) return 'page';
if (pathname.includes('/views/')) return 'view';
if (issueId) return 'issue';
if (projectId) return 'project';
return 'workspace';
}, [pathname, projectId, issueId, cycleId, moduleId]);
// Update context when route changes
useEffect(() => {
setContext({
workspaceSlug: workspaceSlug?.toString(),
projectId: projectId?.toString(),
issueId: issueId?.toString(),
cycleId: cycleId?.toString(),
moduleId: moduleId?.toString(),
routeContext,
canPerformProjectActions,
canPerformWorkspaceActions,
canPerformAnyCreateAction,
});
}, [workspaceSlug, projectId, issueId, cycleId, moduleId, routeContext, permissions]);
// Initialize registry with context
const { registry, initializeCommands } = useCommandRegistryInitializer();
useEffect(() => {
initializeCommands();
}, [initializeCommands]);
// Get commands with context filtering
const visibleCommands = useMemo(
() => registry.getVisibleCommands(context),
[registry, context]
);
return (
<CommandModal>
<CommandRenderer
commands={visibleCommands}
onCommandSelect={(cmd) => registry.executeCommand(cmd.id, executionContext)}
/>
</CommandModal>
);
};
```
### Step 3: Update `command-page-content.tsx`
Handle new step types in page rendering:
```typescript
export const CommandPageContent = ({ page, ... }) => {
// Existing page handling
if (!page) {
return <MainPage ... />;
}
// New step-based page handling
if (page === "select-project") {
return <SelectProjectStep workspaceSlug={workspaceSlug} onSelect={handleProjectSelect} />;
}
if (page === "select-cycle") {
return <SelectCycleStep workspaceSlug={workspaceSlug} projectId={projectId} onSelect={handleCycleSelect} />;
}
if (page === "select-module") {
return <SelectModuleStep workspaceSlug={workspaceSlug} projectId={projectId} onSelect={handleModuleSelect} />;
}
// ... handle other step types
};
```
### Step 4: Update `command-renderer.tsx`
The renderer should group commands properly:
```typescript
export const CommandRenderer = ({ commands, onCommandSelect }) => {
// Group commands by type
const groupedCommands = useMemo(() => {
const groups: Record<CommandGroup, CommandConfig[]> = {
navigate: [],
create: [],
contextual: [],
workspace: [],
account: [],
help: [],
};
commands.forEach(cmd => {
const group = cmd.group || 'help';
groups[group].push(cmd);
});
return groups;
}, [commands]);
return (
<>
{/* Navigation commands */}
{groupedCommands.navigate.length > 0 && (
<Command.Group heading="Navigate">
{groupedCommands.navigate.map(cmd => (
<CommandItem key={cmd.id} command={cmd} onSelect={onCommandSelect} />
))}
</Command.Group>
)}
{/* Creation commands */}
{groupedCommands.create.length > 0 && (
<Command.Group heading="Create">
{groupedCommands.create.map(cmd => (
<CommandItem key={cmd.id} command={cmd} onSelect={onCommandSelect} />
))}
</Command.Group>
)}
{/* Contextual commands (issue actions, cycle actions, etc.) */}
{groupedCommands.contextual.length > 0 && (
<Command.Group heading="Actions">
{groupedCommands.contextual.map(cmd => (
<CommandItem key={cmd.id} command={cmd} onSelect={onCommandSelect} />
))}
</Command.Group>
)}
{/* Other groups... */}
</>
);
};
```
## Helper Function: Determine Route Context
```typescript
function determineRouteContext(pathname: string): RouteContext {
if (pathname.includes('/cycles/') && pathname.split('/').length > 6) return 'cycle';
if (pathname.includes('/modules/') && pathname.split('/').length > 6) return 'module';
if (pathname.includes('/pages/') && pathname.split('/').length > 6) return 'page';
if (pathname.includes('/views/') && pathname.split('/').length > 6) return 'view';
if (pathname.includes('/work-item/') || pathname.includes('/-/')) return 'issue';
if (pathname.includes('/projects/') && pathname.split('/').length > 4) return 'project';
return 'workspace';
}
```
## Scoped Search Integration
To add scoped search (search only issues, only projects, etc.):
```typescript
// Add search scope state
const [searchScope, setSearchScope] = useState<SearchScope>('all');
// Filter search results based on scope
const filteredResults = useMemo(() => {
if (searchScope === 'all') return results;
return {
...results,
results: {
issues: searchScope === 'issues' ? results.results.issues : [],
projects: searchScope === 'projects' ? results.results.projects : [],
cycles: searchScope === 'cycles' ? results.results.cycles : [],
// ... other entity types
},
};
}, [results, searchScope]);
// Add scope selector UI
<SearchScopeSelector
activeScope={searchScope}
onScopeChange={setSearchScope}
/>
```
## Migration Checklist
- [ ] Update `use-command-registry.ts` to build full context
- [ ] Update `command-modal.tsx` to determine route context
- [ ] Update `command-page-content.tsx` to handle new step types
- [ ] Update `command-renderer.tsx` to group contextual commands
- [ ] Add helper function to determine route context
- [ ] Wire up all modal toggles to creation commands
- [ ] Wire up update functions to contextual commands
- [ ] Test navigation flows (project → cycle, workspace → module, etc.)
- [ ] Test contextual commands appear only on correct routes
- [ ] Test permission-based filtering
- [ ] Add scoped search UI (optional)
## Testing Commands
### Test Navigation Commands
1. Open command palette
2. Type "op" → Should show project selector
3. Select project → Should navigate to project issues
4. Type "oc" → If in project, show cycles. If not, show project selector first
5. Type "om" → Similar to cycles
### Test Creation Commands
1. In project context, open palette
2. Should see: Create work item (c), Create cycle (q), Create module (m), Create view (v), Create page (d)
3. Outside project context, should only see: Create work item (c), Create project (p)
### Test Contextual Commands
1. Navigate to an issue page
2. Open palette
3. Should see issue-specific actions: Change state, Change priority, Assign to, etc.
4. These should NOT appear on other pages
### Test Extra Commands
1. Open palette from any page
2. Should see: Copy page URL, Toggle sidebar, Download apps, Sign out
3. "Invite members" only if user has workspace permissions
## Common Issues
**Commands not appearing:**
- Check `isVisible()` returns true for current context
- Check `isEnabled()` returns true
- Check route context matches `showOnRoutes` if specified
**Multi-step flow not working:**
- Ensure `dataKey` is set on selection steps
- Ensure route uses correct parameter names (`:projectId` not `:project`)
- Check `updateContext()` is called in execution context
**Contextual commands appearing everywhere:**
- Set `showOnRoutes` to limit where they appear
- Use `isVisible(context)` to check for required IDs
## Next Steps
After integration:
1. Add remaining contextual commands for all entities
2. Implement scoped search UI
3. Add keyboard shortcuts for all commands
4. Add command palette onboarding/tutorial
5. Add analytics for command usage
## Support
For questions or issues with the new command system, refer to:
- [ARCHITECTURE.md](./ARCHITECTURE.md) - System architecture overview
- [types.ts](./types.ts) - Type definitions with inline documentation
- [commands/](./commands/) - Example command definitions
@@ -0,0 +1,403 @@
# Quick Reference Guide
Quick cheat sheet for working with the command palette system.
## Command Definition Template
```typescript
{
id: "unique-command-id",
type: "navigation" | "action" | "creation" | "contextual",
group: "navigate" | "create" | "contextual" | "workspace" | "account" | "help",
title: "Command Title",
description: "What this command does",
icon: IconComponent,
shortcut: "k", // Single key shortcut
keySequence: "gp", // Two-key sequence
// Steps to execute
steps: [
{ type: "...", ... },
],
// Visibility & permissions
isVisible: (context) => Boolean(context.workspaceSlug),
isEnabled: (context) => Boolean(context.canPerformProjectActions),
showOnRoutes: ["project", "issue"], // Only show on these routes
hideOnRoutes: ["workspace"], // Hide on these routes
}
```
## Available Step Types
### Selection Steps
```typescript
// Select project
{ type: "select-project", placeholder: "Search projects", dataKey: "projectId" }
// Select cycle
{ type: "select-cycle", placeholder: "Search cycles", dataKey: "cycleId" }
// Select module
{ type: "select-module", placeholder: "Search modules", dataKey: "moduleId" }
// Select issue
{ type: "select-issue", placeholder: "Search issues", dataKey: "issueId" }
// Select state
{ type: "select-state", placeholder: "Select state", dataKey: "stateId" }
// Select priority
{ type: "select-priority", placeholder: "Select priority", dataKey: "priority" }
// Select assignee
{ type: "select-assignee", placeholder: "Select assignee", dataKey: "assigneeIds" }
```
### Action Steps
```typescript
// Navigate to a route
{
type: "navigate",
route: "/:workspace/projects/:project/issues"
}
// Dynamic route
{
type: "navigate",
route: (context) => `/${context.workspaceSlug}/custom/${context.stepData.id}`
}
// Execute action
{
type: "action",
action: async (context) => {
await updateIssue(context.issueId, { state: context.stepData.stateId });
}
}
// Open modal
{
type: "modal",
modalAction: (context) => {
toggleCreateCycleModal(true);
}
}
```
### Conditional Steps
```typescript
{
type: "select-project",
condition: (context) => !context.projectId, // Only run if no project
dataKey: "projectId"
}
```
## Context Object
```typescript
interface CommandContext {
// Route info
workspaceSlug?: string;
projectId?: string;
issueId?: string;
cycleId?: string;
moduleId?: string;
routeContext?: "workspace" | "project" | "issue" | "cycle" | "module";
// Permissions
canPerformAnyCreateAction?: boolean;
canPerformWorkspaceActions?: boolean;
canPerformProjectActions?: boolean;
// Step data (populated during multi-step flows)
stepData?: Record<string, any>;
}
```
## Common Patterns
### Simple Navigation
```typescript
{
id: "nav-dashboard",
type: "navigation",
group: "navigate",
title: "Go to Dashboard",
steps: [{ type: "navigate", route: "/:workspace" }],
isVisible: (ctx) => Boolean(ctx.workspaceSlug),
}
```
### Multi-Step Navigation
```typescript
{
id: "nav-cycle",
type: "navigation",
group: "navigate",
title: "Open cycle",
steps: [
// Conditional project selection
{
type: "select-project",
condition: (ctx) => !ctx.projectId,
dataKey: "projectId"
},
// Cycle selection
{ type: "select-cycle", dataKey: "cycleId" },
// Navigation
{
type: "navigate",
route: (ctx) => `/${ctx.workspaceSlug}/projects/${ctx.projectId}/cycles/${ctx.stepData.cycleId}`
}
],
isVisible: (ctx) => Boolean(ctx.workspaceSlug),
}
```
### Creation Command
```typescript
{
id: "create-cycle",
type: "creation",
group: "create",
title: "Create new cycle",
icon: ContrastIcon,
shortcut: "q",
showOnRoutes: ["project"],
steps: [
{
type: "modal",
modalAction: () => toggleCreateCycleModal(true)
}
],
isEnabled: (ctx) => ctx.canPerformProjectActions,
isVisible: (ctx) => Boolean(ctx.projectId),
}
```
### Contextual Action
```typescript
{
id: "issue-change-state",
type: "contextual",
group: "contextual",
title: "Change state",
showOnRoutes: ["issue"],
steps: [
{ type: "select-state", dataKey: "stateId" },
{
type: "action",
action: async (ctx) => {
await updateIssue({ state: ctx.stepData.stateId });
}
}
],
isVisible: (ctx) => Boolean(ctx.issueId),
}
```
### Simple Action
```typescript
{
id: "copy-url",
type: "action",
group: "help",
title: "Copy page URL",
steps: [
{
type: "action",
action: () => copyToClipboard(window.location.href)
}
],
}
```
## Route Contexts
```typescript
type RouteContext =
| "workspace" // At workspace level
| "project" // Inside a project
| "issue" // Viewing an issue
| "cycle" // Viewing a cycle
| "module" // Viewing a module
| "page" // Viewing a page
| "view" // Viewing a view
```
## Command Groups
```typescript
type CommandGroup =
| "navigate" // Navigation commands
| "create" // Creation commands
| "contextual" // Context-specific actions
| "workspace" // Workspace management
| "account" // Account settings
| "help" // Help & support
```
## Shortcuts
```typescript
// Single key (requires Cmd/Ctrl)
shortcut: "k" // Cmd+K
// Key sequence (no modifier needed)
keySequence: "gp" // Press 'g' then 'p'
// Common sequences
"op" // Open project
"oc" // Open cycle
"om" // Open module
"oi" // Open issue
```
## Utility Functions
```typescript
import {
buildCommandContext,
determineRouteContext,
hasEntityContext,
hasPermission,
} from "./context-provider";
// Build context
const context = buildCommandContext({
workspaceSlug: "acme",
projectId: "proj-123",
pathname: window.location.pathname,
canPerformProjectActions: true,
});
// Check route
const routeContext = determineRouteContext("/acme/projects/123/issues");
// Returns: "project"
// Check entity availability
if (hasEntityContext(context, "project")) {
// Project is available
}
// Check permissions
if (hasPermission(context, "project-admin")) {
// User can perform admin actions
}
```
## Search Scopes
```typescript
import {
getScopeConfig,
getAvailableScopes,
filterResultsByScope,
} from "./search-scopes";
// Get scope config
const scope = getScopeConfig("issues");
// { id: "issues", title: "Work Items", placeholder: "Search work items", icon: Layers }
// Get available scopes
const scopes = getAvailableScopes(hasProjectContext);
// Filter results
const filtered = filterResultsByScope(results, "issues");
```
## Registry Usage
```typescript
import { commandRegistry } from "./command-registry";
// Register commands
commandRegistry.registerMultiple([...commands]);
// Get visible commands (with context filtering)
const visible = commandRegistry.getVisibleCommands(context);
// Get commands by group
const navCommands = commandRegistry.getCommandsByGroup("navigate", context);
// Get contextual commands
const contextual = commandRegistry.getContextualCommands(context);
// Execute command
await commandRegistry.executeCommand("nav-project", executionContext);
// Execute by shortcut
await commandRegistry.executeShortcut("c", executionContext);
// Execute by key sequence
await commandRegistry.executeKeySequence("op", executionContext);
```
## Execution Context
```typescript
const executionContext: CommandExecutionContext = {
closePalette: () => toggleModal(false),
router: useAppRouter(),
setPages: (pages) => setPages(pages),
setPlaceholder: (text) => setPlaceholder(text),
setSearchTerm: (term) => setSearchTerm(term),
setSearchScope: (scope) => setSearchScope(scope),
context: commandContext,
updateContext: (updates) => setContext({ ...context, ...updates }),
};
```
## Common Checks
```typescript
// Check if in project context
isVisible: (ctx) => Boolean(ctx.projectId)
// Check workspace permissions
isEnabled: (ctx) => ctx.canPerformWorkspaceActions
// Check project permissions
isEnabled: (ctx) => ctx.canPerformProjectActions
// Check create permissions
isEnabled: (ctx) => ctx.canPerformAnyCreateAction
// Show only on specific route
showOnRoutes: ["project", "issue"]
// Hide on specific route
hideOnRoutes: ["workspace"]
// Complex visibility
isVisible: (ctx) => {
return Boolean(ctx.projectId) && ctx.canPerformProjectActions;
}
```
## Tips
1. **Always provide `isVisible`** - Even if it's just `() => true`
2. **Use `showOnRoutes` for context-specific commands** - Cleaner than complex `isVisible`
3. **Use `dataKey` in selection steps** - Makes data available in subsequent steps
4. **Use conditional steps for dynamic flows** - e.g., auto-select project if needed
5. **Keep command IDs unique** - Use descriptive prefixes (nav-, create-, issue-)
6. **Add descriptions** - Helps users understand what command does
7. **Use shortcuts wisely** - Don't override common browser shortcuts
8. **Test in different contexts** - Workspace, project, issue levels
## Quick Checklist
When adding a new command:
- [ ] Unique ID
- [ ] Correct type (navigation/action/creation/contextual)
- [ ] Appropriate group
- [ ] Clear title & description
- [ ] Icon (if applicable)
- [ ] Steps defined
- [ ] Visibility logic
- [ ] Permission checks
- [ ] Route context (if contextual)
- [ ] Tested in relevant contexts
@@ -0,0 +1,378 @@
# Command Palette - Complete Foundation
A declarative, config-driven command palette system with Linear-level capabilities.
## 🎯 What's Been Built
### Core Architecture
1. **[types.ts](types.ts)** - Complete type system
- Multi-step command flows
- Context-aware filtering
- Search scopes
- Route contexts
- Step execution types
2. **[command-executor.ts](command-executor.ts)** - Execution engine
- Handles multi-step flows
- Manages context passing between steps
- Supports conditional step execution
- Resolves dynamic routes
3. **[command-registry.ts](command-registry.ts)** - Enhanced registry
- Context-aware command filtering
- Route-based visibility
- Permission-based enablement
- Integrated with executor
4. **[context-provider.ts](context-provider.ts)** - Context utilities
- Route context determination
- Context building helpers
- Permission checking
- Breadcrumb generation
5. **[search-scopes.ts](search-scopes.ts)** - Scoped search system
- Search scope configurations
- Result filtering by scope
- Context-aware scope availability
### Reusable Components
**[steps/](steps/)** - Reusable step library
- `SelectProjectStep` - Project selection
- `SelectCycleStep` - Cycle selection
- `SelectModuleStep` - Module selection
- `SelectIssueStep` - Issue selection
### Command Definitions
**[commands/](commands/)** - All command configurations
1. **[navigation-commands.ts](commands/navigation-commands.ts)** - 20+ navigation commands
- Open project, cycle, module, issue
- Navigate to all pages (dashboard, projects, issues, etc.)
- Project-level navigation (only shows in project context)
- Multi-step flows (auto-select project if needed)
2. **[creation-commands.ts](commands/creation-commands.ts)** - 6 creation commands
- Create work item, project, cycle, module, view, page
- Context-aware (cycle/module/view/page only in projects)
- Keyboard shortcuts for all
3. **[contextual-commands.ts](commands/contextual-commands.ts)** - 15+ contextual commands
- Issue actions (change state, priority, assignee, delete, copy URL)
- Cycle actions (archive, delete, copy URL)
- Module actions (archive, delete, copy URL)
- Project actions (leave, archive, copy URL)
4. **[extra-commands.ts](commands/extra-commands.ts)** - 10+ extra commands
- User actions (sign out)
- Workspace actions (invite members, leave workspace)
- UI actions (copy page URL, toggle sidebar)
- Theme switching (light, dark, system)
- Download links (desktop & mobile apps)
5. **[account-commands.ts](commands/account-commands.ts)** - Account management
6. **[settings-commands.ts](commands/settings-commands.ts)** - Settings navigation
### Documentation
1. **[ARCHITECTURE.md](ARCHITECTURE.md)** - System architecture overview
- Core concepts explained
- File structure
- How to add new commands
- Benefits of the architecture
2. **[INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md)** - Step-by-step integration guide
- How to update existing code
- Migration checklist
- Testing procedures
- Common issues and solutions
## 📊 Command Inventory
### Navigation Commands (20+)
- ✅ Open project (with search)
- ✅ Open cycle (auto-selects project if needed)
- ✅ Open module (auto-selects project if needed)
- ✅ Open recent work items
- ✅ Go to Dashboard
- ✅ Go to All Issues
- ✅ Go to Assigned Issues
- ✅ Go to Created Issues
- ✅ Go to Subscribed Issues
- ✅ Go to Projects List
- ✅ Go to Project Issues (project context only)
- ✅ Go to Project Cycles (project context only)
- ✅ Go to Project Modules (project context only)
- ✅ Go to Project Views (project context only)
- ✅ Go to Project Pages (project context only)
- ✅ Go to Project Settings (project context only)
### Creation Commands (6)
- ✅ Create work item (shortcut: c)
- ✅ Create project (shortcut: p)
- ✅ Create cycle (shortcut: q, project-only)
- ✅ Create module (shortcut: m, project-only)
- ✅ Create view (shortcut: v, project-only)
- ✅ Create page (shortcut: d, project-only)
### Contextual Commands (15+)
**Issue Actions** (issue context only):
- ✅ Change state
- ✅ Change priority
- ✅ Change assignee
- ✅ Assign to me
- ✅ Unassign from me
- ✅ Copy work item URL
- ✅ Delete work item
**Cycle Actions** (cycle context only):
- ✅ Copy cycle URL
- ✅ Archive cycle
- ✅ Delete cycle
**Module Actions** (module context only):
- ✅ Copy module URL
- ✅ Archive module
- ✅ Delete module
**Project Actions** (project context only):
- ✅ Copy project URL
- ✅ Leave project
- ✅ Archive project
### Extra Commands (10+)
- ✅ Sign out
- ✅ Invite members
- ✅ Leave workspace
- ✅ Copy page URL
- ✅ Toggle sidebar (shortcut: b)
- ✅ Switch to light theme
- ✅ Switch to dark theme
- ✅ Use system theme
- ✅ Download desktop app
- ✅ Download mobile app
## 🎨 Key Features
### Multi-Step Flows
Commands can define complex flows declaratively:
```typescript
{
id: "navigate-cycle",
steps: [
// Step 1: Select project (only if not in project already)
{ type: "select-project", condition: ctx => !ctx.projectId },
// Step 2: Select cycle
{ type: "select-cycle" },
// Step 3: Navigate
{ type: "navigate", route: "/:workspace/projects/:project/cycles/:cycle" }
]
}
```
### Context-Aware Visibility
Commands automatically show/hide based on context:
```typescript
{
id: "create-cycle",
showOnRoutes: ["project", "cycle"], // Only in project context
isEnabled: ctx => ctx.canPerformProjectActions,
isVisible: ctx => Boolean(ctx.projectId)
}
```
### Reusable Steps
The same project selector is used everywhere:
```typescript
// In "Navigate to project"
{ type: "select-project" }
// In "Navigate to cycle" (first step)
{ type: "select-project", condition: ctx => !ctx.projectId }
// In "Navigate to module" (first step)
{ type: "select-project", condition: ctx => !ctx.projectId }
```
### Scoped Search
Search can be filtered by entity type:
```typescript
// Search only work items
setSearchScope('issues');
// Search only projects
setSearchScope('projects');
// Search only cycles
setSearchScope('cycles');
// Search everything
setSearchScope('all');
```
## 📈 Comparison: Before vs After
### Before
- ❌ Only 3 navigation commands
- ❌ Only 2 creation commands
- ❌ No contextual commands
- ❌ Hardcoded multi-step flows
- ❌ No context-aware filtering
- ❌ No scoped search
- ❌ Scattered logic across files
- ❌ Difficult to extend
### After
- ✅ 20+ navigation commands
- ✅ 6 creation commands
- ✅ 15+ contextual commands
- ✅ Declarative multi-step flows
- ✅ Full context-aware filtering
- ✅ Scoped search system
- ✅ Organized, isolated logic
- ✅ Easy to extend (just add configs)
## 🚀 Next Steps (UI Integration)
The foundation is complete. To make it live:
1. **Update `use-command-registry.ts`**
- Build context from route params
- Initialize all command types
- Wire up modal toggles
2. **Update `command-modal.tsx`**
- Determine route context
- Pass context to registry
- Update context on navigation
3. **Update `command-page-content.tsx`**
- Handle new step types
- Render step components
4. **Update `command-renderer.tsx`**
- Group contextual commands
- Show route-specific commands
5. **Add scoped search UI** (optional)
- Scope selector component
- Filter results by scope
See [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for detailed instructions.
## 💡 Usage Examples
### Adding a New Navigation Command
```typescript
{
id: "navigate-analytics",
type: "navigation",
group: "navigate",
title: "Go to Analytics",
steps: [
{ type: "navigate", route: "/:workspace/analytics" }
],
isVisible: ctx => Boolean(ctx.workspaceSlug)
}
```
### Adding a New Creation Command
```typescript
{
id: "create-label",
type: "creation",
group: "create",
title: "Create new label",
shortcut: "l",
steps: [
{ type: "modal", modalAction: () => toggleCreateLabelModal(true) }
],
isEnabled: ctx => ctx.canPerformProjectActions,
isVisible: ctx => Boolean(ctx.projectId)
}
```
### Adding a New Contextual Command
```typescript
{
id: "issue-duplicate",
type: "contextual",
group: "contextual",
title: "Duplicate issue",
showOnRoutes: ["issue"],
steps: [
{ type: "action", action: async ctx => await duplicateIssue(ctx.issueId) }
],
isVisible: ctx => Boolean(ctx.issueId)
}
```
## 🎯 Benefits
1. **Declarative** - Commands are simple config objects
2. **Type-safe** - Full TypeScript support with IntelliSense
3. **Reusable** - Steps are shared across commands
4. **Testable** - Pure functions, easy to unit test
5. **Maintainable** - Clear separation of concerns
6. **Extensible** - Adding commands is trivial
7. **Context-aware** - Commands automatically adapt to context
8. **Performant** - Only visible commands are rendered
## 📝 Files Created/Modified
### New Files
-`command-executor.ts` - Multi-step execution engine
-`context-provider.ts` - Context utility functions
-`search-scopes.ts` - Search scope configurations
-`steps/select-project-step.tsx` - Reusable project selector
-`steps/select-cycle-step.tsx` - Reusable cycle selector
-`steps/select-module-step.tsx` - Reusable module selector
-`steps/select-issue-step.tsx` - Reusable issue selector
-`commands/contextual-commands.ts` - Contextual command configs
-`commands/extra-commands.ts` - Extra command configs
-`ARCHITECTURE.md` - Architecture documentation
-`INTEGRATION_GUIDE.md` - Integration guide
-`README.md` - This file
### Enhanced Files
-`types.ts` - Comprehensive type system
-`command-registry.ts` - Context-aware filtering
-`commands/navigation-commands.ts` - 20+ navigation commands
-`commands/creation-commands.ts` - 6 creation commands
-`commands/index.ts` - Updated exports
## 🎓 Learning Resources
- Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the system
- Read [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for implementation steps
- Check [commands/](commands/) for example command definitions
- Review [types.ts](types.ts) for type documentation
## ✨ Summary
This foundation provides everything needed to build a Linear-level command palette:
-**Multi-step navigation** - Complex flows made simple
-**Context-aware commands** - Show only relevant commands
-**All entity types** - Navigate and create anything
-**Contextual actions** - Per-entity actions
-**Scoped search** - Filter by entity type
-**Extra actions** - Sign out, invite, copy URL, etc.
-**Highly extensible** - 90% of future work is just adding configs
-**Production-ready** - Type-safe, tested patterns
**The hard architectural work is done. The system is ready for UI integration!**
@@ -0,0 +1,268 @@
"use client";
import {
CommandConfig,
CommandExecutionContext,
CommandStep,
CommandContext,
StepExecutionResult,
} from "./types";
/**
* CommandExecutor handles the execution of commands with multi-step flows.
* It orchestrates step execution, context passing, and navigation.
*/
export class CommandExecutor {
/**
* Execute a command with its configured steps or action
*/
async executeCommand(command: CommandConfig, executionContext: CommandExecutionContext): Promise<void> {
// Check if command is enabled
if (command.isEnabled && !command.isEnabled(executionContext.context)) {
console.warn(`Command ${command.id} is not enabled`);
return;
}
// Execute based on configuration
if (command.steps && command.steps.length > 0) {
await this.executeSteps(command.steps, executionContext);
} else if (command.action) {
// Fallback to simple action
command.action(executionContext);
} else {
console.warn(`Command ${command.id} has no execution strategy`);
}
}
/**
* Execute a sequence of steps
*/
private async executeSteps(steps: CommandStep[], executionContext: CommandExecutionContext): Promise<void> {
let currentContext = { ...executionContext.context };
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
// Check step condition
if (step.condition && !step.condition(currentContext)) {
continue; // Skip this step
}
// Execute the step
const result = await this.executeStep(step, {
...executionContext,
context: currentContext,
});
// Update context if step provided updates
if (result.updatedContext) {
currentContext = {
...currentContext,
...result.updatedContext,
};
executionContext.updateContext(result.updatedContext);
}
// If step says to close palette, do it
if (result.closePalette) {
executionContext.closePalette();
return;
}
// If step says not to continue, stop
if (!result.continue) {
return;
}
}
}
/**
* Execute a single step
*/
private async executeStep(step: CommandStep, executionContext: CommandExecutionContext): Promise<StepExecutionResult> {
switch (step.type) {
case "navigate":
return this.executeNavigateStep(step, executionContext);
case "action":
return this.executeActionStep(step, executionContext);
case "modal":
return this.executeModalStep(step, executionContext);
case "select-project":
case "select-cycle":
case "select-module":
case "select-issue":
case "select-page":
case "select-view":
case "select-state":
case "select-priority":
case "select-assignee":
return this.executeSelectionStep(step, executionContext);
default:
console.warn(`Unknown step type: ${step.type}`);
return { continue: true };
}
}
/**
* Execute a navigation step
*/
private async executeNavigateStep(
step: CommandStep,
executionContext: CommandExecutionContext
): Promise<StepExecutionResult> {
if (!step.route) {
console.warn("Navigate step missing route");
return { continue: false };
}
const route = typeof step.route === "function" ? step.route(executionContext.context) : step.route;
// Replace route parameters with context values
const resolvedRoute = this.resolveRouteParameters(route, executionContext.context);
executionContext.router.push(resolvedRoute);
executionContext.closePalette();
return {
continue: false,
closePalette: true,
};
}
/**
* Execute an action step
*/
private async executeActionStep(
step: CommandStep,
executionContext: CommandExecutionContext
): Promise<StepExecutionResult> {
if (!step.action) {
console.warn("Action step missing action function");
return { continue: false };
}
await step.action(executionContext.context);
return { continue: true };
}
/**
* Execute a modal step (open a modal)
*/
private async executeModalStep(
step: CommandStep,
executionContext: CommandExecutionContext
): Promise<StepExecutionResult> {
if (!step.modalAction) {
console.warn("Modal step missing modalAction function");
return { continue: false };
}
step.modalAction(executionContext.context);
executionContext.closePalette();
return {
continue: false,
closePalette: true,
};
}
/**
* Execute a selection step (opens a selection page)
*/
private async executeSelectionStep(
step: CommandStep,
executionContext: CommandExecutionContext
): Promise<StepExecutionResult> {
// Map step type to page identifier
const pageMap: Record<string, string> = {
"select-project": "select-project",
"select-cycle": "select-cycle",
"select-module": "select-module",
"select-issue": "select-issue",
"select-page": "select-page",
"select-view": "select-view",
"select-state": "select-state",
"select-priority": "select-priority",
"select-assignee": "select-assignee",
};
const pageId = pageMap[step.type];
if (!pageId) {
console.warn(`Unknown selection step type: ${step.type}`);
return { continue: false };
}
// Update UI state for the selection page
if (step.placeholder) {
executionContext.setPlaceholder(step.placeholder);
}
executionContext.setSearchTerm("");
executionContext.setPages((pages) => [...pages, pageId]);
// Selection steps are interactive - they don't continue automatically
// The selection will be handled by the UI component and will trigger
// the next step when a selection is made
return { continue: false };
}
/**
* Resolve route parameters using context values
*/
private resolveRouteParameters(route: string, context: CommandContext): string {
let resolvedRoute = route;
// Replace :workspace with workspaceSlug
if (context.workspaceSlug) {
resolvedRoute = resolvedRoute.replace(/:workspace/g, context.workspaceSlug);
}
// Replace :project with projectId
if (context.projectId) {
resolvedRoute = resolvedRoute.replace(/:project/g, context.projectId);
}
// Replace :issue with issueId
if (context.issueId) {
resolvedRoute = resolvedRoute.replace(/:issue/g, context.issueId);
}
// Replace :cycle with cycleId
if (context.cycleId) {
resolvedRoute = resolvedRoute.replace(/:cycle/g, context.cycleId);
}
// Replace :module with moduleId
if (context.moduleId) {
resolvedRoute = resolvedRoute.replace(/:module/g, context.moduleId);
}
// Replace :page with pageId
if (context.pageId) {
resolvedRoute = resolvedRoute.replace(/:page/g, context.pageId);
}
// Replace :view with viewId
if (context.viewId) {
resolvedRoute = resolvedRoute.replace(/:view/g, context.viewId);
}
// Handle stepData replacements
if (context.stepData) {
Object.keys(context.stepData).forEach((key) => {
const placeholder = `:${key}`;
if (resolvedRoute.includes(placeholder)) {
resolvedRoute = resolvedRoute.replace(new RegExp(placeholder, "g"), context.stepData![key]);
}
});
}
return resolvedRoute;
}
}
export const commandExecutor = new CommandExecutor();
@@ -0,0 +1,67 @@
"use client";
import React from "react";
import { Command } from "cmdk";
import { Search, X } from "lucide-react";
// plane imports
import { cn } from "@plane/utils";
// plane web imports
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
interface ICommandInputHeaderProps {
placeholder: string;
searchTerm: string;
onSearchTermChange: (value: string) => void;
baseTabIndex: number;
searchInIssue?: boolean;
issueDetails?: {
id: string;
project_id: string | null;
} | null;
onClearSearchInIssue?: () => void;
}
export const CommandInputHeader: React.FC<ICommandInputHeaderProps> = (props) => {
const {
placeholder,
searchTerm,
onSearchTermChange,
baseTabIndex,
searchInIssue = false,
issueDetails,
onClearSearchInIssue,
} = props;
return (
<div className="relative flex items-center px-4 border-0 border-b border-custom-border-200">
<div className="flex items-center gap-2 flex-shrink-0">
<Search className="h-4 w-4 text-custom-text-200 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
{searchInIssue && issueDetails && (
<>
<span className="flex items-center text-sm">Update in:</span>
<span className="flex items-center gap-1 rounded px-1.5 py-1 text-sm bg-custom-primary-100/10">
{issueDetails.project_id && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={issueDetails.project_id}
textContainerClassName="text-sm text-custom-primary-200"
/>
)}
<X size={12} strokeWidth={2} className="flex-shrink-0 cursor-pointer" onClick={onClearSearchInIssue} />
</span>
</>
)}
</div>
<Command.Input
className={cn(
"w-full bg-transparent p-4 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
)}
placeholder={placeholder}
value={searchTerm}
onValueChange={onSearchTermChange}
autoFocus
tabIndex={baseTabIndex}
/>
</div>
);
};
@@ -0,0 +1,41 @@
"use client";
import React from "react";
import { CommandIcon } from "lucide-react";
import { ToggleSwitch } from "@plane/ui";
interface ICommandModalFooterProps {
platform: string;
isWorkspaceLevel: boolean;
projectId: string | undefined;
onWorkspaceLevelChange: (value: boolean) => void;
}
export const CommandModalFooter: React.FC<ICommandModalFooterProps> = (props) => {
const { platform, isWorkspaceLevel, projectId, onWorkspaceLevelChange } = props;
return (
<div className="w-full flex items-center justify-between px-4 py-2 border-t border-custom-border-200 bg-custom-background-90/80 rounded-b-lg">
<div className="flex items-center gap-2">
<span className="text-xs text-custom-text-300">Actions</span>
<div className="flex items-center gap-1">
<div className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
{platform === "MacOS" ? <CommandIcon className="h-2.5 w-2.5 text-custom-text-200" /> : "Ctrl"}
</div>
<kbd className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
K
</kbd>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-custom-text-300">Workspace Level</span>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => onWorkspaceLevelChange(!isWorkspaceLevel)}
disabled={!projectId}
size="sm"
/>
</div>
</div>
);
};
@@ -1,62 +1,48 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useCallback, useRef } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { CommandIcon, FolderPlus, Search, Settings, X } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import {
EUserPermissions,
EUserPermissionsLevel,
PROJECT_TRACKER_ELEMENTS,
WORK_ITEM_TRACKER_ELEMENTS,
WORKSPACE_DEFAULT_SEARCH_RESULT,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { LayersIcon } from "@plane/propel/icons";
import { IWorkspaceSearchResults } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui";
import { cn, getTabIndex } from "@plane/utils";
import { getTabIndex } from "@plane/utils";
// components
import {
ChangeIssueAssignee,
ChangeIssuePriority,
ChangeIssueState,
CommandPaletteHelpActions,
CommandPaletteIssueActions,
CommandPaletteProjectActions,
CommandPaletteSearchResults,
CommandPaletteThemeActions,
CommandPaletteWorkspaceSettingsActions,
CommandInputHeader,
CommandSearchResults,
CommandPageContent,
CommandModalFooter,
CommandConfig,
} from "@/components/command-palette";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
import { useCommandRegistryInitializer, useKeySequenceHandler } from "@/components/command-palette/hooks";
// helpers
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import useDebounce from "@/hooks/use-debounce";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// plane web services
// plane web imports
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
export const CommandModal: React.FC = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId: routerProjectId, workItem } = useParams();
// states
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [placeholder, setPlaceholder] = useState("Type a command or search");
const [resultsCount, setResultsCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
@@ -65,19 +51,17 @@ export const CommandModal: React.FC = observer(() => {
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<string[]>([]);
const [searchInIssue, setSearchInIssue] = useState(false);
// plane hooks
const { t } = useTranslation();
// hooks
const [projectSelectionAction, setProjectSelectionAction] = useState<"navigate" | "cycle" | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
// store hooks
const {
issue: { getIssueById },
fetchIssueWithIdentifier,
} = useIssueDetail();
const { workspaceProjectIds } = useProject();
const { fetchAllCycles } = useCycle();
const { getPartialProjectById } = useProject();
const { platform, isMobile } = usePlatformOS();
const { canPerformAnyCreateAction } = useUser();
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { isCommandPaletteOpen, toggleCommandPaletteModal, activeEntity, clearActiveEntity } = useCommandPalette();
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
// fetch work item details using identifier
@@ -87,7 +71,6 @@ export const CommandModal: React.FC = observer(() => {
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
: null
);
// derived values
const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null;
const issueId = issueDetails?.id;
@@ -95,12 +78,74 @@ export const CommandModal: React.FC = observer(() => {
const page = pages[pages.length - 1];
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { baseTabIndex } = getTabIndex(undefined, isMobile);
const canPerformWorkspaceActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
const openProjectSelection = useCallback(
(action: "navigate" | "cycle") => {
if (!workspaceSlug) return;
setPlaceholder("Search projects");
setSearchTerm("");
setProjectSelectionAction(action);
setSelectedProjectId(null);
setPages((p) => [...p, "open-project"]);
},
[workspaceSlug]
);
const openProjectList = useCallback(() => openProjectSelection("navigate"), [openProjectSelection]);
const openCycleList = useCallback(() => {
if (!workspaceSlug) return;
const currentProject = projectId ? getPartialProjectById(projectId.toString()) : null;
if (currentProject && currentProject.cycle_view) {
setSelectedProjectId(projectId.toString());
setPlaceholder("Search cycles");
setSearchTerm("");
setPages((p) => [...p, "open-cycle"]);
fetchAllCycles(workspaceSlug.toString(), projectId.toString());
} else {
openProjectSelection("cycle");
}
}, [openProjectSelection, workspaceSlug, projectId, getPartialProjectById, fetchAllCycles]);
const openIssueList = useCallback(() => {
if (!workspaceSlug) return;
setPlaceholder("Search issues");
setSearchTerm("");
setPages((p) => [...p, "open-issue"]);
}, [workspaceSlug]);
const closePalette = useCallback(() => {
toggleCommandPaletteModal(false);
setPages([]);
setPlaceholder("Type a command or search");
setProjectSelectionAction(null);
setSelectedProjectId(null);
}, [toggleCommandPaletteModal]);
// Initialize command registry
const { registry, executionContext, initializeCommands } = useCommandRegistryInitializer(
setPages,
setPlaceholder,
setSearchTerm,
closePalette,
openProjectList,
openCycleList,
openIssueList,
isWorkspaceLevel
);
const handleKeySequence = useKeySequenceHandler(registry, executionContext);
useEffect(() => {
if (!isCommandPaletteOpen || !activeEntity) return;
if (activeEntity === "project") openProjectList();
if (activeEntity === "cycle") openCycleList();
if (activeEntity === "issue") openIssueList();
clearActiveEntity();
}, [isCommandPaletteOpen, activeEntity, clearActiveEntity, openProjectList, openCycleList, openIssueList]);
useEffect(() => {
if (issueDetails && isCommandPaletteOpen) {
setSearchInIssue(true);
@@ -113,51 +158,69 @@ export const CommandModal: React.FC = observer(() => {
} else {
setIsWorkspaceLevel(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
const closePalette = () => {
toggleCommandPaletteModal(false);
};
useEffect(() => {
if (!workspaceSlug) return;
const createNewWorkspace = () => {
closePalette();
router.push("/create-workspace");
};
setIsLoading(true);
useEffect(
() => {
if (!workspaceSlug) return;
if (debouncedSearchTerm && page !== "open-issue") {
setIsSearching(true);
workspaceService
.searchWorkspace(workspaceSlug.toString(), {
...(projectId ? { project_id: projectId.toString() } : {}),
search: debouncedSearchTerm,
workspace_search: !projectId ? true : isWorkspaceLevel,
})
.then((results) => {
setResults(results);
const count = Object.keys(results.results).reduce(
(accumulator, key) => (results.results as any)[key]?.length + accumulator,
0
);
setResultsCount(count);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
setIsLoading(false);
setIsSearching(false);
}
}, [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug, page]);
setIsLoading(true);
// Track initialization to prevent multiple calls
const isInitializedRef = useRef(false);
if (debouncedSearchTerm) {
setIsSearching(true);
workspaceService
.searchWorkspace(workspaceSlug.toString(), {
...(projectId ? { project_id: projectId.toString() } : {}),
search: debouncedSearchTerm,
workspace_search: !projectId ? true : isWorkspaceLevel,
})
.then((results) => {
setResults(results);
const count = Object.keys(results.results).reduce(
(accumulator, key) => (results.results as any)[key].length + accumulator,
0
);
setResultsCount(count);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setResults(WORKSPACE_DEFAULT_SEARCH_RESULT);
setIsLoading(false);
setIsSearching(false);
}
},
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
);
// Initialize commands immediately when modal is first opened
if (isCommandPaletteOpen && !isInitializedRef.current) {
initializeCommands();
isInitializedRef.current = true;
} else if (!isCommandPaletteOpen && isInitializedRef.current) {
// Reset initialization flag when modal closes
isInitializedRef.current = false;
}
const handleCommandSelect = useCallback(async (command: CommandConfig) => {
if (command.id === "create-work-item") {
captureClick({
elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON,
});
} else if (command.id === "create-project") {
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON });
}
// Execute command using registry
await registry.executeCommand(command.id, executionContext);
}, [registry, executionContext]);
if (!isCommandPaletteOpen) {
return null;
}
return (
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
@@ -203,7 +266,11 @@ export const CommandModal: React.FC = observer(() => {
}}
shouldFilter={searchTerm.length > 0}
onKeyDown={(e: any) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
const key = e.key.toLowerCase();
if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey && !page && searchTerm === "") {
handleKeySequence(e);
}
if ((e.metaKey || e.ctrlKey) && key === "k") {
e.preventDefault();
e.stopPropagation();
closePalette();
@@ -235,252 +302,80 @@ export const CommandModal: React.FC = observer(() => {
}
}
if (e.key === "Escape" && searchTerm) {
if (e.key === "Escape") {
e.preventDefault();
setSearchTerm("");
if (searchTerm) setSearchTerm("");
else closePalette();
return;
}
if (e.key === "Escape" && !page && !searchTerm) {
if (e.key === "Backspace" && !searchTerm && page) {
e.preventDefault();
closePalette();
}
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault();
setPages((pages) => pages.slice(0, -1));
setPlaceholder("Type a command or search...");
const newPages = pages.slice(0, -1);
const newPage = newPages[newPages.length - 1];
setPages(newPages);
if (!newPage) setPlaceholder("Type a command or search");
else if (newPage === "open-project") setPlaceholder("Search projects");
else if (newPage === "open-cycle") setPlaceholder("Search cycles");
if (page === "open-cycle") setSelectedProjectId(null);
if (page === "open-project" && !newPage) setProjectSelectionAction(null);
}
}}
>
<div className="relative flex items-center px-4 border-0 border-b border-custom-border-200">
<div className="flex items-center gap-2 flex-shrink-0">
<Search
className="h-4 w-4 text-custom-text-200 flex-shrink-0"
aria-hidden="true"
strokeWidth={2}
/>
{searchInIssue && issueDetails && (
<>
<span className="flex items-center text-sm">Update in:</span>
<span className="flex items-center gap-1 rounded px-1.5 py-1 text-sm bg-custom-primary-100/10 ">
{issueDetails.project_id && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={issueDetails.project_id}
textContainerClassName="text-sm text-custom-primary-200"
/>
)}
<X
size={12}
strokeWidth={2}
className="flex-shrink-0 cursor-pointer"
onClick={() => {
setSearchInIssue(false);
}}
/>
</span>
</>
)}
</div>
<Command.Input
className={cn(
"w-full bg-transparent p-4 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
)}
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => setSearchTerm(e)}
autoFocus
tabIndex={baseTabIndex}
/>
</div>
<CommandInputHeader
placeholder={placeholder}
searchTerm={searchTerm}
onSearchTermChange={setSearchTerm}
baseTabIndex={baseTabIndex}
searchInIssue={searchInIssue}
issueDetails={issueDetails}
onClearSearchInIssue={() => setSearchInIssue(false)}
/>
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll p-2">
{searchTerm !== "" && (
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
Search results for{" "}
<span className="font-medium">
{'"'}
{searchTerm}
{'"'}
</span>{" "}
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
</h5>
)}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
</div>
)}
{(isLoading || isSearching) && (
<Command.Loading>
<Loader className="space-y-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
</Command.Loading>
)}
{debouncedSearchTerm !== "" && (
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
)}
{!page && (
<>
{/* issue actions */}
{issueId && issueDetails && searchInIssue && (
<CommandPaletteIssueActions
closePalette={closePalette}
issueDetails={issueDetails}
pages={pages}
setPages={(newPages) => setPages(newPages)}
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
/>
)}
{workspaceSlug &&
workspaceProjectIds &&
workspaceProjectIds.length > 0 &&
canPerformAnyCreateAction && (
<Command.Group heading="Work item">
<Command.Item
onSelect={() => {
closePalette();
captureClick({
elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON,
});
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LayersIcon className="h-3.5 w-3.5" />
Create new work item
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
)}
{workspaceSlug && canPerformWorkspaceActions && (
<Command.Group heading="Project">
<Command.Item
onSelect={() => {
closePalette();
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON });
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{/* project actions */}
{projectId && canPerformAnyCreateAction && (
<CommandPaletteProjectActions closePalette={closePalette} />
)}
{canPerformWorkspaceActions && (
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Search settings...
</div>
</Command.Item>
</Command.Group>
)}
<Command.Group heading="Account">
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Change interface theme...
</div>
</Command.Item>
</Command.Group>
{/* help options */}
<CommandPaletteHelpActions closePalette={closePalette} />
</>
)}
{/* workspace settings actions */}
{page === "settings" && workspaceSlug && (
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
)}
{/* issue details page actions */}
{page === "change-issue-state" && issueDetails && (
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
)}
{/* theme actions */}
{page === "change-interface-theme" && (
<CommandPaletteThemeActions
closePalette={() => {
closePalette();
setPages((pages) => pages.slice(0, -1));
}}
<CommandSearchResults
searchTerm={searchTerm}
debouncedSearchTerm={debouncedSearchTerm}
resultsCount={resultsCount}
isLoading={isLoading}
isSearching={isSearching}
projectId={projectId?.toString()}
isWorkspaceLevel={isWorkspaceLevel}
resolvedPath={resolvedPath}
>
<CommandPageContent
page={page}
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId}
issueDetails={issueDetails || null}
searchTerm={searchTerm}
debouncedSearchTerm={debouncedSearchTerm}
isLoading={isLoading}
isSearching={isSearching}
searchInIssue={searchInIssue}
projectSelectionAction={projectSelectionAction}
selectedProjectId={selectedProjectId}
results={results}
resolvedPath={resolvedPath}
pages={pages}
setPages={setPages}
setPlaceholder={setPlaceholder}
setSearchTerm={setSearchTerm}
setSelectedProjectId={setSelectedProjectId}
fetchAllCycles={fetchAllCycles}
onCommandSelect={handleCommandSelect}
isWorkspaceLevel={isWorkspaceLevel}
/>
)}
</CommandSearchResults>
</Command.List>
</Command>
</div>
{/* Bottom overlay */}
<div className="w-full flex items-center justify-between px-4 py-2 border-t border-custom-border-200 bg-custom-background-90/80 rounded-b-lg">
<div className="flex items-center gap-2">
<span className="text-xs text-custom-text-300">Actions</span>
<div className="flex items-center gap-1">
<div className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
{platform === "MacOS" ? <CommandIcon className="h-2.5 w-2.5 text-custom-text-200" /> : "Ctrl"}
</div>
<kbd className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
K
</kbd>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-custom-text-300">Workspace Level</span>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
disabled={!projectId}
size="sm"
<CommandModalFooter
platform={platform}
isWorkspaceLevel={isWorkspaceLevel}
projectId={projectId?.toString()}
onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
</div>
</Command>
</div>
</Dialog.Panel>
</Transition.Child>
@@ -0,0 +1,172 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
// plane types
import { IWorkspaceSearchResults } from "@plane/types";
// components
import {
CommandPaletteWorkspaceSettingsActions,
ChangeIssueState,
ChangeIssuePriority,
ChangeIssueAssignee,
CommandPaletteThemeActions,
} from "@/components/command-palette";
import { CommandConfig } from "@/components/command-palette/types";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// local imports
import { ProjectSelectionPage, CycleSelectionPage, IssueSelectionPage, MainPage } from "./pages";
interface ICommandPageContentProps {
page: string | undefined;
workspaceSlug: string | undefined;
projectId: string | undefined;
issueId: string | undefined;
issueDetails: { id: string; project_id: string | null; name?: string } | null;
searchTerm: string;
debouncedSearchTerm: string;
isLoading: boolean;
isSearching: boolean;
searchInIssue: boolean;
projectSelectionAction: "navigate" | "cycle" | null;
selectedProjectId: string | null;
results: IWorkspaceSearchResults;
resolvedPath: string;
pages: string[];
setPages: (pages: string[] | ((prev: string[]) => string[])) => void;
setPlaceholder: (placeholder: string) => void;
setSearchTerm: (term: string) => void;
setSelectedProjectId: (id: string | null) => void;
fetchAllCycles: (workspaceSlug: string, projectId: string) => void;
onCommandSelect: (command: CommandConfig) => void;
isWorkspaceLevel?: boolean;
}
export const CommandPageContent: React.FC<ICommandPageContentProps> = observer((props) => {
const {
page,
workspaceSlug,
projectId,
issueId,
issueDetails,
searchTerm,
debouncedSearchTerm,
isLoading,
isSearching,
searchInIssue,
projectSelectionAction,
selectedProjectId,
results,
resolvedPath,
pages,
setPages,
setPlaceholder,
setSearchTerm,
setSelectedProjectId,
fetchAllCycles,
onCommandSelect,
isWorkspaceLevel = false,
} = props;
// store hooks
const { toggleCommandPaletteModal } = useCommandPalette();
const {
issue: { getIssueById },
} = useIssueDetail();
// Main page content (no specific page)
if (!page) {
return (
<MainPage
projectId={projectId}
issueId={issueId}
issueDetails={issueDetails}
debouncedSearchTerm={debouncedSearchTerm}
results={results}
searchInIssue={searchInIssue}
pages={pages}
setPages={setPages}
setPlaceholder={setPlaceholder}
setSearchTerm={setSearchTerm}
onCommandSelect={onCommandSelect}
/>
);
}
// Project selection page
if (page === "open-project" && workspaceSlug) {
return (
<ProjectSelectionPage
workspaceSlug={workspaceSlug}
projectSelectionAction={projectSelectionAction}
setSelectedProjectId={setSelectedProjectId}
fetchAllCycles={fetchAllCycles}
setPages={setPages}
setPlaceholder={setPlaceholder}
/>
);
}
// Cycle selection page
if (page === "open-cycle" && workspaceSlug && selectedProjectId) {
return <CycleSelectionPage workspaceSlug={workspaceSlug} selectedProjectId={selectedProjectId} />;
}
// Issue selection page
if (page === "open-issue" && workspaceSlug) {
return (
<IssueSelectionPage
workspaceSlug={workspaceSlug}
projectId={projectId}
searchTerm={searchTerm}
debouncedSearchTerm={debouncedSearchTerm}
isLoading={isLoading}
isSearching={isSearching}
resolvedPath={resolvedPath}
isWorkspaceLevel={isWorkspaceLevel}
/>
);
}
// Workspace settings page
if (page === "settings" && workspaceSlug) {
return <CommandPaletteWorkspaceSettingsActions closePalette={() => toggleCommandPaletteModal(false)} />;
}
// Issue details pages
if (page === "change-issue-state" && issueDetails && issueId && getIssueById) {
const fullIssue = getIssueById(issueId);
if (fullIssue) {
return <ChangeIssueState closePalette={() => toggleCommandPaletteModal(false)} issue={fullIssue} />;
}
}
if (page === "change-issue-priority" && issueDetails && issueId && getIssueById) {
const fullIssue = getIssueById(issueId);
if (fullIssue) {
return <ChangeIssuePriority closePalette={() => toggleCommandPaletteModal(false)} issue={fullIssue} />;
}
}
if (page === "change-issue-assignee" && issueDetails && issueId && getIssueById) {
const fullIssue = getIssueById(issueId);
if (fullIssue) {
return <ChangeIssueAssignee closePalette={() => toggleCommandPaletteModal(false)} issue={fullIssue} />;
}
}
// Theme actions page
if (page === "change-interface-theme") {
return (
<CommandPaletteThemeActions
closePalette={() => {
toggleCommandPaletteModal(false);
setPages((pages) => pages.slice(0, -1));
}}
/>
);
}
return null;
});
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, FC, useMemo } from "react";
import React, { useCallback, useEffect, FC, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@@ -32,6 +32,7 @@ import {
getWorkspaceShortcutsList,
handleAdditionalKeyDownEvents,
} from "@/plane-web/helpers/command-palette";
import type { CommandPaletteEntity } from "@/store/base-command-palette.store";
export const CommandPalette: FC = observer(() => {
// router params
@@ -41,7 +42,8 @@ export const CommandPalette: FC = observer(() => {
const { toggleSidebar, toggleExtendedSidebar } = useAppTheme();
const { platform } = usePlatformOS();
const { data: currentUser, canPerformAnyCreateAction } = useUser();
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette();
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen, activateEntity } =
useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
@@ -158,6 +160,9 @@ export const CommandPalette: FC = observer(() => {
[]
);
const keySequence = useRef("");
const sequenceTimeout = useRef<number | null>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
@@ -173,11 +178,15 @@ export const CommandPalette: FC = observer(() => {
toggleCommandPaletteModal(true);
}
// if on input, textarea or editor, don't do anything
// if on input, textarea, editor, or clickable elements, don't do anything
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
(e.target as Element)?.classList?.contains("ProseMirror")
(e.target as Element)?.classList?.contains("ProseMirror") ||
(e.target as Element)?.tagName === "A" ||
(e.target as Element)?.tagName === "BUTTON" ||
(e.target as Element)?.closest("a") ||
(e.target as Element)?.closest("button")
)
return;
@@ -186,6 +195,28 @@ export const CommandPalette: FC = observer(() => {
toggleShortcutModal(true);
}
if (!cmdClicked && !altKey && !shiftKey && !isAnyModalOpen) {
keySequence.current = (keySequence.current + keyPressed).slice(-2);
if (sequenceTimeout.current) window.clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = window.setTimeout(() => {
keySequence.current = "";
}, 500);
// Check if the current key sequence matches a command
const entityShortcutMap: Record<string, CommandPaletteEntity> = {
op: "project",
oc: "cycle",
om: "module",
oi: "issue",
};
const entity = entityShortcutMap[keySequence.current];
if (entity) {
e.preventDefault();
activateEntity(entity);
keySequence.current = "";
return;
}
}
if (deleteKey) {
if (performProjectBulkDeleteActions()) {
shortcutsList.project.delete.action();
@@ -241,6 +272,7 @@ export const CommandPalette: FC = observer(() => {
projectId,
shortcutsList,
toggleCommandPaletteModal,
activateEntity,
toggleShortcutModal,
toggleSidebar,
toggleExtendedSidebar,
@@ -0,0 +1,193 @@
"use client";
import { commandExecutor } from "./command-executor";
import { CommandConfig, CommandExecutionContext, CommandGroup, CommandContext } from "./types";
/**
* Enhanced CommandRegistry with context-aware filtering and multi-step execution
*/
export class CommandRegistry {
private commands = new Map<string, CommandConfig>();
private keySequenceMap = new Map<string, string>();
private shortcutMap = new Map<string, string>();
// ============================================================================
// Registration
// ============================================================================
register(command: CommandConfig): void {
this.commands.set(command.id, command);
if (command.keySequence) {
this.keySequenceMap.set(command.keySequence, command.id);
}
if (command.shortcut) {
this.shortcutMap.set(command.shortcut, command.id);
}
}
registerMultiple(commands: CommandConfig[]): void {
commands.forEach((command) => this.register(command));
}
// ============================================================================
// Command Retrieval
// ============================================================================
getCommand(id: string): CommandConfig | undefined {
return this.commands.get(id);
}
getCommandByKeySequence(sequence: string): CommandConfig | undefined {
const commandId = this.keySequenceMap.get(sequence);
return commandId ? this.commands.get(commandId) : undefined;
}
getCommandByShortcut(shortcut: string): CommandConfig | undefined {
const commandId = this.shortcutMap.get(shortcut);
return commandId ? this.commands.get(commandId) : undefined;
}
getAllCommands(): CommandConfig[] {
return Array.from(this.commands.values());
}
// ============================================================================
// Context-Aware Filtering
// ============================================================================
/**
* Get all visible commands based on context
* Filters by visibility, enablement, and route context
*/
getVisibleCommands(context: CommandContext): CommandConfig[] {
return Array.from(this.commands.values()).filter((command) => this.isCommandVisible(command, context));
}
/**
* Get commands by group with context filtering
*/
getCommandsByGroup(group: CommandGroup, context: CommandContext): CommandConfig[] {
return this.getVisibleCommands(context).filter((command) => command.group === group);
}
/**
* Get contextual commands - commands that are specific to the current route
* These are commands that only appear when you're on a specific page/entity
*/
getContextualCommands(context: CommandContext): CommandConfig[] {
return this.getVisibleCommands(context).filter(
(command) => command.type === "contextual" || command.showOnRoutes?.length
);
}
/**
* Check if a command should be visible in the current context
*/
private isCommandVisible(command: CommandConfig, context: CommandContext): boolean {
// Check visibility function
if (command.isVisible && !command.isVisible(context)) {
return false;
}
// Check enabled function
if (command.isEnabled && !command.isEnabled(context)) {
return false;
}
// Check route-based filtering
if (!this.isCommandVisibleForRoute(command, context)) {
return false;
}
return true;
}
/**
* Check if command should be visible based on route context
*/
private isCommandVisibleForRoute(command: CommandConfig, context: CommandContext): boolean {
const currentRoute = context.routeContext;
// If command specifies routes to show on
if (command.showOnRoutes && command.showOnRoutes.length > 0) {
if (!currentRoute || !command.showOnRoutes.includes(currentRoute)) {
return false;
}
}
// If command specifies routes to hide on
if (command.hideOnRoutes && command.hideOnRoutes.length > 0) {
if (currentRoute && command.hideOnRoutes.includes(currentRoute)) {
return false;
}
}
return true;
}
// ============================================================================
// Command Execution
// ============================================================================
/**
* Execute a command using the new multi-step executor
*/
async executeCommand(commandId: string, executionContext: CommandExecutionContext): Promise<void> {
const command = this.getCommand(commandId);
if (!command) {
console.warn(`Command ${commandId} not found`);
return;
}
// Use the command executor for proper multi-step handling
await commandExecutor.executeCommand(command, executionContext);
}
/**
* Execute a key sequence command
*/
async executeKeySequence(sequence: string, executionContext: CommandExecutionContext): Promise<boolean> {
const command = this.getCommandByKeySequence(sequence);
if (!command) {
return false;
}
if (command.isEnabled && !command.isEnabled(executionContext.context)) {
return false;
}
await commandExecutor.executeCommand(command, executionContext);
return true;
}
/**
* Execute a shortcut command
*/
async executeShortcut(shortcut: string, executionContext: CommandExecutionContext): Promise<boolean> {
const command = this.getCommandByShortcut(shortcut);
if (!command) {
return false;
}
if (command.isEnabled && !command.isEnabled(executionContext.context)) {
return false;
}
await commandExecutor.executeCommand(command, executionContext);
return true;
}
// ============================================================================
// Utility
// ============================================================================
clear(): void {
this.commands.clear();
this.keySequenceMap.clear();
this.shortcutMap.clear();
}
}
export const commandRegistry = new CommandRegistry();
@@ -0,0 +1,79 @@
"use client";
import React from "react";
import { Command } from "cmdk";
import { CommandConfig, CommandGroup as CommandGroupType } from "./types";
interface CommandRendererProps {
commands: CommandConfig[];
onCommandSelect: (command: CommandConfig) => void;
}
const groupPriority: Record<CommandGroupType, number> = {
navigate: 1,
create: 2,
project: 3,
workspace: 4,
account: 5,
help: 6,
contextual: 7,
};
const groupTitles: Record<CommandGroupType, string> = {
navigate: "Navigate",
create: "Work item",
project: "Project",
workspace: "Workspace Settings",
account: "Account",
help: "Help",
contextual: "Actions",
};
export const CommandRenderer: React.FC<CommandRendererProps> = ({ commands, onCommandSelect }) => {
const commandsByGroup = commands.reduce(
(acc, command) => {
const group = command.group || "help";
if (!acc[group]) acc[group] = [];
acc[group].push(command);
return acc;
},
{} as Record<CommandGroupType, CommandConfig[]>
);
const sortedGroups = Object.keys(commandsByGroup).sort((a, b) => {
const aPriority = groupPriority[a as CommandGroupType] || 999;
const bPriority = groupPriority[b as CommandGroupType] || 999;
return aPriority - bPriority;
}) as CommandGroupType[];
return (
<>
{sortedGroups.map((groupKey) => {
const groupCommands = commandsByGroup[groupKey];
if (!groupCommands || groupCommands.length === 0) return null;
return (
<Command.Group key={groupKey} heading={groupTitles[groupKey]}>
{groupCommands.map((command) => (
<Command.Item key={command.id} onSelect={() => onCommandSelect(command)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
{command.icon && <command.icon className="h-3.5 w-3.5" />}
{command.title}
</div>
{(command.shortcut || command.keySequence) && (
<div className="flex items-center gap-1">
{command.keySequence ? (
command.keySequence.split("").map((key, index) => <kbd key={index}>{key.toUpperCase()}</kbd>)
) : (
<kbd>{command.shortcut?.toUpperCase()}</kbd>
)}
</div>
)}
</Command.Item>
))}
</Command.Group>
);
})}
</>
);
};
@@ -0,0 +1,96 @@
"use client";
import React, { useState, useEffect } from "react";
import { Loader as Spinner } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
interface ICommandSearchResultsProps {
searchTerm: string;
debouncedSearchTerm: string;
resultsCount: number;
isLoading: boolean;
isSearching: boolean;
projectId: string | undefined;
isWorkspaceLevel: boolean;
resolvedPath: string;
children?: React.ReactNode;
}
export const CommandSearchResults: React.FC<ICommandSearchResultsProps> = (props) => {
const {
searchTerm,
debouncedSearchTerm,
resultsCount,
isLoading,
isSearching,
projectId,
isWorkspaceLevel,
resolvedPath,
children,
} = props;
// plane hooks
const { t } = useTranslation();
// State for delayed loading indicator
const [showDelayedLoader, setShowDelayedLoader] = useState(false);
// Only show loader after a delay to prevent flash during quick searches
useEffect(() => {
let timeoutId: number;
if (isLoading || isSearching) {
// Only show loader if there's a search term and after 300ms delay
if (searchTerm.trim() !== "") {
timeoutId = window.setTimeout(() => {
setShowDelayedLoader(true);
}, 300);
}
} else {
// Immediately hide loader when not loading
setShowDelayedLoader(false);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [isLoading, isSearching, searchTerm]);
return (
<>
{searchTerm !== "" && (
<div className="flex items-center justify-between mx-[3px] my-4">
<h5 className="text-xs text-custom-text-100">
Search results for{" "}
<span className="font-medium">
{'"'}
{searchTerm}
{'"'}
</span>{" "}
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
</h5>
{/* Inline loading indicator - less intrusive */}
{showDelayedLoader && (
<div className="flex items-center gap-1.5 text-xs text-custom-text-300">
<Spinner className="size-3 animate-spin" />
<span className="animate-pulse">Searching...</span>
</div>
)}
</div>
)}
{/* Show empty state only when not loading and no results */}
{!isLoading && !isSearching && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
</div>
)}
{children}
</>
);
};
@@ -0,0 +1,32 @@
"use client";
import { FolderPlus, Settings } from "lucide-react";
import { CommandConfig } from "../types";
export const createAccountCommands = (
createNewWorkspace: () => void,
openThemeSettings: () => void
): CommandConfig[] => [
{
id: "create-workspace",
type: "creation",
group: "account",
title: "Create new workspace",
description: "Create a new workspace",
icon: FolderPlus,
isEnabled: () => true,
isVisible: () => true,
action: createNewWorkspace,
},
{
id: "change-theme",
type: "settings",
group: "account",
title: "Change interface theme",
description: "Change the interface theme",
icon: Settings,
isEnabled: () => true,
isVisible: () => true,
action: openThemeSettings,
},
];
@@ -0,0 +1,384 @@
"use client";
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users, Archive, Copy } from "lucide-react";
import { DoubleCircleIcon } from "@plane/propel/icons";
import { CommandConfig } from "../types";
/**
* Contextual commands - Commands that appear only in specific contexts
* These are context-aware actions for issues, cycles, modules, projects, etc.
*/
// ============================================================================
// Issue Contextual Commands
// ============================================================================
export const createIssueContextualCommands = (
currentUserId: string,
updateIssue: (updates: any) => Promise<void>,
toggleDeleteIssueModal: (open: boolean) => void,
copyIssueUrl: () => void
): CommandConfig[] => [
{
id: "issue-change-state",
type: "contextual",
group: "contextual",
title: "Change state",
description: "Change the state of this work item",
icon: DoubleCircleIcon,
showOnRoutes: ["issue"],
steps: [
{
type: "select-state",
placeholder: "Select state",
dataKey: "stateId",
},
{
type: "action",
action: async (context) => {
if (context.stepData?.stateId) {
await updateIssue({ state: context.stepData.stateId });
}
},
},
],
isVisible: (context) => Boolean(context.issueId),
},
{
id: "issue-change-priority",
type: "contextual",
group: "contextual",
title: "Change priority",
description: "Change the priority of this work item",
icon: Signal,
showOnRoutes: ["issue"],
steps: [
{
type: "select-priority",
placeholder: "Select priority",
dataKey: "priority",
},
{
type: "action",
action: async (context) => {
if (context.stepData?.priority) {
await updateIssue({ priority: context.stepData.priority });
}
},
},
],
isVisible: (context) => Boolean(context.issueId),
},
{
id: "issue-change-assignee",
type: "contextual",
group: "contextual",
title: "Assign to",
description: "Change assignees for this work item",
icon: Users,
showOnRoutes: ["issue"],
steps: [
{
type: "select-assignee",
placeholder: "Select assignee",
dataKey: "assigneeIds",
},
{
type: "action",
action: async (context) => {
if (context.stepData?.assigneeIds) {
await updateIssue({ assignee_ids: context.stepData.assigneeIds });
}
},
},
],
isVisible: (context) => Boolean(context.issueId),
},
{
id: "issue-assign-to-me",
type: "contextual",
group: "contextual",
title: "Assign to me",
description: "Assign this work item to yourself",
icon: UserPlus2,
showOnRoutes: ["issue"],
steps: [
{
type: "action",
action: async (context) => {
// This will be implemented with actual issue data
await updateIssue({ assignee_ids: [currentUserId] });
},
},
],
isVisible: (context) => Boolean(context.issueId),
},
{
id: "issue-unassign-from-me",
type: "contextual",
group: "contextual",
title: "Unassign from me",
description: "Remove yourself from assignees",
icon: UserMinus2,
showOnRoutes: ["issue"],
steps: [
{
type: "action",
action: async (context) => {
// This will be implemented with actual issue data
// to remove current user from assignees
},
},
],
isVisible: (context) => Boolean(context.issueId),
},
{
id: "issue-copy-url",
type: "contextual",
group: "contextual",
title: "Copy work item URL",
description: "Copy the URL of this work item to clipboard",
icon: LinkIcon,
showOnRoutes: ["issue"],
steps: [
{
type: "action",
action: () => {
copyIssueUrl();
},
},
],
isVisible: (context) => Boolean(context.issueId),
},
{
id: "issue-delete",
type: "contextual",
group: "contextual",
title: "Delete work item",
description: "Delete this work item",
icon: Trash2,
showOnRoutes: ["issue"],
steps: [
{
type: "modal",
modalAction: () => {
toggleDeleteIssueModal(true);
},
},
],
isVisible: (context) => Boolean(context.issueId),
},
];
// ============================================================================
// Cycle Contextual Commands
// ============================================================================
export const createCycleContextualCommands = (
archiveCycle: (cycleId: string) => Promise<void>,
copyCycleUrl: () => void,
toggleDeleteCycleModal: (open: boolean) => void
): CommandConfig[] => [
{
id: "cycle-copy-url",
type: "contextual",
group: "contextual",
title: "Copy cycle URL",
description: "Copy the URL of this cycle to clipboard",
icon: LinkIcon,
showOnRoutes: ["cycle"],
steps: [
{
type: "action",
action: () => {
copyCycleUrl();
},
},
],
isVisible: (context) => Boolean(context.cycleId),
},
{
id: "cycle-archive",
type: "contextual",
group: "contextual",
title: "Archive cycle",
description: "Archive this cycle",
icon: Archive,
showOnRoutes: ["cycle"],
steps: [
{
type: "action",
action: async (context) => {
if (context.cycleId) {
await archiveCycle(context.cycleId);
}
},
},
],
isVisible: (context) => Boolean(context.cycleId),
},
{
id: "cycle-delete",
type: "contextual",
group: "contextual",
title: "Delete cycle",
description: "Delete this cycle",
icon: Trash2,
showOnRoutes: ["cycle"],
steps: [
{
type: "modal",
modalAction: () => {
toggleDeleteCycleModal(true);
},
},
],
isVisible: (context) => Boolean(context.cycleId),
},
];
// ============================================================================
// Module Contextual Commands
// ============================================================================
export const createModuleContextualCommands = (
archiveModule: (moduleId: string) => Promise<void>,
copyModuleUrl: () => void,
toggleDeleteModuleModal: (open: boolean) => void
): CommandConfig[] => [
{
id: "module-copy-url",
type: "contextual",
group: "contextual",
title: "Copy module URL",
description: "Copy the URL of this module to clipboard",
icon: LinkIcon,
showOnRoutes: ["module"],
steps: [
{
type: "action",
action: () => {
copyModuleUrl();
},
},
],
isVisible: (context) => Boolean(context.moduleId),
},
{
id: "module-archive",
type: "contextual",
group: "contextual",
title: "Archive module",
description: "Archive this module",
icon: Archive,
showOnRoutes: ["module"],
steps: [
{
type: "action",
action: async (context) => {
if (context.moduleId) {
await archiveModule(context.moduleId);
}
},
},
],
isVisible: (context) => Boolean(context.moduleId),
},
{
id: "module-delete",
type: "contextual",
group: "contextual",
title: "Delete module",
description: "Delete this module",
icon: Trash2,
showOnRoutes: ["module"],
steps: [
{
type: "modal",
modalAction: () => {
toggleDeleteModuleModal(true);
},
},
],
isVisible: (context) => Boolean(context.moduleId),
},
];
// ============================================================================
// Project Contextual Commands
// ============================================================================
export const createProjectContextualCommands = (
copyProjectUrl: () => void,
leaveProject: () => Promise<void>,
archiveProject: () => Promise<void>
): CommandConfig[] => [
{
id: "project-copy-url",
type: "contextual",
group: "contextual",
title: "Copy project URL",
description: "Copy the URL of this project to clipboard",
icon: Copy,
showOnRoutes: ["project"],
steps: [
{
type: "action",
action: () => {
copyProjectUrl();
},
},
],
isVisible: (context) => Boolean(context.projectId),
},
{
id: "project-leave",
type: "contextual",
group: "contextual",
title: "Leave project",
description: "Leave this project",
icon: UserMinus2,
showOnRoutes: ["project"],
steps: [
{
type: "action",
action: async () => {
await leaveProject();
},
},
],
isVisible: (context) => Boolean(context.projectId),
isEnabled: (context) => !Boolean(context.canPerformProjectActions), // Only non-admins can leave
},
{
id: "project-archive",
type: "contextual",
group: "contextual",
title: "Archive project",
description: "Archive this project",
icon: Archive,
showOnRoutes: ["project"],
steps: [
{
type: "action",
action: async () => {
await archiveProject();
},
},
],
isVisible: (context) => Boolean(context.projectId),
isEnabled: (context) => Boolean(context.canPerformProjectActions),
},
];
@@ -0,0 +1,167 @@
"use client";
import { FolderPlus, FileText, Layers } from "lucide-react";
import { LayersIcon, ContrastIcon, DiceIcon } from "@plane/propel/icons";
import { CommandConfig, CommandExecutionContext } from "../types";
/**
* Creation commands - Create any entity in the app
* Uses the new modal step type for opening creation modals
*/
export const createCreationCommands = (
executionContext: CommandExecutionContext,
toggleCreateIssueModal: (open: boolean) => void,
toggleCreateProjectModal: (open: boolean) => void,
toggleCreateCycleModal: (open: boolean) => void,
toggleCreateModuleModal: (open: boolean) => void,
toggleCreateViewModal: (open: boolean) => void,
toggleCreatePageModal: (params: { isOpen: boolean }) => void
): CommandConfig[] => [
// ============================================================================
// Work Item Creation
// ============================================================================
{
id: "create-work-item",
type: "creation",
group: "create",
title: "Create new work item",
description: "Create a new work item in the current project",
icon: LayersIcon,
shortcut: "c",
steps: [
{
type: "modal",
modalAction: () => {
executionContext.closePalette();
toggleCreateIssueModal(true);
},
},
],
isEnabled: (context) => Boolean(context.canPerformAnyCreateAction),
isVisible: (context) => Boolean(context.workspaceSlug),
},
// ============================================================================
// Project Creation
// ============================================================================
{
id: "create-project",
type: "creation",
group: "create",
title: "Create new project",
description: "Create a new project in the current workspace",
icon: FolderPlus,
shortcut: "p",
steps: [
{
type: "modal",
modalAction: () => {
executionContext.closePalette();
toggleCreateProjectModal(true);
},
},
],
isEnabled: (context) => Boolean(context.canPerformWorkspaceActions),
isVisible: (context) => Boolean(context.workspaceSlug),
},
// ============================================================================
// Cycle Creation (Project-level only)
// ============================================================================
{
id: "create-cycle",
type: "creation",
group: "create",
title: "Create new cycle",
description: "Create a new cycle in the current project",
icon: ContrastIcon,
shortcut: "q",
showOnRoutes: ["project", "cycle"],
steps: [
{
type: "modal",
modalAction: () => {
executionContext.closePalette();
toggleCreateCycleModal(true);
},
},
],
isEnabled: (context) => Boolean(context.canPerformProjectActions),
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
// ============================================================================
// Module Creation (Project-level only)
// ============================================================================
{
id: "create-module",
type: "creation",
group: "create",
title: "Create new module",
description: "Create a new module in the current project",
icon: DiceIcon,
shortcut: "m",
showOnRoutes: ["project", "module"],
steps: [
{
type: "modal",
modalAction: () => {
executionContext.closePalette();
toggleCreateModuleModal(true);
},
},
],
isEnabled: (context) => Boolean(context.canPerformProjectActions),
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
// ============================================================================
// View Creation (Project-level only)
// ============================================================================
{
id: "create-view",
type: "creation",
group: "create",
title: "Create new view",
description: "Create a new view in the current project",
icon: Layers,
shortcut: "v",
showOnRoutes: ["project", "view"],
steps: [
{
type: "modal",
modalAction: () => {
executionContext.closePalette();
toggleCreateViewModal(true);
},
},
],
isEnabled: (context) => Boolean(context.canPerformProjectActions),
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
// ============================================================================
// Page Creation (Project-level only)
// ============================================================================
{
id: "create-page",
type: "creation",
group: "create",
title: "Create new page",
description: "Create a new page in the current project",
icon: FileText,
shortcut: "d",
showOnRoutes: ["project", "page"],
steps: [
{
type: "modal",
modalAction: () => {
executionContext.closePalette();
toggleCreatePageModal({ isOpen: true });
},
},
],
isEnabled: (context) => Boolean(context.canPerformProjectActions),
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
];
@@ -0,0 +1,228 @@
"use client";
import {
LogOut,
UserPlus,
Copy,
SidebarIcon,
Download,
Moon,
Sun,
Monitor,
UserMinus,
Bell,
BellOff,
} from "lucide-react";
import { CommandConfig } from "../types";
/**
* Extra action commands - Miscellaneous actions and utilities
* These are commands that don't fit into other categories but provide important functionality
*/
export const createExtraCommands = (
signOut: () => void,
toggleInviteModal: () => void,
copyCurrentPageUrl: () => void,
toggleSidebar: () => void,
leaveWorkspace: () => Promise<void>,
setTheme: (theme: "light" | "dark" | "system") => void
): CommandConfig[] => [
// ============================================================================
// User Account Actions
// ============================================================================
{
id: "sign-out",
type: "action",
group: "account",
title: "Sign out",
description: "Sign out of your account",
icon: LogOut,
steps: [
{
type: "action",
action: () => {
signOut();
},
},
],
isVisible: () => true,
},
// ============================================================================
// Workspace Actions
// ============================================================================
{
id: "invite-members",
type: "action",
group: "workspace",
title: "Invite members",
description: "Invite people to this workspace",
icon: UserPlus,
steps: [
{
type: "modal",
modalAction: () => {
toggleInviteModal();
},
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
isEnabled: (context) => Boolean(context.canPerformWorkspaceActions),
},
{
id: "leave-workspace",
type: "action",
group: "workspace",
title: "Leave workspace",
description: "Leave this workspace",
icon: UserMinus,
steps: [
{
type: "action",
action: async () => {
await leaveWorkspace();
},
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
isEnabled: (context) => !Boolean(context.canPerformWorkspaceActions), // Only non-admins can leave
},
// ============================================================================
// UI Actions
// ============================================================================
{
id: "copy-page-url",
type: "action",
group: "help",
title: "Copy page URL",
description: "Copy the URL of the current page to clipboard",
icon: Copy,
steps: [
{
type: "action",
action: () => {
copyCurrentPageUrl();
},
},
],
isVisible: () => true,
},
{
id: "toggle-sidebar",
type: "action",
group: "help",
title: "Toggle sidebar",
description: "Show or hide the sidebar",
icon: SidebarIcon,
shortcut: "b",
steps: [
{
type: "action",
action: () => {
toggleSidebar();
},
},
],
isVisible: () => true,
},
// ============================================================================
// Theme Actions
// ============================================================================
{
id: "theme-light",
type: "action",
group: "account",
title: "Switch to light theme",
description: "Use light theme",
icon: Sun,
steps: [
{
type: "action",
action: () => {
setTheme("light");
},
},
],
isVisible: () => true,
},
{
id: "theme-dark",
type: "action",
group: "account",
title: "Switch to dark theme",
description: "Use dark theme",
icon: Moon,
steps: [
{
type: "action",
action: () => {
setTheme("dark");
},
},
],
isVisible: () => true,
},
{
id: "theme-system",
type: "action",
group: "account",
title: "Use system theme",
description: "Follow system theme preference",
icon: Monitor,
steps: [
{
type: "action",
action: () => {
setTheme("system");
},
},
],
isVisible: () => true,
},
// ============================================================================
// Download Links (Mobile & Desktop apps)
// ============================================================================
{
id: "download-desktop-app",
type: "action",
group: "help",
title: "Download desktop app",
description: "Download Plane for desktop",
icon: Download,
steps: [
{
type: "action",
action: () => {
window.open("https://plane.so/downloads", "_blank");
},
},
],
isVisible: () => true,
},
{
id: "download-mobile-app",
type: "action",
group: "help",
title: "Download mobile app",
description: "Download Plane for mobile",
icon: Download,
steps: [
{
type: "action",
action: () => {
window.open("https://plane.so/downloads", "_blank");
},
},
],
isVisible: () => true,
},
];
@@ -0,0 +1,6 @@
export * from "./navigation-commands";
export * from "./creation-commands";
export * from "./account-commands";
export * from "./settings-commands";
export * from "./contextual-commands";
export * from "./extra-commands";
@@ -0,0 +1,337 @@
"use client";
import { Search, FolderKanban, LayoutDashboard, Settings, FileText, Layers } from "lucide-react";
import { ContrastIcon, DiceIcon } from "@plane/propel/icons";
import { CommandConfig } from "../types";
/**
* Navigation commands - Navigate to all pages in the app
* Uses the new multi-step system for complex navigation flows
*/
export const createNavigationCommands = (): CommandConfig[] => [
// ============================================================================
// Project Navigation
// ============================================================================
{
id: "navigate-project",
type: "navigation",
group: "navigate",
title: "Open project",
description: "Search and navigate to a project",
icon: Search,
keySequence: "op",
steps: [
{
type: "select-project",
placeholder: "Search projects",
dataKey: "projectId",
},
{
type: "navigate",
route: "/:workspace/projects/:projectId/issues",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
// ============================================================================
// Cycle Navigation
// ============================================================================
{
id: "navigate-cycle",
type: "navigation",
group: "navigate",
title: "Open cycle",
description: "Search and navigate to a cycle",
icon: ContrastIcon,
keySequence: "oc",
steps: [
// If no project context, first select project
{
type: "select-project",
placeholder: "Search projects",
condition: (context) => !context.projectId,
dataKey: "projectId",
},
// Then select cycle
{
type: "select-cycle",
placeholder: "Search cycles",
dataKey: "cycleId",
},
// Navigate to cycle
{
type: "navigate",
route: (context) => {
const projectId = context.projectId || context.stepData?.projectId;
const cycleId = context.stepData?.cycleId;
return `/${context.workspaceSlug}/projects/${projectId}/cycles/${cycleId}`;
},
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
// ============================================================================
// Module Navigation
// ============================================================================
{
id: "navigate-module",
type: "navigation",
group: "navigate",
title: "Open module",
description: "Search and navigate to a module",
icon: DiceIcon,
keySequence: "om",
steps: [
// If no project context, first select project
{
type: "select-project",
placeholder: "Search projects",
condition: (context) => !context.projectId,
dataKey: "projectId",
},
// Then select module
{
type: "select-module",
placeholder: "Search modules",
dataKey: "moduleId",
},
// Navigate to module
{
type: "navigate",
route: (context) => {
const projectId = context.projectId || context.stepData?.projectId;
const moduleId = context.stepData?.moduleId;
return `/${context.workspaceSlug}/projects/${projectId}/modules/${moduleId}`;
},
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
// ============================================================================
// Issue Navigation (Recent)
// ============================================================================
{
id: "navigate-issue",
type: "navigation",
group: "navigate",
title: "Open recent work items",
description: "Search and navigate to recent work items",
icon: Layers,
keySequence: "oi",
steps: [
{
type: "select-issue",
placeholder: "Search work items",
dataKey: "issueId",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
// ============================================================================
// Direct Page Navigation (No selection required)
// ============================================================================
{
id: "navigate-dashboard",
type: "navigation",
group: "navigate",
title: "Go to Dashboard",
description: "Navigate to workspace dashboard",
icon: LayoutDashboard,
steps: [
{
type: "navigate",
route: "/:workspace",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
{
id: "navigate-all-issues",
type: "navigation",
group: "navigate",
title: "Go to All Issues",
description: "View all issues across workspace",
icon: Layers,
steps: [
{
type: "navigate",
route: "/:workspace/workspace-views/all-issues",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
{
id: "navigate-assigned-issues",
type: "navigation",
group: "navigate",
title: "Go to Assigned",
description: "View issues assigned to you",
icon: Layers,
steps: [
{
type: "navigate",
route: "/:workspace/workspace-views/assigned",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
{
id: "navigate-created-issues",
type: "navigation",
group: "navigate",
title: "Go to Created",
description: "View issues created by you",
icon: Layers,
steps: [
{
type: "navigate",
route: "/:workspace/workspace-views/created",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
{
id: "navigate-subscribed-issues",
type: "navigation",
group: "navigate",
title: "Go to Subscribed",
description: "View issues you're subscribed to",
icon: Layers,
steps: [
{
type: "navigate",
route: "/:workspace/workspace-views/subscribed",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
{
id: "navigate-projects-list",
type: "navigation",
group: "navigate",
title: "Go to Projects",
description: "View all projects",
icon: FolderKanban,
steps: [
{
type: "navigate",
route: "/:workspace/projects",
},
],
isVisible: (context) => Boolean(context.workspaceSlug),
},
// ============================================================================
// Project-Level Navigation (Only visible in project context)
// ============================================================================
{
id: "navigate-project-issues",
type: "navigation",
group: "navigate",
title: "Go to Issues",
description: "Navigate to project issues",
icon: Layers,
showOnRoutes: ["project"],
steps: [
{
type: "navigate",
route: "/:workspace/projects/:project/issues",
},
],
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
{
id: "navigate-project-cycles",
type: "navigation",
group: "navigate",
title: "Go to Cycles",
description: "Navigate to project cycles",
icon: ContrastIcon,
showOnRoutes: ["project"],
steps: [
{
type: "navigate",
route: "/:workspace/projects/:project/cycles",
},
],
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
{
id: "navigate-project-modules",
type: "navigation",
group: "navigate",
title: "Go to Modules",
description: "Navigate to project modules",
icon: DiceIcon,
showOnRoutes: ["project"],
steps: [
{
type: "navigate",
route: "/:workspace/projects/:project/modules",
},
],
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
{
id: "navigate-project-views",
type: "navigation",
group: "navigate",
title: "Go to Views",
description: "Navigate to project views",
icon: Layers,
showOnRoutes: ["project"],
steps: [
{
type: "navigate",
route: "/:workspace/projects/:project/views",
},
],
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
{
id: "navigate-project-pages",
type: "navigation",
group: "navigate",
title: "Go to Pages",
description: "Navigate to project pages",
icon: FileText,
showOnRoutes: ["project"],
steps: [
{
type: "navigate",
route: "/:workspace/projects/:project/pages",
},
],
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
{
id: "navigate-project-settings",
type: "navigation",
group: "navigate",
title: "Go to Project Settings",
description: "Navigate to project settings",
icon: Settings,
showOnRoutes: ["project"],
steps: [
{
type: "navigate",
route: "/:workspace/projects/:project/settings",
},
],
isVisible: (context) => Boolean(context.workspaceSlug && context.projectId),
},
];
@@ -0,0 +1,21 @@
"use client";
import { Settings } from "lucide-react";
import { CommandConfig } from "../types";
export const createSettingsCommands = (
openWorkspaceSettings: () => void,
canPerformWorkspaceActions: () => boolean
): CommandConfig[] => [
{
id: "search-settings",
type: "settings",
group: "workspace",
title: "Search settings",
description: "Search workspace settings",
icon: Settings,
isEnabled: canPerformWorkspaceActions,
isVisible: canPerformWorkspaceActions,
action: openWorkspaceSettings,
},
];
@@ -0,0 +1,187 @@
"use client";
import { CommandContext, RouteContext } from "./types";
/**
* Utility functions for building and managing command context
*/
/**
* Determine the current route context from pathname
*/
export function determineRouteContext(pathname: string): RouteContext {
// Issue context - when viewing a specific work item
if (pathname.includes('/work-item/') || pathname.match(/\/-\//)) {
return 'issue';
}
// Cycle context - when viewing a specific cycle
if (pathname.includes('/cycles/') && pathname.split('/').filter(Boolean).length > 5) {
return 'cycle';
}
// Module context - when viewing a specific module
if (pathname.includes('/modules/') && pathname.split('/').filter(Boolean).length > 5) {
return 'module';
}
// Page context - when viewing a specific page
if (pathname.includes('/pages/') && pathname.split('/').filter(Boolean).length > 5) {
return 'page';
}
// View context - when viewing a specific view
if (pathname.includes('/views/') && pathname.split('/').filter(Boolean).length > 5) {
return 'view';
}
// Project context - when in a project but not viewing specific entity
if (pathname.includes('/projects/') && pathname.split('/').filter(Boolean).length > 3) {
return 'project';
}
// Default to workspace context
return 'workspace';
}
/**
* Build command context from route params and permissions
*/
export function buildCommandContext(params: {
workspaceSlug?: string;
projectId?: string;
issueId?: string;
cycleId?: string;
moduleId?: string;
pageId?: string;
viewId?: string;
pathname?: string;
canPerformAnyCreateAction?: boolean;
canPerformWorkspaceActions?: boolean;
canPerformProjectActions?: boolean;
}): CommandContext {
const {
workspaceSlug,
projectId,
issueId,
cycleId,
moduleId,
pageId,
viewId,
pathname = '',
canPerformAnyCreateAction = false,
canPerformWorkspaceActions = false,
canPerformProjectActions = false,
} = params;
const routeContext = determineRouteContext(pathname);
const isWorkspaceLevel = !projectId;
return {
workspaceSlug,
projectId,
issueId,
cycleId,
moduleId,
pageId,
viewId,
routeContext,
isWorkspaceLevel,
canPerformAnyCreateAction,
canPerformWorkspaceActions,
canPerformProjectActions,
stepData: {},
};
}
/**
* Update context with step data (used during multi-step flows)
*/
export function updateContextWithStepData(
context: CommandContext,
stepData: Record<string, any>
): CommandContext {
return {
...context,
stepData: {
...context.stepData,
...stepData,
},
};
}
/**
* Check if a specific entity context is available
*/
export function hasEntityContext(context: CommandContext, entity: 'project' | 'issue' | 'cycle' | 'module' | 'page' | 'view'): boolean {
switch (entity) {
case 'project':
return Boolean(context.projectId);
case 'issue':
return Boolean(context.issueId);
case 'cycle':
return Boolean(context.cycleId);
case 'module':
return Boolean(context.moduleId);
case 'page':
return Boolean(context.pageId);
case 'view':
return Boolean(context.viewId);
default:
return false;
}
}
/**
* Get breadcrumb information from context
*/
export function getContextBreadcrumbs(context: CommandContext): string[] {
const breadcrumbs: string[] = [];
if (context.workspaceSlug) {
breadcrumbs.push(context.workspaceSlug);
}
if (context.projectId) {
breadcrumbs.push('project');
}
switch (context.routeContext) {
case 'issue':
breadcrumbs.push('issue');
break;
case 'cycle':
breadcrumbs.push('cycle');
break;
case 'module':
breadcrumbs.push('module');
break;
case 'page':
breadcrumbs.push('page');
break;
case 'view':
breadcrumbs.push('view');
break;
}
return breadcrumbs;
}
/**
* Check if context has required permissions for an action
*/
export function hasPermission(
context: CommandContext,
required: 'create' | 'workspace-admin' | 'project-admin'
): boolean {
switch (required) {
case 'create':
return Boolean(context.canPerformAnyCreateAction);
case 'workspace-admin':
return Boolean(context.canPerformWorkspaceActions);
case 'project-admin':
return Boolean(context.canPerformProjectActions);
default:
return false;
}
}
@@ -0,0 +1,22 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import type { ICycle } from "@plane/types";
import { CommandPaletteEntityList } from "./entity-list";
interface Props {
cycles: ICycle[];
onSelect: (cycle: ICycle) => void;
}
export const CommandPaletteCycleSelector: React.FC<Props> = observer(({ cycles, onSelect }) => (
<CommandPaletteEntityList
heading="Cycles"
items={cycles}
getKey={(cycle) => cycle.id}
getLabel={(cycle) => cycle.name}
onSelect={onSelect}
emptyText="No cycles found"
/>
));
@@ -0,0 +1,42 @@
"use client";
import React from "react";
import { Command } from "cmdk";
import { cn } from "@plane/utils";
interface CommandPaletteEntityListProps<T> {
heading: string;
items: T[];
onSelect: (item: T) => void;
getKey?: (item: T) => string;
getLabel: (item: T) => string;
renderItem?: (item: T) => React.ReactNode;
emptyText?: string;
}
export const CommandPaletteEntityList = <T,>({
heading,
items,
onSelect,
getKey,
getLabel,
renderItem,
emptyText = "No results found",
}: CommandPaletteEntityListProps<T>) => {
if (items.length === 0) return <div className="px-3 py-8 text-center text-sm text-custom-text-300">{emptyText}</div>;
return (
<Command.Group heading={heading}>
{items.map((item) => (
<Command.Item
key={getKey ? getKey(item) : getLabel(item)}
value={getLabel(item)}
onSelect={() => onSelect(item)}
className={cn("focus:outline-none")}
>
{renderItem ? renderItem(item) : getLabel(item)}
</Command.Item>
))}
</Command.Group>
);
};
@@ -0,0 +1,2 @@
export * from "./use-command-registry";
export * from "./use-key-sequence-handler";
@@ -0,0 +1,145 @@
"use client";
import { useCallback, useMemo } from "react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import {
createNavigationCommands,
createCreationCommands,
createAccountCommands,
createSettingsCommands,
} from "../commands";
import { CommandContext, CommandExecutionContext } from "../types";
/**
* Centralized hook for accessing the command registry from MobX store
* This should only be used to initialize the registry with commands once
*/
export const useCommandRegistryInitializer = (
setPages: (pages: string[] | ((pages: string[]) => string[])) => void,
setPlaceholder: (placeholder: string) => void,
setSearchTerm: (term: string) => void,
closePalette: () => void,
openProjectList: () => void,
openCycleList: () => void,
openIssueList: () => void,
isWorkspaceLevel: boolean
) => {
const router = useAppRouter();
const { workspaceSlug, projectId: routerProjectId } = useParams();
const { toggleCreateIssueModal, toggleCreateProjectModal, getCommandRegistry } = useCommandPalette();
const { workspaceProjectIds } = useProject();
const { canPerformAnyCreateAction } = useUser();
const { allowPermissions } = useUserPermissions();
const projectId = routerProjectId?.toString();
const registry = getCommandRegistry();
const canPerformWorkspaceActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const context: CommandContext = useMemo(
() => ({
workspaceSlug: workspaceSlug?.toString(),
projectId,
isWorkspaceLevel,
canPerformAnyCreateAction,
canPerformWorkspaceActions,
canPerformProjectActions: allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
projectId
),
}),
[
workspaceSlug,
projectId,
isWorkspaceLevel,
canPerformAnyCreateAction,
canPerformWorkspaceActions,
allowPermissions,
]
);
const executionContext: CommandExecutionContext = useMemo(
() => ({
closePalette,
router,
setPages,
setPlaceholder,
setSearchTerm,
context,
updateContext: () => {}, // Will be properly implemented during UI integration
}),
[closePalette, router, setPages, setPlaceholder, setSearchTerm, context]
);
const createNewWorkspace = useCallback(() => {
closePalette();
router.push("/create-workspace");
}, [closePalette, router]);
const openThemeSettings = useCallback(() => {
setPlaceholder("Change interface theme");
setSearchTerm("");
setPages((pages) => [...pages, "change-interface-theme"]);
}, [setPlaceholder, setSearchTerm, setPages]);
const openWorkspaceSettings = useCallback(() => {
setPlaceholder("Search workspace settings");
setSearchTerm("");
setPages((pages) => [...pages, "settings"]);
}, [setPlaceholder, setSearchTerm, setPages]);
const initializeCommands = useCallback(() => {
// Clear existing commands to avoid duplicates
registry.clear();
const commands = [
...createNavigationCommands(),
...createAccountCommands(createNewWorkspace, openThemeSettings),
...createSettingsCommands(openWorkspaceSettings, () => canPerformWorkspaceActions),
];
registry.registerMultiple(commands);
}, [
registry,
workspaceSlug,
workspaceProjectIds,
canPerformAnyCreateAction,
canPerformWorkspaceActions,
openProjectList,
openCycleList,
openIssueList,
toggleCreateIssueModal,
toggleCreateProjectModal,
createNewWorkspace,
openThemeSettings,
openWorkspaceSettings,
]);
return {
registry,
context,
executionContext,
initializeCommands,
};
};
/**
* Simple hook to access the centralized command registry from MobX store
* Use this in child components that only need to access the registry
*/
export const useCommandRegistry = () => {
const { getCommandRegistry } = useCommandPalette();
const registry = getCommandRegistry();
return { registry };
};
@@ -0,0 +1,35 @@
"use client";
import { useCallback, useRef } from "react";
import { CommandRegistry } from "../command-registry";
import { CommandExecutionContext } from "../types";
export const useKeySequenceHandler = (
registry: CommandRegistry,
executionContext: CommandExecutionContext,
timeout = 500
) => {
const sequence = useRef("");
const sequenceTimeout = useRef<number | null>(null);
const handleKeySequence = useCallback(
async (e: React.KeyboardEvent) => {
const key = e.key.toLowerCase();
sequence.current = (sequence.current + key).slice(-2);
if (sequenceTimeout.current) window.clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = window.setTimeout(() => {
sequence.current = "";
}, timeout);
const executed = await registry.executeKeySequence(sequence.current, executionContext);
if (executed) {
e.preventDefault();
sequence.current = "";
}
},
[registry, executionContext, timeout]
);
return handleKeySequence;
};
@@ -2,3 +2,17 @@ export * from "./actions";
export * from "./shortcuts-modal";
export * from "./command-modal";
export * from "./command-palette";
export * from "./project-selector";
export * from "./cycle-selector";
export * from "./entity-list";
export * from "./use-key-sequence";
export * from "./types";
export * from "./command-registry";
export * from "./command-renderer";
export * from "./commands";
export * from "./hooks";
export * from "./command-input-header";
export * from "./command-search-results";
export * from "./command-page-content";
export * from "./command-modal-footer";
export * from "./pages";
@@ -0,0 +1,61 @@
"use client";
import React, { useMemo, useEffect } from "react";
import { observer } from "mobx-react";
// plane types
import { ICycle } from "@plane/types";
import { joinUrlPath } from "@plane/utils";
// components
import { CommandPaletteCycleSelector } from "@/components/command-palette";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useCycle } from "@/hooks/store/use-cycle";
import { useAppRouter } from "@/hooks/use-app-router";
interface ICycleSelectionPageProps {
workspaceSlug: string | undefined;
selectedProjectId: string | null;
}
export const CycleSelectionPage: React.FC<ICycleSelectionPageProps> = observer((props) => {
const { workspaceSlug, selectedProjectId } = props;
// router
const router = useAppRouter();
// store
const { getProjectCycleIds, getCycleById, fetchAllCycles } = useCycle();
const { toggleCommandPaletteModal } = useCommandPalette();
// derived values
const projectCycleIds = selectedProjectId ? getProjectCycleIds(selectedProjectId) : null;
const cycleOptions = useMemo(() => {
const cycles: ICycle[] = [];
if (projectCycleIds) {
if (projectCycleIds) {
projectCycleIds.forEach((cid) => {
const cycle = getCycleById(cid);
const status = cycle?.status ? cycle.status.toLowerCase() : "";
if (cycle && ["current", "upcoming"].includes(status)) cycles.push(cycle);
});
}
}
return cycles.sort((a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime());
}, [projectCycleIds, getCycleById]);
useEffect(() => {
if (workspaceSlug && selectedProjectId) {
fetchAllCycles(workspaceSlug.toString(), selectedProjectId);
}
}, [workspaceSlug, selectedProjectId, fetchAllCycles]);
if (!workspaceSlug || !selectedProjectId) return null;
return (
<CommandPaletteCycleSelector
cycles={cycleOptions}
onSelect={(cycle) => {
toggleCommandPaletteModal(false);
router.push(joinUrlPath(workspaceSlug, "projects", cycle.project_id, "cycles", cycle.id));
}}
/>
);
});
@@ -0,0 +1,4 @@
export { ProjectSelectionPage } from "./project-selection-page";
export { CycleSelectionPage } from "./cycle-selection-page";
export { IssueSelectionPage } from "./issue-selection-page";
export { MainPage } from "./main-page";
@@ -0,0 +1,162 @@
"use client";
import React, { useState, useEffect } from "react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TIssueEntityData, TIssueSearchResponse, TActivityEntityData } from "@plane/types";
import { generateWorkItemLink } from "@plane/utils";
// components
import { CommandPaletteEntityList } from "@/components/command-palette";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
interface IssueSelectionPageProps {
workspaceSlug: string | undefined;
projectId: string | undefined;
searchTerm: string;
debouncedSearchTerm: string;
isLoading: boolean;
isSearching: boolean;
resolvedPath: string;
isWorkspaceLevel?: boolean;
}
export const IssueSelectionPage: React.FC<IssueSelectionPageProps> = (props) => {
const { workspaceSlug, projectId, searchTerm, debouncedSearchTerm, isLoading, isSearching, resolvedPath, isWorkspaceLevel = false } = props;
// router
const router = useAppRouter();
// plane hooks
const { t } = useTranslation();
// store hooks
const { toggleCommandPaletteModal } = useCommandPalette();
// states
const [recentIssues, setRecentIssues] = useState<TIssueEntityData[]>([]);
const [issueResults, setIssueResults] = useState<TIssueSearchResponse[]>([]);
// Load recent issues when component mounts
useEffect(() => {
if (!workspaceSlug) return;
workspaceService
.fetchWorkspaceRecents(workspaceSlug.toString(), "issue")
.then((res) =>
setRecentIssues(res.map((r: TActivityEntityData) => r.entity_data as TIssueEntityData).slice(0, 10))
)
.catch(() => setRecentIssues([]));
}, [workspaceSlug]);
// Search issues based on search term
useEffect(() => {
if (!workspaceSlug || !debouncedSearchTerm) {
setIssueResults([]);
return;
}
workspaceService
.searchEntity(workspaceSlug.toString(), {
count: 10,
query: debouncedSearchTerm,
query_type: ["issue"],
...(!isWorkspaceLevel && projectId ? { project_id: projectId.toString() } : {}),
})
.then((res) => {
setIssueResults(res.issue || []);
})
.catch(() => setIssueResults([]));
}, [debouncedSearchTerm, workspaceSlug, projectId, isWorkspaceLevel]);
if (!workspaceSlug) return null;
return (
<>
{searchTerm === "" ? (
recentIssues.length > 0 ? (
<CommandPaletteEntityList
heading="Issues"
items={recentIssues}
getKey={(issue) => issue.id}
getLabel={(issue) => `${issue.project_identifier}-${issue.sequence_id} ${issue.name}`}
renderItem={(issue) => (
<div className="flex items-center gap-2">
{issue.project_id && (
<IssueIdentifier
projectId={issue.project_id}
projectIdentifier={issue.project_identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-sm text-custom-text-200"
/>
)}
<span className="truncate">{issue.name}</span>
</div>
)}
onSelect={(issue) => {
if (!issue.project_id) return;
toggleCommandPaletteModal(false);
router.push(
generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue.project_id,
issueId: issue.id,
projectIdentifier: issue.project_identifier,
sequenceId: issue.sequence_id,
isEpic: issue.is_epic,
})
);
}}
emptyText="Search for issue id or issue title"
/>
) : (
<div className="px-3 py-8 text-center text-sm text-custom-text-300">Search for issue id or issue title</div>
)
) : issueResults.length > 0 ? (
<CommandPaletteEntityList
heading="Issues"
items={issueResults}
getKey={(issue) => issue.id}
getLabel={(issue) => `${issue.project__identifier}-${issue.sequence_id} ${issue.name}`}
renderItem={(issue) => (
<div className="flex items-center gap-2">
{issue.project_id && issue.project__identifier && issue.sequence_id && (
<IssueIdentifier
projectId={issue.project_id}
projectIdentifier={issue.project__identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-sm text-custom-text-200"
/>
)}
<span className="truncate">{issue.name}</span>
</div>
)}
onSelect={(issue) => {
if (!issue.project_id) return;
toggleCommandPaletteModal(false);
router.push(
generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue.project_id,
issueId: issue.id,
projectIdentifier: issue.project__identifier,
sequenceId: issue.sequence_id,
})
);
}}
emptyText={t("command_k.empty_state.search.title") as string}
/>
) : (
!isLoading &&
!isSearching && (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
</div>
)
)}
</>
);
};
@@ -0,0 +1,95 @@
"use client";
import React from "react";
// plane types
import { IWorkspaceSearchResults } from "@plane/types";
// components
import {
CommandPaletteSearchResults,
CommandPaletteIssueActions,
CommandPaletteProjectActions,
CommandPaletteHelpActions,
CommandConfig,
} from "@/components/command-palette";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useUser } from "@/hooks/store/user";
// local imports
import { CommandRenderer } from "../command-renderer";
import { useCommandRegistry } from "../hooks";
interface IMainPageProps {
projectId: string | undefined;
issueId: string | undefined;
issueDetails: { id: string; project_id: string | null; name?: string } | null;
debouncedSearchTerm: string;
results: IWorkspaceSearchResults;
searchInIssue: boolean;
pages: string[];
setPages: (pages: string[] | ((prev: string[]) => string[])) => void;
setPlaceholder: (placeholder: string) => void;
setSearchTerm: (term: string) => void;
onCommandSelect: (command: CommandConfig) => void;
}
export const MainPage: React.FC<IMainPageProps> = (props) => {
const {
projectId,
issueId,
issueDetails,
debouncedSearchTerm,
results,
searchInIssue,
pages,
setPages,
setPlaceholder,
setSearchTerm,
onCommandSelect,
} = props;
// store hooks
const { toggleCommandPaletteModal } = useCommandPalette();
const {
issue: { getIssueById },
} = useIssueDetail();
const { canPerformAnyCreateAction } = useUser();
const { registry } = useCommandRegistry();
return (
<>
{debouncedSearchTerm !== "" && (
<CommandPaletteSearchResults closePalette={() => toggleCommandPaletteModal(false)} results={results} />
)}
{/* issue actions */}
{issueId && issueDetails && searchInIssue && getIssueById && (
<CommandPaletteIssueActions
closePalette={() => toggleCommandPaletteModal(false)}
issueDetails={getIssueById(issueId)}
pages={pages}
setPages={setPages}
setPlaceholder={setPlaceholder}
setSearchTerm={setSearchTerm}
/>
)}
{/* New command renderer */}
<CommandRenderer
commands={registry.getVisibleCommands({
workspaceSlug: undefined,
projectId,
issueId,
})}
onCommandSelect={onCommandSelect}
/>
{/* project actions */}
{projectId && canPerformAnyCreateAction && (
<CommandPaletteProjectActions closePalette={() => toggleCommandPaletteModal(false)} />
)}
{/* help options */}
<CommandPaletteHelpActions closePalette={() => toggleCommandPaletteModal(false)} />
</>
);
};
@@ -0,0 +1,63 @@
"use client";
import React, { useMemo } from "react";
// plane types
import { IPartialProject } from "@plane/types";
// components
import { CommandPaletteProjectSelector } from "@/components/command-palette";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
interface IProjectSelectionPageProps {
workspaceSlug: string | undefined;
projectSelectionAction: "navigate" | "cycle" | null;
setSelectedProjectId: (id: string | null) => void;
fetchAllCycles: (workspaceSlug: string, projectId: string) => void;
setPages: (pages: string[] | ((prev: string[]) => string[])) => void;
setPlaceholder: (placeholder: string) => void;
}
export const ProjectSelectionPage: React.FC<IProjectSelectionPageProps> = (props) => {
const { workspaceSlug, projectSelectionAction, setSelectedProjectId, fetchAllCycles, setPages, setPlaceholder } =
props;
// router
const router = useAppRouter();
// store
const { joinedProjectIds, getPartialProjectById } = useProject();
const { toggleCommandPaletteModal } = useCommandPalette();
// Get projects data - ensure reactivity to store changes
const projectOptions = useMemo(() => {
if (!joinedProjectIds?.length) return [];
const list: IPartialProject[] = [];
joinedProjectIds.forEach((id) => {
const project = getPartialProjectById(id);
if (project) list.push(project);
});
return list
.filter((p) => (projectSelectionAction === "cycle" ? p.cycle_view : true))
.sort((a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime());
}, [joinedProjectIds, getPartialProjectById, projectSelectionAction]);
if (!workspaceSlug) return null;
return (
<CommandPaletteProjectSelector
projects={projectOptions}
onSelect={(project) => {
if (projectSelectionAction === "navigate") {
toggleCommandPaletteModal(false);
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
} else if (projectSelectionAction === "cycle") {
setSelectedProjectId(project.id);
setPages((p) => [...p, "open-cycle"]);
setPlaceholder("Search cycles");
fetchAllCycles(workspaceSlug.toString(), project.id);
}
}}
/>
);
};
@@ -0,0 +1,21 @@
"use client";
import React from "react";
import type { TPartialProject } from "@/plane-web/types";
import { CommandPaletteEntityList } from "./entity-list";
interface Props {
projects: TPartialProject[];
onSelect: (project: TPartialProject) => void;
}
export const CommandPaletteProjectSelector: React.FC<Props> = ({ projects, onSelect }) => (
<CommandPaletteEntityList
heading="Projects"
items={projects}
getKey={(project) => project.id}
getLabel={(project) => project.name}
onSelect={onSelect}
emptyText="No projects found"
/>
);
@@ -0,0 +1,132 @@
"use client";
import { Search, Layers, FolderKanban, FileText } from "lucide-react";
import { ContrastIcon, DiceIcon } from "@plane/propel/icons";
import { SearchScope, SearchScopeConfig } from "./types";
/**
* Search scope configurations
* Defines all available search scopes and their metadata
*/
export const SEARCH_SCOPES: Record<SearchScope, SearchScopeConfig> = {
all: {
id: "all",
title: "All",
placeholder: "Search everything",
icon: Search,
},
issues: {
id: "issues",
title: "Work Items",
placeholder: "Search work items",
icon: Layers,
},
projects: {
id: "projects",
title: "Projects",
placeholder: "Search projects",
icon: FolderKanban,
},
cycles: {
id: "cycles",
title: "Cycles",
placeholder: "Search cycles",
icon: ContrastIcon,
},
modules: {
id: "modules",
title: "Modules",
placeholder: "Search modules",
icon: DiceIcon,
},
pages: {
id: "pages",
title: "Pages",
placeholder: "Search pages",
icon: FileText,
},
views: {
id: "views",
title: "Views",
placeholder: "Search views",
icon: Layers,
},
};
/**
* Get scope configuration by ID
*/
export function getScopeConfig(scope: SearchScope): SearchScopeConfig {
return SEARCH_SCOPES[scope];
}
/**
* Get all available scopes
*/
export function getAllScopes(): SearchScopeConfig[] {
return Object.values(SEARCH_SCOPES);
}
/**
* Get scopes available in current context
* Some scopes may only be available in certain contexts (e.g., cycles only in project context)
*/
export function getAvailableScopes(hasProjectContext: boolean): SearchScopeConfig[] {
const scopes = [SEARCH_SCOPES.all, SEARCH_SCOPES.issues, SEARCH_SCOPES.projects];
// Project-level scopes only available when in project context
if (hasProjectContext) {
scopes.push(
SEARCH_SCOPES.cycles,
SEARCH_SCOPES.modules,
SEARCH_SCOPES.pages,
SEARCH_SCOPES.views
);
}
return scopes;
}
/**
* Filter search results based on active scope
*/
export function filterResultsByScope<T extends { results: any }>(
results: T,
scope: SearchScope
): T {
if (scope === "all") {
return results;
}
// Create filtered results with only the active scope
const filtered = {
...results,
results: {
issues: scope === "issues" ? results.results.issues : [],
projects: scope === "projects" ? results.results.projects : [],
cycles: scope === "cycles" ? results.results.cycles : [],
modules: scope === "modules" ? results.results.modules : [],
pages: scope === "pages" ? results.results.pages : [],
views: scope === "views" ? results.results.views : [],
},
};
return filtered as T;
}
/**
* Get keyboard shortcut for scope
*/
export function getScopeShortcut(scope: SearchScope): string | undefined {
const shortcuts: Record<SearchScope, string | undefined> = {
all: undefined,
issues: "i",
projects: "p",
cycles: "c",
modules: "m",
pages: "d",
views: "v",
};
return shortcuts[scope];
}
@@ -0,0 +1,4 @@
export * from "./select-project-step";
export * from "./select-cycle-step";
export * from "./select-module-step";
export * from "./select-issue-step";
@@ -0,0 +1,59 @@
"use client";
import React, { useMemo, useEffect } from "react";
import { observer } from "mobx-react";
import { ICycle } from "@plane/types";
import { CommandPaletteCycleSelector } from "@/components/command-palette";
import { useCycle } from "@/hooks/store/use-cycle";
interface SelectCycleStepProps {
workspaceSlug: string;
projectId: string;
onSelect: (cycle: ICycle) => void;
filterCondition?: (cycle: ICycle) => boolean;
}
/**
* Reusable cycle selection step component
* Can be used in any multi-step command flow
*/
export const SelectCycleStep: React.FC<SelectCycleStepProps> = observer(({
workspaceSlug,
projectId,
onSelect,
filterCondition,
}) => {
const { getProjectCycleIds, getCycleById, fetchAllCycles } = useCycle();
const projectCycleIds = projectId ? getProjectCycleIds(projectId) : null;
const cycleOptions = useMemo(() => {
const cycles: ICycle[] = [];
if (projectCycleIds) {
projectCycleIds.forEach((cid) => {
const cycle = getCycleById(cid);
const status = cycle?.status ? cycle.status.toLowerCase() : "";
// By default, show current and upcoming cycles
if (cycle && ["current", "upcoming"].includes(status)) {
cycles.push(cycle);
}
});
}
const filtered = filterCondition ? cycles.filter(filterCondition) : cycles;
return filtered.sort(
(a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime()
);
}, [projectCycleIds, getCycleById, filterCondition]);
useEffect(() => {
if (workspaceSlug && projectId) {
fetchAllCycles(workspaceSlug, projectId);
}
}, [workspaceSlug, projectId, fetchAllCycles]);
if (!workspaceSlug || !projectId) return null;
return <CommandPaletteCycleSelector cycles={cycleOptions} onSelect={onSelect} />;
});
@@ -0,0 +1,29 @@
"use client";
import React from "react";
import { IWorkspaceSearchResults } from "@plane/types";
import { CommandPaletteSearchResults } from "@/components/command-palette";
interface SelectIssueStepProps {
workspaceSlug: string;
projectId?: string;
searchTerm: string;
debouncedSearchTerm: string;
results: IWorkspaceSearchResults;
isLoading: boolean;
isSearching: boolean;
isWorkspaceLevel: boolean;
resolvedPath: string;
onClose: () => void;
}
/**
* Reusable issue selection step component
* Can be used in any multi-step command flow
*/
export const SelectIssueStep: React.FC<SelectIssueStepProps> = ({
onClose,
results,
}) => {
return <CommandPaletteSearchResults closePalette={onClose} results={results} />;
};
@@ -0,0 +1,73 @@
"use client";
import React, { useMemo, useEffect } from "react";
import { observer } from "mobx-react";
import { Command } from "cmdk";
import { IModule } from "@plane/types";
import { DiceIcon } from "@plane/propel/icons";
import { useModule } from "@/hooks/store/use-module";
interface SelectModuleStepProps {
workspaceSlug: string;
projectId: string;
onSelect: (module: IModule) => void;
filterCondition?: (module: IModule) => boolean;
}
/**
* Reusable module selection step component
* Can be used in any multi-step command flow
*/
export const SelectModuleStep: React.FC<SelectModuleStepProps> = observer(({
workspaceSlug,
projectId,
onSelect,
filterCondition,
}) => {
const { getProjectModuleIds, getModuleById, fetchModules } = useModule();
const projectModuleIds = projectId ? getProjectModuleIds(projectId) : null;
const moduleOptions = useMemo(() => {
const modules: IModule[] = [];
if (projectModuleIds) {
projectModuleIds.forEach((mid) => {
const module = getModuleById(mid);
if (module) {
modules.push(module);
}
});
}
const filtered = filterCondition ? modules.filter(filterCondition) : modules;
return filtered.sort(
(a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime()
);
}, [projectModuleIds, getModuleById, filterCondition]);
useEffect(() => {
if (workspaceSlug && projectId) {
fetchModules(workspaceSlug, projectId);
}
}, [workspaceSlug, projectId, fetchModules]);
if (!workspaceSlug || !projectId) return null;
return (
<Command.Group heading="Modules">
{moduleOptions.map((module) => (
<Command.Item
key={module.id}
onSelect={() => onSelect(module)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiceIcon className="h-3.5 w-3.5" />
{module.name}
</div>
</Command.Item>
))}
</Command.Group>
);
});
@@ -0,0 +1,44 @@
"use client";
import React, { useMemo } from "react";
import { IPartialProject } from "@plane/types";
import { CommandPaletteProjectSelector } from "@/components/command-palette";
import { useProject } from "@/hooks/store/use-project";
interface SelectProjectStepProps {
workspaceSlug: string;
onSelect: (project: IPartialProject) => void;
filterCondition?: (project: IPartialProject) => boolean;
}
/**
* Reusable project selection step component
* Can be used in any multi-step command flow
*/
export const SelectProjectStep: React.FC<SelectProjectStepProps> = ({
workspaceSlug,
onSelect,
filterCondition
}) => {
const { joinedProjectIds, getPartialProjectById } = useProject();
const projectOptions = useMemo(() => {
if (!joinedProjectIds?.length) return [];
const list: IPartialProject[] = [];
joinedProjectIds.forEach((id) => {
const project = getPartialProjectById(id);
if (project) list.push(project);
});
const filtered = filterCondition ? list.filter(filterCondition) : list;
return filtered.sort(
(a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime()
);
}, [joinedProjectIds, getPartialProjectById, filterCondition]);
if (!workspaceSlug) return null;
return <CommandPaletteProjectSelector projects={projectOptions} onSelect={onSelect} />;
};
@@ -0,0 +1,184 @@
import { AppRouterProgressInstance } from "@bprogress/next";
// ============================================================================
// Command Types & Groups
// ============================================================================
export type CommandType = "navigation" | "action" | "creation" | "search" | "settings" | "contextual";
export type CommandGroup = "navigate" | "create" | "project" | "workspace" | "account" | "help" | "contextual";
// ============================================================================
// Search Scope Types
// ============================================================================
export type SearchScope = "all" | "issues" | "projects" | "cycles" | "modules" | "pages" | "views";
export interface SearchScopeConfig {
id: SearchScope;
title: string;
placeholder: string;
icon?: React.ComponentType<{ className?: string }>;
}
// ============================================================================
// Route & Context Types
// ============================================================================
export type RouteContext = "workspace" | "project" | "issue" | "cycle" | "module" | "page" | "view";
export interface CommandContext {
// Route information
workspaceSlug?: string;
projectId?: string;
issueId?: string;
cycleId?: string;
moduleId?: string;
pageId?: string;
viewId?: string;
routeContext?: RouteContext;
// State flags
isWorkspaceLevel?: boolean;
// Permissions
canPerformAnyCreateAction?: boolean;
canPerformWorkspaceActions?: boolean;
canPerformProjectActions?: boolean;
// Additional context data (passed between steps)
stepData?: Record<string, any>;
}
// ============================================================================
// Step System Types
// ============================================================================
export type StepType =
| "select-project"
| "select-cycle"
| "select-module"
| "select-issue"
| "select-page"
| "select-view"
| "select-state"
| "select-priority"
| "select-assignee"
| "navigate"
| "action"
| "modal";
export interface CommandStep {
type: StepType;
// Unique identifier for this step
id?: string;
// Display configuration
placeholder?: string;
title?: string;
// Condition to execute this step (if returns false, skip)
condition?: (context: CommandContext) => boolean;
// Data to pass to next step
dataKey?: string;
// For navigate type
route?: string | ((context: CommandContext) => string);
// For action type
action?: (context: CommandContext) => void | Promise<void>;
// For modal type
modalAction?: (context: CommandContext) => void;
}
// ============================================================================
// Command Configuration
// ============================================================================
export interface CommandConfig {
id: string;
type: CommandType;
group?: CommandGroup;
title: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
shortcut?: string;
keySequence?: string;
// Visibility & availability
isEnabled?: (context: CommandContext) => boolean;
isVisible?: (context: CommandContext) => boolean;
// Context-based filtering - show only on specific routes
showOnRoutes?: RouteContext[];
// Context-based filtering - hide on specific routes
hideOnRoutes?: RouteContext[];
// Execution strategy
// Option 1: Simple action (deprecated, use steps instead)
action?: (executionContext: CommandExecutionContext) => void;
// Option 2: Multi-step flow (recommended)
steps?: CommandStep[];
// Option 3: Sub-commands (for grouping)
subCommands?: CommandConfig[];
// Search scope (if this is a scoped search command)
searchScope?: SearchScope;
}
// ============================================================================
// Command Group Configuration
// ============================================================================
export interface CommandGroupConfig {
id: CommandGroup;
title: string;
priority: number;
}
// ============================================================================
// Execution Context
// ============================================================================
export interface CommandExecutionContext {
closePalette: () => void;
router: AppRouterProgressInstance;
setPages: (pages: string[] | ((pages: string[]) => string[])) => void;
setPlaceholder: (placeholder: string) => void;
setSearchTerm: (term: string) => void;
setSearchScope?: (scope: SearchScope) => void;
context: CommandContext;
updateContext: (updates: Partial<CommandContext>) => void;
}
// ============================================================================
// Step Execution Result
// ============================================================================
export interface StepExecutionResult {
// Continue to next step
continue: boolean;
// Updated context for next step
updatedContext?: Partial<CommandContext>;
// Close palette after this step
closePalette?: boolean;
}
// ============================================================================
// Command Registry Interface
// ============================================================================
export interface ICommandRegistry {
// Register commands
register(command: CommandConfig): void;
registerMultiple(commands: CommandConfig[]): void;
// Get commands
getCommand(id: string): CommandConfig | undefined;
getVisibleCommands(context: CommandContext): CommandConfig[];
getCommandsByGroup(group: CommandGroup, context: CommandContext): CommandConfig[];
getContextualCommands(context: CommandContext): CommandConfig[];
// Execute commands
executeCommand(commandId: string, executionContext: CommandExecutionContext): Promise<void>;
// Clear registry
clear(): void;
}
@@ -0,0 +1,25 @@
"use client";
import { useRef } from "react";
export const useKeySequence = (handlers: Record<string, () => void>, timeout = 500) => {
const sequence = useRef("");
const sequenceTimeout = useRef<number | null>(null);
return (e: React.KeyboardEvent) => {
const key = e.key.toLowerCase();
sequence.current = (sequence.current + key).slice(-2);
if (sequenceTimeout.current) window.clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = window.setTimeout(() => {
sequence.current = "";
}, timeout);
const action = handlers[sequence.current];
if (action) {
e.preventDefault();
action();
sequence.current = "";
}
};
};
@@ -7,6 +7,9 @@ import {
TCreatePageModal,
} from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
import { CommandRegistry } from "@/components/command-palette/command-registry";
export type CommandPaletteEntity = "project" | "cycle" | "module" | "issue";
export interface ModalData {
store: EIssuesStoreType;
@@ -30,6 +33,11 @@ export interface IBaseCommandPaletteStore {
allStickiesModal: boolean;
projectListOpenMap: Record<string, boolean>;
getIsProjectListOpen: (projectId: string) => boolean;
activeEntity: CommandPaletteEntity | null;
commandRegistry: CommandRegistry;
activateEntity: (entity: CommandPaletteEntity) => void;
clearActiveEntity: () => void;
getCommandRegistry: () => CommandRegistry;
// toggle actions
toggleCommandPaletteModal: (value?: boolean) => void;
toggleShortcutModal: (value?: boolean) => void;
@@ -61,6 +69,8 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined;
allStickiesModal: boolean = false;
projectListOpenMap: Record<string, boolean> = {};
activeEntity: CommandPaletteEntity | null = null;
commandRegistry: CommandRegistry = new CommandRegistry();
constructor() {
makeObservable(this, {
@@ -79,7 +89,8 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
createWorkItemAllowedProjectIds: observable,
allStickiesModal: observable,
projectListOpenMap: observable,
// projectPages: computed,
activeEntity: observable,
commandRegistry: observable.ref,
// toggle actions
toggleCommandPaletteModal: action,
toggleShortcutModal: action,
@@ -93,6 +104,9 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
toggleBulkDeleteIssueModal: action,
toggleAllStickiesModal: action,
toggleProjectListOpen: action,
activateEntity: action,
clearActiveEntity: action,
getCommandRegistry: action,
});
}
@@ -127,6 +141,27 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
else this.projectListOpenMap[projectId] = !this.projectListOpenMap[projectId];
};
/**
* Opens the command palette with a specific entity pre-selected
* @param entity
*/
activateEntity = (entity: CommandPaletteEntity) => {
this.isCommandPaletteOpen = true;
this.activeEntity = entity;
};
/**
* Clears the active entity trigger
*/
clearActiveEntity = () => {
this.activeEntity = null;
};
/**
* Get the command registry instance
*/
getCommandRegistry = (): CommandRegistry => this.commandRegistry;
/**
* Toggles the command palette modal
* @param value