Fix CommandBar input and navigation issues with state machine refactor (Vibe Kanban) (#1885)
* Done! I've removed all 7 `shortcut` properties from the action definitions in `index.ts`:
- `NewWorkspace`: removed `shortcut: 'N'`
- `Settings`: removed `shortcut: ','`
- `ToggleSidebar`: removed `shortcut: '['`
- `ToggleGitPanel`: removed `shortcut: ']'`
- `ToggleChangesMode`: removed `shortcut: 'C'`
- `ToggleLogsMode`: removed `shortcut: 'L'`
- `TogglePreviewMode`: removed `shortcut: 'P'`
The `ActionDefinition` type (and its base `ActionBase` interface) were left unchanged as requested.
* fix tailwind nto auto creating styles
* I've successfully completed all the changes. Here's a summary of what was done:
## Summary of Changes
### 1. `ActionExecutorContext` (index.ts)
- Made all properties non-optional
- Removed `gitRepoId` from context (now passed as action parameter)
- Properties now use `| null` for values that may not exist
### 2. New `GitActionDefinition` Type (index.ts)
- Added new action type with `requiresTarget: 'git'`
- Git actions now explicitly require `repoId` as a parameter
### 3. Git Actions Updated (index.ts)
- `GitCreatePR`, `GitMerge`, `GitRebase`, `GitChangeTarget` now use `requiresTarget: 'git'`
- Each action's `execute` function takes `repoId` as third parameter
- Simplified implementations (no more fallback repo fetching logic)
### 4. `ActionsContext` Provider (ActionsContext.tsx)
- Now calls `useDevServer()` to get dev server state
- Provides all required context properties
- `executeAction` handles three action types: global, workspace, and git
- Removed `contextOverride` pattern
### 5. `ContextBarContainer` (ContextBarContainer.tsx)
- Simplified significantly - no longer builds extended context
- Uses executor context directly from provider
- Removed `containerPath` prop
### 6. `ActionVisibilityContext` (index.ts + useActionVisibility.ts)
- Made `editorType`, `devServerState`, `runningDevServerId` required
- Visibility hook now provides all values from hooks
### 7. Updated Call Sites
- `WorkspacesLayout`: Git actions now pass `repoId` as third parameter
- `WorkspacesMain`: Removed `containerPath` prop from ContextBarContainer
* Cleanup script changes for workspace e961e311-6db4-41bf-83a1-ef8dd96eb497
* All changes are complete and type checks pass. Here's a summary of the changes made:
## Summary of Changes
### 1. `ActionExecutorContext` - Made all properties non-optional
- Removed `?` from all properties
- Removed `gitRepoId` (moved to per-action parameter)
- Changed nullable properties to use `| null` type
### 2. New `GitActionDefinition` type
- Added new action type with `requiresTarget: 'git'`
- Takes `repoId` as explicit third parameter
### 3. Git actions updated
- `GitCreatePR`, `GitMerge`, `GitRebase`, `GitChangeTarget` now use `requiresTarget: 'git'`
- Each action's `execute` receives `repoId` as parameter
### 4. `ActionsContext` provider centralized
- Now calls `useDevServer()` to provide dev server state
- Provides all required context properties from hooks
- `executeAction` handles three action types: global, workspace, git
- Removed `contextOverride` pattern
### 5. `ActionVisibilityContext` - Made dev server properties required
- `editorType`, `devServerState`, `runningDevServerId` now required
- `useActionVisibilityContext` hook provides all values
### 6. `ContextBarContainer` simplified
- Removed extended context creation
- Uses executor context directly from provider
### 7. Provider order fix
- Moved `ExecutionProcessesProvider` up to `NewDesignScope`
- Required because `useDevServer` depends on `ExecutionProcessesContext`
### Files Modified:
- `vibe-kanban/frontend/src/components/ui-new/actions/index.ts`
- `vibe-kanban/frontend/src/components/ui-new/actions/useActionVisibility.ts`
- `vibe-kanban/frontend/src/contexts/ActionsContext.tsx`
- `vibe-kanban/frontend/src/components/ui-new/containers/ContextBarContainer.tsx`
- `vibe-kanban/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx`
- `vibe-kanban/frontend/src/components/ui-new/views/WorkspacesMain.tsx`
- `vibe-kanban/frontend/src/components/ui-new/scope/NewDesignScope.tsx`
* The implementation is complete. Let me summarize what was changed:
## Summary of Changes
### `pages.ts`
- Added `'selectRepo'` to `PageId` type for dynamic repo selection page
- Created `StaticPageId` type (excludes `selectRepo`) for static page records
- Added `RepoItem` interface for repo selection items
- Extended `ResolvedGroupItem` union type to include `{ type: 'repo'; repo: RepoItem }`
- Updated `Pages` record and `getPageActions` to use `StaticPageId`
### `CommandBarDialog.tsx`
- Added `repos` from `useWorkspaceContext()`
- Added `pendingGitAction` state to track git actions awaiting repo selection
- Reset `pendingGitAction` when dialog opens/closes
- Modified `getPageWithItems` to generate dynamic `selectRepo` page with repo list
- Updated `handleSelect` to:
- Execute git actions immediately with single repo
- Navigate to `selectRepo` page when multiple repos exist
- Execute pending git action when user selects a repo
- Clear `pendingGitAction` when navigating back from `selectRepo` page
### `CommandBar.tsx`
- Added `FolderIcon` import
- Added rendering for `'repo'` item type with folder icon
## Behavior
1. **Single repo**: When user selects a git action (e.g., "Create Pull Request") and the workspace has only one git repo, the action executes immediately with that repo's ID.
2. **Multiple repos**: When workspace has multiple repos, selecting a git action navigates to a "Select Repository" page where the user can choose which repo to use, then the action executes with the selected repo.
3. **Back navigation**: Pressing back from the repo selection page clears the pending action and returns to the previous page.
* Done! The `CommandBarDialog` has been refactored to use a state machine pattern with `useReducer`. Here's a summary of the changes:
## Summary of Changes
**State Machine Types (lines 38-67):**
- `CommandBarState`: Discriminated union with `browsing` and `selectingRepo` states
- `CommandBarEvent`: `RESET`, `SEARCH_CHANGE`, `GO_BACK`, `SELECT_ITEM`
- `CommandBarEffect`: Side effects returned from reducer (`none` or `execute`)
**Reducer (lines 73-201):**
- Pure function returning `[newState, effect]` tuple
- Handles all state transitions based on current state and event
- Git action flow: single repo → execute, multiple repos → transition to `selectingRepo`
**Component Changes:**
- Replaced 4 `useState` calls with single `useReducer`
- `currentPage` is now derived from state (line 249-250)
- `handleSelect` computes effect from reducer, dispatches event, then handles effect (lines 456-475)
- `goBack` and `handleSearchChange` simply dispatch events (lines 446-453)
**Benefits:**
- All state transitions in one place (the reducer)
- State transitions are pure and testable
- Clear separation between state updates and side effects
- Impossible states are unrepresentable (e.g., can't have `pendingAction` while `browsing`)
* Done! Here's the final result:
## Summary
**Before:** 520 lines in 1 file (after initial state machine refactor)
**After:** 487 lines across 4 files
| File | Lines | Purpose |
|------|-------|---------|
| `CommandBarDialog.tsx` | 101 | Thin orchestration (down from 520) |
| `useCommandBarState.ts` | 177 | State machine hook |
| `useResolvedPage.ts` | 150 | Page building logic |
| `injectSearchMatches.ts` | 59 | DRY search helper |
**Key wins:**
- Main component: **520 → 101 lines** (80% reduction)
- Search injection code: **100 → 59 lines** (4× duplication eliminated)
- Each file has single responsibility
- State machine is testable in isolation
- Page building logic is reusable
* Cleanup script changes for workspace e961e311-6db4-41bf-83a1-ef8dd96eb497
* Done! Here are the final results:
## Final Summary
| File | Before | After | Reduction |
|------|--------|-------|-----------|
| `CommandBarDialog.tsx` | 101 | 106 | +5 (linter formatting) |
| `useCommandBarState.ts` | 177 | 95 | **-82 (46%)** |
| `useResolvedPage.ts` | 150 | 95 | **-55 (37%)** |
| `injectSearchMatches.ts` | 59 | 42 | **-17 (29%)** |
| **Total** | **487** | **338** | **-149 (31%)** |
**Original single file:** 398 lines
**Final 4 files:** 338 lines (15% less code overall)
Key wins:
- Main component is now just 106 lines of thin orchestration
- State machine is flattened (if statements instead of nested switch/case)
- Page resolution is simplified with reused types
- Search injection uses reduce() instead of for-loop
- All files pass type check
* Cleanup script changes for workspace e961e311-6db4-41bf-83a1-ef8dd96eb497
* I've fixed both issues with the CommandBar. Here's a summary of what was wrong and how I fixed it:
## Root Causes
### Issue 1: Nested `<Command>` Components
The `CommandDialog` component was wrapping children in a `<Command>` component, and `CommandBar` also rendered its own `<Command>`. This nested cmdk context caused conflicts with state management.
**Fix:** Removed the `<Command>` wrapper from `CommandDialog` in `Command.tsx:26-44`, and moved the styling classes to `CommandBar`'s `<Command>` component.
### Issue 2: Unstable `dispatch` Function Causing Infinite RESET Loop
In `useCommandBarState.ts`, the `dispatch` function had `[state, repoCount]` in its `useCallback` dependency array. This caused `dispatch` to be recreated on every state change.
In `CommandBarDialog.tsx`, the `useEffect` that resets state when the modal opens had `dispatch` in its dependency array:
```tsx
useEffect(() => {
if (modal.visible) {
dispatch({ type: 'RESET', page });
}
}, [modal.visible, page, dispatch]); // dispatch changes → useEffect runs → RESET
```
This created an infinite loop:
1. User types → state updates → `dispatch` recreated
2. `dispatch` changed → `useEffect` runs → `RESET` dispatched
3. State resets to empty → repeat
**Fix:** Made `dispatch` stable by using refs instead of state in dependencies:
```tsx
const stateRef = useRef<CommandBarState>(browsing(initialPage));
const repoCountRef = useRef(repoCount);
const dispatch = useCallback(
(event: CommandBarEvent): CommandBarEffect => {
const [, effect] = reducer(stateRef.current, event, repoCountRef.current);
rawDispatch(event);
return effect;
},
[] // No dependencies - uses refs for current values
);
```
## Files Changed
1. `vibe-kanban/frontend/src/components/ui-new/primitives/Command.tsx` - Removed nested `<Command>` wrapper from `CommandDialog`
2. `vibe-kanban/frontend/src/components/ui-new/primitives/CommandBar.tsx` - Added styling classes that were on the removed wrapper
3. `vibe-kanban/frontend/src/components/ui-new/dialogs/commandBar/useCommandBarState.ts` - Made `dispatch` stable using refs
This commit is contained in:
committed by
GitHub
parent
2c53fc976b
commit
6e3421c820
@@ -62,20 +62,13 @@ export type DevServerState = 'stopped' | 'starting' | 'running' | 'stopping';
|
|||||||
export interface ActionExecutorContext {
|
export interface ActionExecutorContext {
|
||||||
navigate: NavigateFunction;
|
navigate: NavigateFunction;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
// Optional workspace selection context (for archive action)
|
selectWorkspace: (workspaceId: string) => void;
|
||||||
selectWorkspace?: (workspaceId: string) => void;
|
activeWorkspaces: SidebarWorkspace[];
|
||||||
activeWorkspaces?: SidebarWorkspace[];
|
currentWorkspaceId: string | null;
|
||||||
// Current workspace ID (for actions that optionally use workspace context)
|
containerRef: string | null;
|
||||||
currentWorkspaceId?: string;
|
runningDevServerId: string | null;
|
||||||
|
startDevServer: () => void;
|
||||||
// ContextBar-specific state (optional, only set in ContextBar context)
|
stopDevServer: () => void;
|
||||||
containerRef?: string; // For copy path (workspace.container_ref)
|
|
||||||
runningDevServerId?: string; // For stopping dev server
|
|
||||||
startDevServer?: () => void; // For starting dev server with mutation tracking
|
|
||||||
stopDevServer?: () => void; // For stopping dev server with mutation tracking
|
|
||||||
|
|
||||||
// Git-specific state (optional, only set when clicking from a specific RepoCard)
|
|
||||||
gitRepoId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context for evaluating action visibility and state conditions
|
// Context for evaluating action visibility and state conditions
|
||||||
@@ -98,10 +91,10 @@ export interface ActionVisibilityContext {
|
|||||||
diffViewMode: DiffViewMode;
|
diffViewMode: DiffViewMode;
|
||||||
isAllDiffsExpanded: boolean;
|
isAllDiffsExpanded: boolean;
|
||||||
|
|
||||||
// ContextBar-specific state (optional)
|
// Dev server state
|
||||||
editorType?: EditorType | null;
|
editorType: EditorType | null;
|
||||||
devServerState?: DevServerState;
|
devServerState: DevServerState;
|
||||||
runningDevServerId?: string;
|
runningDevServerId: string | null;
|
||||||
|
|
||||||
// Git panel state
|
// Git panel state
|
||||||
hasGitRepos: boolean;
|
hasGitRepos: boolean;
|
||||||
@@ -144,10 +137,21 @@ export interface WorkspaceActionDefinition extends ActionBase {
|
|||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Git action (requires workspace + repoId)
|
||||||
|
export interface GitActionDefinition extends ActionBase {
|
||||||
|
requiresTarget: 'git';
|
||||||
|
execute: (
|
||||||
|
ctx: ActionExecutorContext,
|
||||||
|
workspaceId: string,
|
||||||
|
repoId: string
|
||||||
|
) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
// Discriminated union
|
// Discriminated union
|
||||||
export type ActionDefinition =
|
export type ActionDefinition =
|
||||||
| GlobalActionDefinition
|
| GlobalActionDefinition
|
||||||
| WorkspaceActionDefinition;
|
| WorkspaceActionDefinition
|
||||||
|
| GitActionDefinition;
|
||||||
|
|
||||||
// Helper to get workspace from query cache
|
// Helper to get workspace from query cache
|
||||||
function getWorkspaceFromCache(
|
function getWorkspaceFromCache(
|
||||||
@@ -233,9 +237,9 @@ export const Actions = {
|
|||||||
const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId);
|
const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId);
|
||||||
const wasArchived = workspace.archived;
|
const wasArchived = workspace.archived;
|
||||||
|
|
||||||
// Calculate next workspace before archiving (if we have the context)
|
// Calculate next workspace before archiving
|
||||||
let nextWorkspaceId: string | null = null;
|
let nextWorkspaceId: string | null = null;
|
||||||
if (!wasArchived && ctx.selectWorkspace && ctx.activeWorkspaces) {
|
if (!wasArchived) {
|
||||||
const currentIndex = ctx.activeWorkspaces.findIndex(
|
const currentIndex = ctx.activeWorkspaces.findIndex(
|
||||||
(ws) => ws.id === workspaceId
|
(ws) => ws.id === workspaceId
|
||||||
);
|
);
|
||||||
@@ -252,7 +256,7 @@ export const Actions = {
|
|||||||
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
|
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
|
||||||
|
|
||||||
// Select next workspace after successful archive
|
// Select next workspace after successful archive
|
||||||
if (!wasArchived && nextWorkspaceId && ctx.selectWorkspace) {
|
if (!wasArchived && nextWorkspaceId) {
|
||||||
ctx.selectWorkspace(nextWorkspaceId);
|
ctx.selectWorkspace(nextWorkspaceId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -289,7 +293,6 @@ export const Actions = {
|
|||||||
id: 'new-workspace',
|
id: 'new-workspace',
|
||||||
label: 'New Workspace',
|
label: 'New Workspace',
|
||||||
icon: PlusIcon,
|
icon: PlusIcon,
|
||||||
shortcut: 'N',
|
|
||||||
requiresTarget: false,
|
requiresTarget: false,
|
||||||
execute: (ctx) => {
|
execute: (ctx) => {
|
||||||
ctx.navigate('/workspaces/create');
|
ctx.navigate('/workspaces/create');
|
||||||
@@ -300,7 +303,6 @@ export const Actions = {
|
|||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
icon: GearIcon,
|
icon: GearIcon,
|
||||||
shortcut: ',',
|
|
||||||
requiresTarget: false,
|
requiresTarget: false,
|
||||||
execute: (ctx) => {
|
execute: (ctx) => {
|
||||||
ctx.navigate('/settings');
|
ctx.navigate('/settings');
|
||||||
@@ -364,7 +366,6 @@ export const Actions = {
|
|||||||
? 'Hide Sidebar'
|
? 'Hide Sidebar'
|
||||||
: 'Show Sidebar',
|
: 'Show Sidebar',
|
||||||
icon: SidebarSimpleIcon,
|
icon: SidebarSimpleIcon,
|
||||||
shortcut: '[',
|
|
||||||
requiresTarget: false,
|
requiresTarget: false,
|
||||||
isActive: (ctx) => ctx.isSidebarVisible,
|
isActive: (ctx) => ctx.isSidebarVisible,
|
||||||
execute: () => {
|
execute: () => {
|
||||||
@@ -394,7 +395,6 @@ export const Actions = {
|
|||||||
? 'Hide Git Panel'
|
? 'Hide Git Panel'
|
||||||
: 'Show Git Panel',
|
: 'Show Git Panel',
|
||||||
icon: SidebarSimpleIcon,
|
icon: SidebarSimpleIcon,
|
||||||
shortcut: ']',
|
|
||||||
requiresTarget: false,
|
requiresTarget: false,
|
||||||
isActive: (ctx) => ctx.isGitPanelVisible,
|
isActive: (ctx) => ctx.isGitPanelVisible,
|
||||||
execute: () => {
|
execute: () => {
|
||||||
@@ -409,7 +409,6 @@ export const Actions = {
|
|||||||
? 'Hide Changes Panel'
|
? 'Hide Changes Panel'
|
||||||
: 'Show Changes Panel',
|
: 'Show Changes Panel',
|
||||||
icon: GitDiffIcon,
|
icon: GitDiffIcon,
|
||||||
shortcut: 'C',
|
|
||||||
requiresTarget: false,
|
requiresTarget: false,
|
||||||
isVisible: (ctx) => !ctx.isCreateMode,
|
isVisible: (ctx) => !ctx.isCreateMode,
|
||||||
isActive: (ctx) => ctx.isChangesMode,
|
isActive: (ctx) => ctx.isChangesMode,
|
||||||
@@ -426,7 +425,6 @@ export const Actions = {
|
|||||||
? 'Hide Logs Panel'
|
? 'Hide Logs Panel'
|
||||||
: 'Show Logs Panel',
|
: 'Show Logs Panel',
|
||||||
icon: TerminalIcon,
|
icon: TerminalIcon,
|
||||||
shortcut: 'L',
|
|
||||||
requiresTarget: false,
|
requiresTarget: false,
|
||||||
isVisible: (ctx) => !ctx.isCreateMode,
|
isVisible: (ctx) => !ctx.isCreateMode,
|
||||||
isActive: (ctx) => ctx.isLogsMode,
|
isActive: (ctx) => ctx.isLogsMode,
|
||||||
@@ -443,7 +441,6 @@ export const Actions = {
|
|||||||
? 'Hide Preview Panel'
|
? 'Hide Preview Panel'
|
||||||
: 'Show Preview Panel',
|
: 'Show Preview Panel',
|
||||||
icon: DesktopIcon,
|
icon: DesktopIcon,
|
||||||
shortcut: 'P',
|
|
||||||
requiresTarget: false,
|
requiresTarget: false,
|
||||||
isVisible: (ctx) => !ctx.isCreateMode,
|
isVisible: (ctx) => !ctx.isCreateMode,
|
||||||
isActive: (ctx) => ctx.isPreviewMode,
|
isActive: (ctx) => ctx.isPreviewMode,
|
||||||
@@ -589,9 +586,9 @@ export const Actions = {
|
|||||||
getLabel: (ctx) =>
|
getLabel: (ctx) =>
|
||||||
ctx.devServerState === 'running' ? 'Stop Dev Server' : 'Start Dev Server',
|
ctx.devServerState === 'running' ? 'Stop Dev Server' : 'Start Dev Server',
|
||||||
execute: (ctx) => {
|
execute: (ctx) => {
|
||||||
if (ctx.runningDevServerId && ctx.stopDevServer) {
|
if (ctx.runningDevServerId) {
|
||||||
ctx.stopDevServer();
|
ctx.stopDevServer();
|
||||||
} else if (ctx.startDevServer) {
|
} else {
|
||||||
ctx.startDevServer();
|
ctx.startDevServer();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -602,18 +599,9 @@ export const Actions = {
|
|||||||
id: 'git-create-pr',
|
id: 'git-create-pr',
|
||||||
label: 'Create Pull Request',
|
label: 'Create Pull Request',
|
||||||
icon: GitPullRequestIcon,
|
icon: GitPullRequestIcon,
|
||||||
requiresTarget: true,
|
requiresTarget: 'git',
|
||||||
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
|
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
|
||||||
execute: async (ctx, workspaceId) => {
|
execute: async (ctx, workspaceId, repoId) => {
|
||||||
// Get repoId - either from context (RepoCard click) or fetch repos
|
|
||||||
let repoId = ctx.gitRepoId;
|
|
||||||
if (!repoId) {
|
|
||||||
const repos = await attemptsApi.getRepos(workspaceId);
|
|
||||||
if (repos.length === 0) throw new Error('No repositories found');
|
|
||||||
if (repos.length > 1) throw new Error('Please select a repository');
|
|
||||||
repoId = repos[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId);
|
const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId);
|
||||||
const task = await tasksApi.getById(workspace.task_id);
|
const task = await tasksApi.getById(workspace.task_id);
|
||||||
|
|
||||||
@@ -638,18 +626,9 @@ export const Actions = {
|
|||||||
id: 'git-merge',
|
id: 'git-merge',
|
||||||
label: 'Merge',
|
label: 'Merge',
|
||||||
icon: GitMergeIcon,
|
icon: GitMergeIcon,
|
||||||
requiresTarget: true,
|
requiresTarget: 'git',
|
||||||
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
|
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
|
||||||
execute: async (ctx, workspaceId) => {
|
execute: async (ctx, workspaceId, repoId) => {
|
||||||
// Get repoId - either from context (RepoCard click) or fetch repos
|
|
||||||
let repoId = ctx.gitRepoId;
|
|
||||||
if (!repoId) {
|
|
||||||
const repos = await attemptsApi.getRepos(workspaceId);
|
|
||||||
if (repos.length === 0) throw new Error('No repositories found');
|
|
||||||
if (repos.length > 1) throw new Error('Please select a repository');
|
|
||||||
repoId = repos[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmResult = await ConfirmDialog.show({
|
const confirmResult = await ConfirmDialog.show({
|
||||||
title: 'Merge Branch',
|
title: 'Merge Branch',
|
||||||
message:
|
message:
|
||||||
@@ -669,27 +648,17 @@ export const Actions = {
|
|||||||
id: 'git-rebase',
|
id: 'git-rebase',
|
||||||
label: 'Rebase',
|
label: 'Rebase',
|
||||||
icon: ArrowsClockwiseIcon,
|
icon: ArrowsClockwiseIcon,
|
||||||
requiresTarget: true,
|
requiresTarget: 'git',
|
||||||
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
|
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
|
||||||
execute: async (ctx, workspaceId) => {
|
execute: async (_ctx, workspaceId, repoId) => {
|
||||||
// Fetch repos to get target_branch info
|
|
||||||
const repos = await attemptsApi.getRepos(workspaceId);
|
const repos = await attemptsApi.getRepos(workspaceId);
|
||||||
if (repos.length === 0) throw new Error('No repositories found');
|
const repo = repos.find((r) => r.id === repoId);
|
||||||
|
if (!repo) throw new Error('Repository not found');
|
||||||
|
|
||||||
// Get repo - either from context (RepoCard click) or use first repo
|
const branches = await repoApi.getBranches(repoId);
|
||||||
let repo = ctx.gitRepoId
|
|
||||||
? repos.find((r) => r.id === ctx.gitRepoId)
|
|
||||||
: repos[0];
|
|
||||||
|
|
||||||
if (!repo) {
|
|
||||||
if (repos.length > 1) throw new Error('Please select a repository');
|
|
||||||
repo = repos[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const branches = await repoApi.getBranches(repo.id);
|
|
||||||
await RebaseDialog.show({
|
await RebaseDialog.show({
|
||||||
attemptId: workspaceId,
|
attemptId: workspaceId,
|
||||||
repoId: repo.id,
|
repoId,
|
||||||
branches,
|
branches,
|
||||||
initialTargetBranch: repo.target_branch,
|
initialTargetBranch: repo.target_branch,
|
||||||
});
|
});
|
||||||
@@ -700,18 +669,9 @@ export const Actions = {
|
|||||||
id: 'git-change-target',
|
id: 'git-change-target',
|
||||||
label: 'Change Target Branch',
|
label: 'Change Target Branch',
|
||||||
icon: CrosshairIcon,
|
icon: CrosshairIcon,
|
||||||
requiresTarget: true,
|
requiresTarget: 'git',
|
||||||
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
|
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
|
||||||
execute: async (ctx, workspaceId) => {
|
execute: async (_ctx, workspaceId, repoId) => {
|
||||||
// Get repoId - either from context (RepoCard click) or fetch repos
|
|
||||||
let repoId = ctx.gitRepoId;
|
|
||||||
if (!repoId) {
|
|
||||||
const repos = await attemptsApi.getRepos(workspaceId);
|
|
||||||
if (repos.length === 0) throw new Error('No repositories found');
|
|
||||||
if (repos.length > 1) throw new Error('Please select a repository');
|
|
||||||
repoId = repos[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const branches = await repoApi.getBranches(repoId);
|
const branches = await repoApi.getBranches(repoId);
|
||||||
await ChangeTargetDialog.show({
|
await ChangeTargetDialog.show({
|
||||||
attemptId: workspaceId,
|
attemptId: workspaceId,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export type PageId =
|
|||||||
| 'workspaceActions'
|
| 'workspaceActions'
|
||||||
| 'diffOptions'
|
| 'diffOptions'
|
||||||
| 'viewOptions'
|
| 'viewOptions'
|
||||||
| 'gitActions';
|
| 'gitActions'
|
||||||
|
| 'selectRepo'; // Dynamic page for repo selection (not in Pages record)
|
||||||
|
|
||||||
// Items that can appear inside a group
|
// Items that can appear inside a group
|
||||||
export type CommandBarGroupItem =
|
export type CommandBarGroupItem =
|
||||||
@@ -26,10 +27,17 @@ export interface CommandBarGroup {
|
|||||||
// Top-level items in a page are groups
|
// Top-level items in a page are groups
|
||||||
export type CommandBarItem = CommandBarGroup;
|
export type CommandBarItem = CommandBarGroup;
|
||||||
|
|
||||||
|
// Repo item for dynamic repo selection page
|
||||||
|
export interface RepoItem {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolved types (after childPages expansion)
|
// Resolved types (after childPages expansion)
|
||||||
export type ResolvedGroupItem =
|
export type ResolvedGroupItem =
|
||||||
| { type: 'action'; action: ActionDefinition }
|
| { type: 'action'; action: ActionDefinition }
|
||||||
| { type: 'page'; pageId: PageId; label: string; icon: Icon };
|
| { type: 'page'; pageId: PageId; label: string; icon: Icon }
|
||||||
|
| { type: 'repo'; repo: RepoItem };
|
||||||
|
|
||||||
export interface ResolvedGroup {
|
export interface ResolvedGroup {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -46,7 +54,10 @@ export interface CommandBarPage {
|
|||||||
isVisible?: (ctx: ActionVisibilityContext) => boolean;
|
isVisible?: (ctx: ActionVisibilityContext) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Pages: Record<PageId, CommandBarPage> = {
|
// Static page IDs (excludes dynamic pages like selectRepo)
|
||||||
|
export type StaticPageId = Exclude<PageId, 'selectRepo'>;
|
||||||
|
|
||||||
|
export const Pages: Record<StaticPageId, CommandBarPage> = {
|
||||||
// Root page - shown when opening via CMD+K
|
// Root page - shown when opening via CMD+K
|
||||||
root: {
|
root: {
|
||||||
id: 'root',
|
id: 'root',
|
||||||
@@ -164,7 +175,7 @@ export const Pages: Record<PageId, CommandBarPage> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get all actions from a specific page
|
// Get all actions from a specific page
|
||||||
export function getPageActions(pageId: PageId): ActionDefinition[] {
|
export function getPageActions(pageId: StaticPageId): ActionDefinition[] {
|
||||||
const page = Pages[pageId];
|
const page = Pages[pageId];
|
||||||
const actions: ActionDefinition[] = [];
|
const actions: ActionDefinition[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { useLayoutStore } from '@/stores/useLayoutStore';
|
|||||||
import { useDiffViewStore, useDiffViewMode } from '@/stores/useDiffViewStore';
|
import { useDiffViewStore, useDiffViewMode } from '@/stores/useDiffViewStore';
|
||||||
import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore';
|
import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore';
|
||||||
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
||||||
|
import { useUserSystem } from '@/components/ConfigProvider';
|
||||||
|
import { useDevServer } from '@/hooks/useDevServer';
|
||||||
import type { Workspace } from 'shared/types';
|
import type { Workspace } from 'shared/types';
|
||||||
import type {
|
import type {
|
||||||
ActionVisibilityContext,
|
ActionVisibilityContext,
|
||||||
ActionDefinition,
|
ActionDefinition,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
DevServerState,
|
||||||
} from './index';
|
} from './index';
|
||||||
import { resolveLabel } from './index';
|
import { resolveLabel } from './index';
|
||||||
import type { CommandBarPage } from './pages';
|
import type { CommandBarPage } from './pages';
|
||||||
@@ -19,10 +22,13 @@ import type { CommandBarPage } from './pages';
|
|||||||
*/
|
*/
|
||||||
export function useActionVisibilityContext(): ActionVisibilityContext {
|
export function useActionVisibilityContext(): ActionVisibilityContext {
|
||||||
const layout = useLayoutStore();
|
const layout = useLayoutStore();
|
||||||
const { workspace, isCreateMode, repos } = useWorkspaceContext();
|
const { workspace, workspaceId, isCreateMode, repos } = useWorkspaceContext();
|
||||||
const diffPaths = useDiffViewStore((s) => s.diffPaths);
|
const diffPaths = useDiffViewStore((s) => s.diffPaths);
|
||||||
const diffViewMode = useDiffViewMode();
|
const diffViewMode = useDiffViewMode();
|
||||||
const expanded = useUiPreferencesStore((s) => s.expanded);
|
const expanded = useUiPreferencesStore((s) => s.expanded);
|
||||||
|
const { config } = useUserSystem();
|
||||||
|
const { isStarting, isStopping, runningDevServer } =
|
||||||
|
useDevServer(workspaceId);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
// Compute isAllDiffsExpanded
|
// Compute isAllDiffsExpanded
|
||||||
@@ -30,6 +36,15 @@ export function useActionVisibilityContext(): ActionVisibilityContext {
|
|||||||
const isAllDiffsExpanded =
|
const isAllDiffsExpanded =
|
||||||
diffKeys.length > 0 && diffKeys.every((k) => expanded[k] !== false);
|
diffKeys.length > 0 && diffKeys.every((k) => expanded[k] !== false);
|
||||||
|
|
||||||
|
// Compute dev server state
|
||||||
|
const devServerState: DevServerState = isStarting
|
||||||
|
? 'starting'
|
||||||
|
: isStopping
|
||||||
|
? 'stopping'
|
||||||
|
: runningDevServer
|
||||||
|
? 'running'
|
||||||
|
: 'stopped';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isChangesMode: layout.isChangesMode,
|
isChangesMode: layout.isChangesMode,
|
||||||
isLogsMode: layout.isLogsMode,
|
isLogsMode: layout.isLogsMode,
|
||||||
@@ -43,6 +58,9 @@ export function useActionVisibilityContext(): ActionVisibilityContext {
|
|||||||
hasDiffs: diffPaths.length > 0,
|
hasDiffs: diffPaths.length > 0,
|
||||||
diffViewMode,
|
diffViewMode,
|
||||||
isAllDiffsExpanded,
|
isAllDiffsExpanded,
|
||||||
|
editorType: config?.editor?.editor_type ?? null,
|
||||||
|
devServerState,
|
||||||
|
runningDevServerId: runningDevServer?.id ?? null,
|
||||||
hasGitRepos: repos.length > 0,
|
hasGitRepos: repos.length > 0,
|
||||||
hasMultipleRepos: repos.length > 1,
|
hasMultipleRepos: repos.length > 1,
|
||||||
};
|
};
|
||||||
@@ -59,6 +77,10 @@ export function useActionVisibilityContext(): ActionVisibilityContext {
|
|||||||
diffPaths,
|
diffPaths,
|
||||||
diffViewMode,
|
diffViewMode,
|
||||||
expanded,
|
expanded,
|
||||||
|
config?.editor?.editor_type,
|
||||||
|
isStarting,
|
||||||
|
isStopping,
|
||||||
|
runningDevServer,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { useMemo, useCallback, type RefObject } from 'react';
|
import { useMemo, useCallback, type RefObject } from 'react';
|
||||||
import { useActions } from '@/contexts/ActionsContext';
|
import { useActions } from '@/contexts/ActionsContext';
|
||||||
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
|
||||||
import { useUserSystem } from '@/components/ConfigProvider';
|
import { useUserSystem } from '@/components/ConfigProvider';
|
||||||
import { useDevServer } from '@/hooks/useDevServer';
|
|
||||||
import { ContextBar } from '../primitives/ContextBar';
|
import { ContextBar } from '../primitives/ContextBar';
|
||||||
import {
|
import {
|
||||||
ContextBarActionGroups,
|
ContextBarActionGroups,
|
||||||
type ActionDefinition,
|
type ActionDefinition,
|
||||||
type ActionVisibilityContext,
|
type ActionVisibilityContext,
|
||||||
type ContextBarItem,
|
type ContextBarItem,
|
||||||
type DevServerState,
|
|
||||||
} from '../actions';
|
} from '../actions';
|
||||||
import {
|
import {
|
||||||
useActionVisibilityContext,
|
useActionVisibilityContext,
|
||||||
@@ -60,62 +57,26 @@ function filterContextBarItems(
|
|||||||
|
|
||||||
export interface ContextBarContainerProps {
|
export interface ContextBarContainerProps {
|
||||||
containerRef: RefObject<HTMLElement | null>;
|
containerRef: RefObject<HTMLElement | null>;
|
||||||
containerPath?: string; // workspace.container_ref for copy path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextBarContainer({
|
export function ContextBarContainer({
|
||||||
containerRef,
|
containerRef,
|
||||||
containerPath,
|
|
||||||
}: ContextBarContainerProps) {
|
}: ContextBarContainerProps) {
|
||||||
const { executorContext } = useActions();
|
const { executorContext } = useActions();
|
||||||
const { workspaceId } = useWorkspaceContext();
|
|
||||||
const { config } = useUserSystem();
|
const { config } = useUserSystem();
|
||||||
const editorType = config?.editor?.editor_type ?? null;
|
const editorType = config?.editor?.editor_type ?? null;
|
||||||
|
|
||||||
// Dev server state from hook (uses workspaceId from context)
|
// Get visibility context (now includes dev server state)
|
||||||
const { start, stop, isStarting, isStopping, runningDevServer } =
|
const actionCtx = useActionVisibilityContext();
|
||||||
useDevServer(workspaceId);
|
|
||||||
|
|
||||||
// Compute dev server state
|
// Action handler - use executor context directly from provider
|
||||||
const devServerState: DevServerState = useMemo(() => {
|
|
||||||
if (isStarting) return 'starting';
|
|
||||||
if (isStopping) return 'stopping';
|
|
||||||
if (runningDevServer) return 'running';
|
|
||||||
return 'stopped';
|
|
||||||
}, [isStarting, isStopping, runningDevServer]);
|
|
||||||
|
|
||||||
// Build extended visibility context
|
|
||||||
const baseCtx = useActionVisibilityContext();
|
|
||||||
const actionCtx = useMemo<ActionVisibilityContext>(
|
|
||||||
() => ({
|
|
||||||
...baseCtx,
|
|
||||||
editorType,
|
|
||||||
devServerState,
|
|
||||||
runningDevServerId: runningDevServer?.id,
|
|
||||||
}),
|
|
||||||
[baseCtx, editorType, devServerState, runningDevServer?.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build extended executor context with ContextBar-specific data
|
|
||||||
const extendedExecutorCtx = useMemo(
|
|
||||||
() => ({
|
|
||||||
...executorContext,
|
|
||||||
containerRef: containerPath,
|
|
||||||
runningDevServerId: runningDevServer?.id,
|
|
||||||
startDevServer: start,
|
|
||||||
stopDevServer: stop,
|
|
||||||
}),
|
|
||||||
[executorContext, containerPath, runningDevServer?.id, start, stop]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Action handler - pass extended context
|
|
||||||
const handleExecuteAction = useCallback(
|
const handleExecuteAction = useCallback(
|
||||||
async (action: ActionDefinition) => {
|
async (action: ActionDefinition) => {
|
||||||
if (!action.requiresTarget) {
|
if (action.requiresTarget === false) {
|
||||||
await action.execute(extendedExecutorCtx);
|
await action.execute(executorContext);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[extendedExecutorCtx]
|
[executorContext]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter visible actions
|
// Filter visible actions
|
||||||
|
|||||||
@@ -105,10 +105,8 @@ function GitPanelContainer({
|
|||||||
const actionDef = actionMap[action];
|
const actionDef = actionMap[action];
|
||||||
if (!actionDef) return;
|
if (!actionDef) return;
|
||||||
|
|
||||||
// Execute action with pre-selected repoId
|
// Execute git action with workspaceId and repoId
|
||||||
await executeAction(actionDef, selectedWorkspace.id, {
|
await executeAction(actionDef, selectedWorkspace.id, repoId);
|
||||||
gitRepoId: repoId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[selectedWorkspace, executeAction]
|
[selectedWorkspace, executeAction]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
|
||||||
StackIcon,
|
|
||||||
SlidersIcon,
|
|
||||||
SquaresFourIcon,
|
|
||||||
GitBranchIcon,
|
|
||||||
} from '@phosphor-icons/react';
|
|
||||||
import type { Workspace } from 'shared/types';
|
import type { Workspace } from 'shared/types';
|
||||||
import { defineModal } from '@/lib/modals';
|
import { defineModal } from '@/lib/modals';
|
||||||
import { CommandDialog } from '@/components/ui-new/primitives/Command';
|
import { CommandDialog } from '@/components/ui-new/primitives/Command';
|
||||||
@@ -14,34 +8,16 @@ import { CommandBar } from '@/components/ui-new/primitives/CommandBar';
|
|||||||
import { useActions } from '@/contexts/ActionsContext';
|
import { useActions } from '@/contexts/ActionsContext';
|
||||||
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
||||||
import { attemptKeys } from '@/hooks/useAttempt';
|
import { attemptKeys } from '@/hooks/useAttempt';
|
||||||
import { type ActionDefinition } from '@/components/ui-new/actions';
|
import type {
|
||||||
import {
|
PageId,
|
||||||
Pages,
|
ResolvedGroupItem,
|
||||||
getPageActions,
|
|
||||||
type PageId,
|
|
||||||
type CommandBarGroup,
|
|
||||||
type CommandBarGroupItem,
|
|
||||||
type ResolvedGroup,
|
|
||||||
type ResolvedGroupItem,
|
|
||||||
} from '@/components/ui-new/actions/pages';
|
} from '@/components/ui-new/actions/pages';
|
||||||
import { resolveLabel } from '@/components/ui-new/actions';
|
import { useActionVisibilityContext } from '@/components/ui-new/actions/useActionVisibility';
|
||||||
import {
|
import { useCommandBarState } from './commandBar/useCommandBarState';
|
||||||
useActionVisibilityContext,
|
import { useResolvedPage } from './commandBar/useResolvedPage';
|
||||||
isActionVisible,
|
|
||||||
isPageVisible,
|
|
||||||
} from '@/components/ui-new/actions/useActionVisibility';
|
|
||||||
|
|
||||||
// Resolved page structure passed to CommandBar
|
|
||||||
interface ResolvedCommandBarPage {
|
|
||||||
id: string;
|
|
||||||
title?: string;
|
|
||||||
groups: ResolvedGroup[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandBarDialogProps {
|
export interface CommandBarDialogProps {
|
||||||
// Page to show (defaults to 'root')
|
|
||||||
page?: PageId;
|
page?: PageId;
|
||||||
// Optional workspaceId for workspace actions
|
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,276 +27,74 @@ const CommandBarDialogImpl = NiceModal.create<CommandBarDialogProps>(
|
|||||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { executeAction, getLabel } = useActions();
|
const { executeAction, getLabel } = useActions();
|
||||||
const { workspaceId: contextWorkspaceId } = useWorkspaceContext();
|
const { workspaceId: contextWorkspaceId, repos } = useWorkspaceContext();
|
||||||
|
|
||||||
// Use prop workspaceId if provided, otherwise fall back to context
|
|
||||||
const effectiveWorkspaceId = workspaceId ?? contextWorkspaceId;
|
|
||||||
|
|
||||||
// Get visibility context for filtering actions
|
|
||||||
const visibilityContext = useActionVisibilityContext();
|
const visibilityContext = useActionVisibilityContext();
|
||||||
|
|
||||||
// Page navigation state (lifted from CommandBar)
|
const effectiveWorkspaceId = workspaceId ?? contextWorkspaceId;
|
||||||
const [currentPage, setCurrentPage] = useState<PageId>(page);
|
|
||||||
const [pageStack, setPageStack] = useState<PageId[]>([]);
|
|
||||||
// Search state - cleared when page changes
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
// Reset page state when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (modal.visible) {
|
|
||||||
setCurrentPage(page);
|
|
||||||
setPageStack([]);
|
|
||||||
setSearch('');
|
|
||||||
}
|
|
||||||
}, [modal.visible, page]);
|
|
||||||
|
|
||||||
// Clear search when navigating to a new page
|
|
||||||
useEffect(() => {
|
|
||||||
setSearch('');
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// Get workspace from cache for label resolution
|
|
||||||
const workspace = effectiveWorkspaceId
|
const workspace = effectiveWorkspaceId
|
||||||
? queryClient.getQueryData<Workspace>(
|
? queryClient.getQueryData<Workspace>(
|
||||||
attemptKeys.byId(effectiveWorkspaceId)
|
attemptKeys.byId(effectiveWorkspaceId)
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Build resolved page by processing childPages markers within groups
|
// State machine
|
||||||
// When searching on root page, also include actions from nested pages
|
const { state, currentPage, canGoBack, dispatch } = useCommandBarState(
|
||||||
const getPageWithItems = useMemo(() => {
|
page,
|
||||||
return (pageId: PageId, searchQuery: string): ResolvedCommandBarPage => {
|
repos.length
|
||||||
const basePage = Pages[pageId];
|
);
|
||||||
|
|
||||||
// Process each group, expanding childPages markers within
|
// Reset state and capture focus when dialog opens
|
||||||
const resolvedGroups: ResolvedGroup[] = basePage.items
|
|
||||||
.map((group: CommandBarGroup): ResolvedGroup | null => {
|
|
||||||
const resolvedItems = group.items.flatMap(
|
|
||||||
(item: CommandBarGroupItem): ResolvedGroupItem[] => {
|
|
||||||
if (item.type === 'childPages') {
|
|
||||||
const childPage = Pages[item.id];
|
|
||||||
// Check page visibility condition
|
|
||||||
if (!isPageVisible(childPage, visibilityContext)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// Get icon based on page type
|
|
||||||
const pageIcons: Record<PageId, typeof StackIcon> = {
|
|
||||||
root: SquaresFourIcon,
|
|
||||||
workspaceActions: StackIcon,
|
|
||||||
diffOptions: SlidersIcon,
|
|
||||||
viewOptions: SquaresFourIcon,
|
|
||||||
gitActions: GitBranchIcon,
|
|
||||||
};
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'page' as const,
|
|
||||||
pageId: item.id,
|
|
||||||
label: childPage.title ?? item.id,
|
|
||||||
icon: pageIcons[item.id],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// For action items, filter by visibility condition
|
|
||||||
if (item.type === 'action') {
|
|
||||||
if (!isActionVisible(item.action, visibilityContext)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// action or page items pass through
|
|
||||||
return [item];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return null for empty groups (will be filtered out)
|
|
||||||
if (resolvedItems.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: group.label,
|
|
||||||
items: resolvedItems,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((group): group is ResolvedGroup => group !== null);
|
|
||||||
|
|
||||||
// When searching on root page, inject matching actions from nested pages
|
|
||||||
if (pageId === 'root' && searchQuery.trim()) {
|
|
||||||
const searchLower = searchQuery.toLowerCase();
|
|
||||||
|
|
||||||
// Inject workspace actions if workspace is available
|
|
||||||
if (visibilityContext.hasWorkspace) {
|
|
||||||
const workspaceActions = getPageActions('workspaceActions');
|
|
||||||
const matchingWorkspaceActions = workspaceActions
|
|
||||||
.filter((action) => isActionVisible(action, visibilityContext))
|
|
||||||
.filter((action) => {
|
|
||||||
const label = resolveLabel(action, workspace);
|
|
||||||
return (
|
|
||||||
label.toLowerCase().includes(searchLower) ||
|
|
||||||
action.id.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchingWorkspaceActions.length > 0) {
|
|
||||||
resolvedGroups.push({
|
|
||||||
label: Pages.workspaceActions.title || 'Workspace Actions',
|
|
||||||
items: matchingWorkspaceActions.map((action) => ({
|
|
||||||
type: 'action' as const,
|
|
||||||
action,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject diff options (filtered by visibility)
|
|
||||||
const diffActions = getPageActions('diffOptions');
|
|
||||||
const matchingDiffActions = diffActions
|
|
||||||
.filter((action) => isActionVisible(action, visibilityContext))
|
|
||||||
.filter((action) => {
|
|
||||||
const label = resolveLabel(action, workspace);
|
|
||||||
return (
|
|
||||||
label.toLowerCase().includes(searchLower) ||
|
|
||||||
action.id.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchingDiffActions.length > 0) {
|
|
||||||
resolvedGroups.push({
|
|
||||||
label: Pages.diffOptions.title || 'Diff Options',
|
|
||||||
items: matchingDiffActions.map((action) => ({
|
|
||||||
type: 'action' as const,
|
|
||||||
action,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject view options (filtered by visibility)
|
|
||||||
const viewActions = getPageActions('viewOptions');
|
|
||||||
const matchingViewActions = viewActions
|
|
||||||
.filter((action) => isActionVisible(action, visibilityContext))
|
|
||||||
.filter((action) => {
|
|
||||||
const label = resolveLabel(action, workspace);
|
|
||||||
return (
|
|
||||||
label.toLowerCase().includes(searchLower) ||
|
|
||||||
action.id.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchingViewActions.length > 0) {
|
|
||||||
resolvedGroups.push({
|
|
||||||
label: Pages.viewOptions.title || 'View Options',
|
|
||||||
items: matchingViewActions.map((action) => ({
|
|
||||||
type: 'action' as const,
|
|
||||||
action,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject git actions if workspace has git repos
|
|
||||||
if (visibilityContext.hasGitRepos) {
|
|
||||||
const gitActions = getPageActions('gitActions');
|
|
||||||
const matchingGitActions = gitActions
|
|
||||||
.filter((action) => isActionVisible(action, visibilityContext))
|
|
||||||
.filter((action) => {
|
|
||||||
const label = resolveLabel(action, workspace);
|
|
||||||
return (
|
|
||||||
label.toLowerCase().includes(searchLower) ||
|
|
||||||
action.id.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchingGitActions.length > 0) {
|
|
||||||
resolvedGroups.push({
|
|
||||||
label: Pages.gitActions.title || 'Git Actions',
|
|
||||||
items: matchingGitActions.map((action) => ({
|
|
||||||
type: 'action' as const,
|
|
||||||
action,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: basePage.id,
|
|
||||||
title: basePage.title,
|
|
||||||
groups: resolvedGroups,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}, [visibilityContext, workspace]);
|
|
||||||
|
|
||||||
// Store the previously focused element when dialog opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modal.visible) {
|
if (modal.visible) {
|
||||||
|
dispatch({ type: 'RESET', page });
|
||||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||||
}
|
}
|
||||||
}, [modal.visible]);
|
}, [modal.visible, page, dispatch]);
|
||||||
|
|
||||||
// Navigate to another page
|
// Resolve current page to renderable data
|
||||||
const navigateToPage = useCallback(
|
const resolvedPage = useResolvedPage(
|
||||||
(pageId: PageId) => {
|
currentPage,
|
||||||
setPageStack((prev) => [...prev, currentPage]);
|
state.search,
|
||||||
setCurrentPage(pageId);
|
visibilityContext,
|
||||||
},
|
workspace,
|
||||||
[currentPage]
|
repos
|
||||||
);
|
);
|
||||||
|
|
||||||
// Go back to previous page
|
// Handle item selection with side effects
|
||||||
const goBack = useCallback(() => {
|
|
||||||
const prevPage = pageStack[pageStack.length - 1];
|
|
||||||
if (prevPage) {
|
|
||||||
setPageStack((prev) => prev.slice(0, -1));
|
|
||||||
setCurrentPage(prevPage);
|
|
||||||
}
|
|
||||||
}, [pageStack]);
|
|
||||||
|
|
||||||
// Handle item selection
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
async (item: ResolvedGroupItem) => {
|
(item: ResolvedGroupItem) => {
|
||||||
if (item.type === 'page') {
|
const effect = dispatch({ type: 'SELECT_ITEM', item });
|
||||||
navigateToPage(item.pageId);
|
|
||||||
} else if (item.type === 'action') {
|
if (effect.type === 'execute') {
|
||||||
modal.hide();
|
modal.hide();
|
||||||
await executeAction(item.action, effectiveWorkspaceId);
|
const repoId =
|
||||||
|
effect.repoId === '__single__' ? repos[0]?.id : effect.repoId;
|
||||||
|
executeAction(effect.action, effectiveWorkspaceId, repoId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigateToPage, modal, executeAction, effectiveWorkspaceId]
|
[dispatch, modal, executeAction, effectiveWorkspaceId, repos]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get label for an action (with visibility context for dynamic labels)
|
// Restore focus when dialog closes
|
||||||
const getLabelForAction = useCallback(
|
const handleCloseAutoFocus = useCallback((event: Event) => {
|
||||||
(action: ActionDefinition) =>
|
|
||||||
getLabel(action, workspace, visibilityContext),
|
|
||||||
[getLabel, workspace, visibilityContext]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore focus to previously focused element when dialog closes
|
|
||||||
const handleCloseAutoFocus = (event: Event) => {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
previousFocusRef.current?.focus();
|
previousFocusRef.current?.focus();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const canGoBack = pageStack.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog
|
<CommandDialog
|
||||||
open={modal.visible}
|
open={modal.visible}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={(open) => !open && modal.hide()}
|
||||||
onCloseAutoFocus={handleCloseAutoFocus}
|
onCloseAutoFocus={handleCloseAutoFocus}
|
||||||
>
|
>
|
||||||
<CommandBar
|
<CommandBar
|
||||||
page={getPageWithItems(currentPage, search)}
|
page={resolvedPage}
|
||||||
canGoBack={canGoBack}
|
canGoBack={canGoBack}
|
||||||
onGoBack={goBack}
|
onGoBack={() => dispatch({ type: 'GO_BACK' })}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
getLabel={getLabelForAction}
|
getLabel={(action) => getLabel(action, workspace, visibilityContext)}
|
||||||
search={search}
|
search={state.search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={(query) => dispatch({ type: 'SEARCH_CHANGE', query })}
|
||||||
/>
|
/>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Workspace } from 'shared/types';
|
||||||
|
import {
|
||||||
|
Pages,
|
||||||
|
getPageActions,
|
||||||
|
type StaticPageId,
|
||||||
|
type ResolvedGroup,
|
||||||
|
} from '@/components/ui-new/actions/pages';
|
||||||
|
import {
|
||||||
|
resolveLabel,
|
||||||
|
type ActionVisibilityContext,
|
||||||
|
} from '@/components/ui-new/actions';
|
||||||
|
import { isActionVisible } from '@/components/ui-new/actions/useActionVisibility';
|
||||||
|
|
||||||
|
const INJECTABLE_PAGES: Array<{
|
||||||
|
id: StaticPageId;
|
||||||
|
condition: (ctx: ActionVisibilityContext) => boolean;
|
||||||
|
}> = [
|
||||||
|
{ id: 'workspaceActions', condition: (ctx) => ctx.hasWorkspace },
|
||||||
|
{ id: 'diffOptions', condition: () => true },
|
||||||
|
{ id: 'viewOptions', condition: () => true },
|
||||||
|
{ id: 'gitActions', condition: (ctx) => ctx.hasGitRepos },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function injectSearchMatches(
|
||||||
|
searchQuery: string,
|
||||||
|
ctx: ActionVisibilityContext,
|
||||||
|
workspace: Workspace | undefined
|
||||||
|
): ResolvedGroup[] {
|
||||||
|
const searchLower = searchQuery.toLowerCase();
|
||||||
|
|
||||||
|
return INJECTABLE_PAGES.reduce<ResolvedGroup[]>(
|
||||||
|
(groups, { id, condition }) => {
|
||||||
|
if (!condition(ctx)) return groups;
|
||||||
|
|
||||||
|
const items = getPageActions(id)
|
||||||
|
.filter((a) => isActionVisible(a, ctx))
|
||||||
|
.filter((a) => {
|
||||||
|
const label = resolveLabel(a, workspace);
|
||||||
|
return (
|
||||||
|
label.toLowerCase().includes(searchLower) ||
|
||||||
|
a.id.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((action) => ({ type: 'action' as const, action }));
|
||||||
|
|
||||||
|
if (items.length) groups.push({ label: Pages[id].title || id, items });
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useReducer, useCallback, useRef } from 'react';
|
||||||
|
import type {
|
||||||
|
PageId,
|
||||||
|
ResolvedGroupItem,
|
||||||
|
} from '@/components/ui-new/actions/pages';
|
||||||
|
import type {
|
||||||
|
ActionDefinition,
|
||||||
|
GitActionDefinition,
|
||||||
|
} from '@/components/ui-new/actions';
|
||||||
|
|
||||||
|
export type CommandBarState =
|
||||||
|
| { status: 'browsing'; page: PageId; stack: PageId[]; search: string }
|
||||||
|
| {
|
||||||
|
status: 'selectingRepo';
|
||||||
|
stack: PageId[];
|
||||||
|
search: string;
|
||||||
|
pendingAction: GitActionDefinition;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandBarEvent =
|
||||||
|
| { type: 'RESET'; page: PageId }
|
||||||
|
| { type: 'SEARCH_CHANGE'; query: string }
|
||||||
|
| { type: 'GO_BACK' }
|
||||||
|
| { type: 'SELECT_ITEM'; item: ResolvedGroupItem };
|
||||||
|
|
||||||
|
export type CommandBarEffect =
|
||||||
|
| { type: 'none' }
|
||||||
|
| { type: 'execute'; action: ActionDefinition; repoId?: string };
|
||||||
|
|
||||||
|
const browsing = (page: PageId, stack: PageId[] = []): CommandBarState => ({
|
||||||
|
status: 'browsing',
|
||||||
|
page,
|
||||||
|
stack,
|
||||||
|
search: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const noEffect: CommandBarEffect = { type: 'none' };
|
||||||
|
|
||||||
|
function reducer(
|
||||||
|
state: CommandBarState,
|
||||||
|
event: CommandBarEvent,
|
||||||
|
repoCount: number
|
||||||
|
): [CommandBarState, CommandBarEffect] {
|
||||||
|
if (event.type === 'RESET') {
|
||||||
|
return [browsing(event.page), noEffect];
|
||||||
|
}
|
||||||
|
if (event.type === 'SEARCH_CHANGE') {
|
||||||
|
return [{ ...state, search: event.query }, noEffect];
|
||||||
|
}
|
||||||
|
if (event.type === 'GO_BACK') {
|
||||||
|
const prevPage = state.stack[state.stack.length - 1];
|
||||||
|
if (state.status === 'browsing' && !prevPage) return [state, noEffect];
|
||||||
|
return [browsing(prevPage ?? 'root', state.stack.slice(0, -1)), noEffect];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'SELECT_ITEM') {
|
||||||
|
if (state.status === 'selectingRepo' && event.item.type === 'repo') {
|
||||||
|
return [
|
||||||
|
browsing('root'),
|
||||||
|
{
|
||||||
|
type: 'execute',
|
||||||
|
action: state.pendingAction,
|
||||||
|
repoId: event.item.repo.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === 'browsing') {
|
||||||
|
const { item } = event;
|
||||||
|
if (item.type === 'page') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
page: item.pageId,
|
||||||
|
stack: [...state.stack, state.page],
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
noEffect,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (item.type === 'action') {
|
||||||
|
if (item.action.requiresTarget === 'git') {
|
||||||
|
if (repoCount === 1) {
|
||||||
|
return [
|
||||||
|
state,
|
||||||
|
{ type: 'execute', action: item.action, repoId: '__single__' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (repoCount > 1) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
status: 'selectingRepo',
|
||||||
|
stack: [...state.stack, state.page],
|
||||||
|
search: '',
|
||||||
|
pendingAction: item.action as GitActionDefinition,
|
||||||
|
},
|
||||||
|
noEffect,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [state, { type: 'execute', action: item.action }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [state, noEffect];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandBarState(initialPage: PageId, repoCount: number) {
|
||||||
|
// Use refs to avoid stale closures and keep dispatch stable
|
||||||
|
const stateRef = useRef<CommandBarState>(browsing(initialPage));
|
||||||
|
const repoCountRef = useRef(repoCount);
|
||||||
|
repoCountRef.current = repoCount;
|
||||||
|
|
||||||
|
const [state, rawDispatch] = useReducer(
|
||||||
|
(s: CommandBarState, e: CommandBarEvent) => {
|
||||||
|
const [newState] = reducer(s, e, repoCountRef.current);
|
||||||
|
stateRef.current = newState;
|
||||||
|
return newState;
|
||||||
|
},
|
||||||
|
browsing(initialPage)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep stateRef in sync
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
// Stable dispatch that doesn't change on every render
|
||||||
|
const dispatch = useCallback(
|
||||||
|
(event: CommandBarEvent): CommandBarEffect => {
|
||||||
|
const [, effect] = reducer(stateRef.current, event, repoCountRef.current);
|
||||||
|
rawDispatch(event);
|
||||||
|
return effect;
|
||||||
|
},
|
||||||
|
[] // No dependencies - uses refs for current values
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
currentPage: (state.status === 'selectingRepo'
|
||||||
|
? 'selectRepo'
|
||||||
|
: state.page) as PageId,
|
||||||
|
canGoBack: state.stack.length > 0,
|
||||||
|
dispatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
StackIcon,
|
||||||
|
SlidersIcon,
|
||||||
|
SquaresFourIcon,
|
||||||
|
GitBranchIcon,
|
||||||
|
} from '@phosphor-icons/react';
|
||||||
|
import type { Workspace } from 'shared/types';
|
||||||
|
import {
|
||||||
|
Pages,
|
||||||
|
type PageId,
|
||||||
|
type StaticPageId,
|
||||||
|
type CommandBarGroupItem,
|
||||||
|
type ResolvedGroup,
|
||||||
|
type ResolvedGroupItem,
|
||||||
|
type RepoItem,
|
||||||
|
} from '@/components/ui-new/actions/pages';
|
||||||
|
import type { ActionVisibilityContext } from '@/components/ui-new/actions';
|
||||||
|
import {
|
||||||
|
isActionVisible,
|
||||||
|
isPageVisible,
|
||||||
|
} from '@/components/ui-new/actions/useActionVisibility';
|
||||||
|
import { injectSearchMatches } from './injectSearchMatches';
|
||||||
|
|
||||||
|
export interface ResolvedCommandBarPage {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
groups: ResolvedGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_ICONS = {
|
||||||
|
root: SquaresFourIcon,
|
||||||
|
workspaceActions: StackIcon,
|
||||||
|
diffOptions: SlidersIcon,
|
||||||
|
viewOptions: SquaresFourIcon,
|
||||||
|
gitActions: GitBranchIcon,
|
||||||
|
} as const satisfies Record<StaticPageId, typeof StackIcon>;
|
||||||
|
|
||||||
|
function expandGroupItems(
|
||||||
|
items: CommandBarGroupItem[],
|
||||||
|
ctx: ActionVisibilityContext
|
||||||
|
): ResolvedGroupItem[] {
|
||||||
|
return items.flatMap((item) => {
|
||||||
|
if (item.type === 'childPages') {
|
||||||
|
const page = Pages[item.id as StaticPageId];
|
||||||
|
if (!isPageVisible(page, ctx)) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'page' as const,
|
||||||
|
pageId: item.id,
|
||||||
|
label: page.title ?? item.id,
|
||||||
|
icon: PAGE_ICONS[item.id as StaticPageId],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (item.type === 'action' && !isActionVisible(item.action, ctx)) return [];
|
||||||
|
return [item];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageGroups(
|
||||||
|
pageId: StaticPageId,
|
||||||
|
ctx: ActionVisibilityContext
|
||||||
|
): ResolvedGroup[] {
|
||||||
|
return Pages[pageId].items
|
||||||
|
.map((group) => {
|
||||||
|
const items = expandGroupItems(group.items, ctx);
|
||||||
|
return items.length ? { label: group.label, items } : null;
|
||||||
|
})
|
||||||
|
.filter((g): g is ResolvedGroup => g !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolvedPage(
|
||||||
|
pageId: PageId,
|
||||||
|
search: string,
|
||||||
|
ctx: ActionVisibilityContext,
|
||||||
|
workspace: Workspace | undefined,
|
||||||
|
repos: RepoItem[]
|
||||||
|
): ResolvedCommandBarPage {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (pageId === 'selectRepo') {
|
||||||
|
return {
|
||||||
|
id: 'selectRepo',
|
||||||
|
title: 'Select Repository',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Repositories',
|
||||||
|
items: repos.map((r) => ({ type: 'repo' as const, repo: r })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = buildPageGroups(pageId as StaticPageId, ctx);
|
||||||
|
if (pageId === 'root' && search.trim()) {
|
||||||
|
groups.push(...injectSearchMatches(search, ctx, workspace));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Pages[pageId as StaticPageId].id,
|
||||||
|
title: Pages[pageId as StaticPageId].title,
|
||||||
|
groups,
|
||||||
|
};
|
||||||
|
}, [pageId, search, ctx, workspace, repos]);
|
||||||
|
}
|
||||||
@@ -35,9 +35,7 @@ function CommandDialog({
|
|||||||
hideCloseButton
|
hideCloseButton
|
||||||
onCloseAutoFocus={onCloseAutoFocus}
|
onCloseAutoFocus={onCloseAutoFocus}
|
||||||
>
|
>
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-base [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-low [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-half [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-base [&_[cmdk-item]]:py-half">
|
{children}
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CaretLeftIcon, CopyIcon } from '@phosphor-icons/react';
|
import { CaretLeftIcon, CopyIcon, FolderIcon } from '@phosphor-icons/react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -68,7 +68,7 @@ export function CommandBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Command
|
<Command
|
||||||
className="rounded-sm border border-border"
|
className="rounded-sm border border-border [&_[cmdk-group-heading]]:px-base [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-low [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-half [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-base [&_[cmdk-item]]:py-half"
|
||||||
loop
|
loop
|
||||||
filter={(value, search) => {
|
filter={(value, search) => {
|
||||||
// Always show the back option
|
// Always show the back option
|
||||||
@@ -111,6 +111,17 @@ export function CommandBar({
|
|||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
|
} else if (item.type === 'repo') {
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={item.repo.id}
|
||||||
|
value={`${item.repo.id} ${item.repo.display_name}`}
|
||||||
|
onSelect={() => onSelect(item)}
|
||||||
|
>
|
||||||
|
<FolderIcon className="h-4 w-4" weight="regular" />
|
||||||
|
<span>{item.repo.display_name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
} else if (item.type === 'action') {
|
} else if (item.type === 'action') {
|
||||||
const label = getLabel(item.action);
|
const label = getLabel(item.action);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ReactNode, useRef } from 'react';
|
import { ReactNode, useRef } from 'react';
|
||||||
import { PortalContainerContext } from '@/contexts/PortalContainerContext';
|
import { PortalContainerContext } from '@/contexts/PortalContainerContext';
|
||||||
import { WorkspaceProvider } from '@/contexts/WorkspaceContext';
|
import {
|
||||||
|
WorkspaceProvider,
|
||||||
|
useWorkspaceContext,
|
||||||
|
} from '@/contexts/WorkspaceContext';
|
||||||
import { ActionsProvider } from '@/contexts/ActionsContext';
|
import { ActionsProvider } from '@/contexts/ActionsContext';
|
||||||
|
import { ExecutionProcessesProvider } from '@/contexts/ExecutionProcessesContext';
|
||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from '@ebay/nice-modal-react';
|
||||||
import '@/styles/new/index.css';
|
import '@/styles/new/index.css';
|
||||||
|
|
||||||
@@ -9,15 +13,31 @@ interface NewDesignScopeProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrapper component to get workspaceId from context for ExecutionProcessesProvider
|
||||||
|
function ExecutionProcessesProviderWrapper({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { workspaceId } = useWorkspaceContext();
|
||||||
|
return (
|
||||||
|
<ExecutionProcessesProvider attemptId={workspaceId}>
|
||||||
|
{children}
|
||||||
|
</ExecutionProcessesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function NewDesignScope({ children }: NewDesignScopeProps) {
|
export function NewDesignScope({ children }: NewDesignScopeProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="new-design h-full">
|
<div ref={ref} className="new-design h-full">
|
||||||
<PortalContainerContext.Provider value={ref}>
|
<PortalContainerContext.Provider value={ref}>
|
||||||
<WorkspaceProvider>
|
<WorkspaceProvider>
|
||||||
<ActionsProvider>
|
<ExecutionProcessesProviderWrapper>
|
||||||
<NiceModal.Provider>{children}</NiceModal.Provider>
|
<ActionsProvider>
|
||||||
</ActionsProvider>
|
<NiceModal.Provider>{children}</NiceModal.Provider>
|
||||||
|
</ActionsProvider>
|
||||||
|
</ExecutionProcessesProviderWrapper>
|
||||||
</WorkspaceProvider>
|
</WorkspaceProvider>
|
||||||
</PortalContainerContext.Provider>
|
</PortalContainerContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ export function WorkspacesMain({
|
|||||||
</ApprovalFeedbackProvider>
|
</ApprovalFeedbackProvider>
|
||||||
{/* Context Bar - floating toolbar */}
|
{/* Context Bar - floating toolbar */}
|
||||||
{workspaceWithSession && (
|
{workspaceWithSession && (
|
||||||
<ContextBarContainer
|
<ContextBarContainer containerRef={containerRef} />
|
||||||
containerRef={containerRef}
|
|
||||||
containerPath={workspaceWithSession.container_ref ?? undefined}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import {
|
|||||||
} from '@/components/ui-new/actions';
|
} from '@/components/ui-new/actions';
|
||||||
import { getActionLabel } from '@/components/ui-new/actions/useActionVisibility';
|
import { getActionLabel } from '@/components/ui-new/actions/useActionVisibility';
|
||||||
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
||||||
|
import { useDevServer } from '@/hooks/useDevServer';
|
||||||
|
|
||||||
interface ActionsContextValue {
|
interface ActionsContextValue {
|
||||||
// Execute an action with optional workspaceId and context override
|
// Execute an action with optional workspaceId and repoId (for git actions)
|
||||||
executeAction: (
|
executeAction: (
|
||||||
action: ActionDefinition,
|
action: ActionDefinition,
|
||||||
workspaceId?: string,
|
workspaceId?: string,
|
||||||
contextOverride?: Partial<ActionExecutorContext>
|
repoId?: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
// Get resolved label for an action (supports dynamic labels via visibility context)
|
// Get resolved label for an action (supports dynamic labels via visibility context)
|
||||||
@@ -47,9 +48,12 @@ export function ActionsProvider({ children }: ActionsProviderProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
// Get workspace context (ActionsProvider is nested inside WorkspaceProvider)
|
// Get workspace context (ActionsProvider is nested inside WorkspaceProvider)
|
||||||
const { selectWorkspace, activeWorkspaces, workspaceId } =
|
const { selectWorkspace, activeWorkspaces, workspaceId, workspace } =
|
||||||
useWorkspaceContext();
|
useWorkspaceContext();
|
||||||
|
|
||||||
|
// Get dev server state
|
||||||
|
const { start, stop, runningDevServer } = useDevServer(workspaceId);
|
||||||
|
|
||||||
// Build executor context from hooks
|
// Build executor context from hooks
|
||||||
const executorContext = useMemo<ActionExecutorContext>(
|
const executorContext = useMemo<ActionExecutorContext>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -57,9 +61,23 @@ export function ActionsProvider({ children }: ActionsProviderProps) {
|
|||||||
queryClient,
|
queryClient,
|
||||||
selectWorkspace,
|
selectWorkspace,
|
||||||
activeWorkspaces,
|
activeWorkspaces,
|
||||||
currentWorkspaceId: workspaceId,
|
currentWorkspaceId: workspaceId ?? null,
|
||||||
|
containerRef: workspace?.container_ref ?? null,
|
||||||
|
runningDevServerId: runningDevServer?.id ?? null,
|
||||||
|
startDevServer: start,
|
||||||
|
stopDevServer: stop,
|
||||||
}),
|
}),
|
||||||
[navigate, queryClient, selectWorkspace, activeWorkspaces, workspaceId]
|
[
|
||||||
|
navigate,
|
||||||
|
queryClient,
|
||||||
|
selectWorkspace,
|
||||||
|
activeWorkspaces,
|
||||||
|
workspaceId,
|
||||||
|
workspace?.container_ref,
|
||||||
|
runningDevServer?.id,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Main action executor with centralized target validation and error handling
|
// Main action executor with centralized target validation and error handling
|
||||||
@@ -67,23 +85,25 @@ export function ActionsProvider({ children }: ActionsProviderProps) {
|
|||||||
async (
|
async (
|
||||||
action: ActionDefinition,
|
action: ActionDefinition,
|
||||||
workspaceId?: string,
|
workspaceId?: string,
|
||||||
contextOverride?: Partial<ActionExecutorContext>
|
repoId?: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Merge context with any overrides (e.g., gitRepoId from GitPanelContainer)
|
if (action.requiresTarget === 'git') {
|
||||||
const ctx = contextOverride
|
if (!workspaceId || !repoId) {
|
||||||
? { ...executorContext, ...contextOverride }
|
throw new Error(
|
||||||
: executorContext;
|
`Action "${action.id}" requires both workspace and repository`
|
||||||
|
);
|
||||||
if (action.requiresTarget) {
|
}
|
||||||
|
await action.execute(executorContext, workspaceId, repoId);
|
||||||
|
} else if (action.requiresTarget === true) {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Action "${action.id}" requires a workspace target`
|
`Action "${action.id}" requires a workspace target`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await action.execute(ctx, workspaceId);
|
await action.execute(executorContext, workspaceId);
|
||||||
} else {
|
} else {
|
||||||
await action.execute(ctx);
|
await action.execute(executorContext);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Show error to user via alert dialog
|
// Show error to user via alert dialog
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const ELIPSIS_N = 6;
|
|
||||||
|
|
||||||
export function DisplayTruncatedPath({ path }: { path: string }) {
|
export function DisplayTruncatedPath({ path }: { path: string }) {
|
||||||
const isWindows = path.includes('\\');
|
const isWindows = path.includes('\\');
|
||||||
const parts = isWindows ? path.split('\\') : path.split('/');
|
const parts = isWindows ? path.split('\\') : path.split('/');
|
||||||
@@ -9,16 +7,17 @@ export function DisplayTruncatedPath({ path }: { path: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="h-[1lh] overflow-hidden">
|
<div className="h-[1lh] overflow-hidden">
|
||||||
<div className="flex flex-row-reverse flex-wrap justify-end relative pl-2">
|
<div className="flex flex-row-reverse flex-wrap justify-end relative pl-2">
|
||||||
{[...Array(ELIPSIS_N)].map((_, i) => (
|
<ElipsisComponent className="bottom-[1lh]" />
|
||||||
<div
|
<ElipsisComponent className="bottom-[2lh]" />
|
||||||
className={cn(
|
<ElipsisComponent className="bottom-[3lh]" />
|
||||||
'absolute -translate-x-full tracking-tighter',
|
<ElipsisComponent className="bottom-[4lh]" />
|
||||||
`bottom-[${i + 1}lh]`
|
<ElipsisComponent className="bottom-[5lh]" />
|
||||||
)}
|
<ElipsisComponent className="bottom-[6lh]" />
|
||||||
>
|
<ElipsisComponent className="bottom-[7lh]" />
|
||||||
...
|
<ElipsisComponent className="bottom-[8lh]" />
|
||||||
</div>
|
<ElipsisComponent className="bottom-[9lh]" />
|
||||||
))}
|
<ElipsisComponent className="bottom-[10lh]" />
|
||||||
|
|
||||||
{parts.reverse().map((part, index) => (
|
{parts.reverse().map((part, index) => (
|
||||||
<span className="flex-none font-ibm-plex-mono " key={index}>
|
<span className="flex-none font-ibm-plex-mono " key={index}>
|
||||||
{isWindows ? '\\' : '/'}
|
{isWindows ? '\\' : '/'}
|
||||||
@@ -29,3 +28,13 @@ export function DisplayTruncatedPath({ path }: { path: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ElipsisComponent = ({ className }: { className: string }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('absolute -translate-x-full tracking-tighter', className)}
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user