Files
vibe-kanban/frontend/src/components/ui-new/actions/index.ts

757 lines
23 KiB
TypeScript
Raw Normal View History

2026-01-08 22:14:38 +00:00
import type { Icon } from '@phosphor-icons/react';
import type { NavigateFunction } from 'react-router-dom';
import type { QueryClient } from '@tanstack/react-query';
import type { EditorType, Workspace } from 'shared/types';
import type { DiffViewMode } from '@/stores/useDiffViewStore';
import {
CopyIcon,
PushPinIcon,
ArchiveIcon,
TrashIcon,
PlusIcon,
GearIcon,
ColumnsIcon,
RowsIcon,
TextAlignLeftIcon,
EyeSlashIcon,
SidebarSimpleIcon,
ChatsTeardropIcon,
GitDiffIcon,
TerminalIcon,
SignOutIcon,
CaretDoubleUpIcon,
CaretDoubleDownIcon,
PlayIcon,
PauseIcon,
SpinnerIcon,
GitPullRequestIcon,
GitMergeIcon,
ArrowsClockwiseIcon,
CrosshairIcon,
DesktopIcon,
} from '@phosphor-icons/react';
import { useDiffViewStore } from '@/stores/useDiffViewStore';
import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore';
import { useLayoutStore } from '@/stores/useLayoutStore';
import { attemptsApi, tasksApi, repoApi } from '@/lib/api';
import { attemptKeys } from '@/hooks/useAttempt';
import { taskKeys } from '@/hooks/useTask';
import { workspaceSummaryKeys } from '@/components/ui-new/hooks/useWorkspaces';
import { ConfirmDialog } from '@/components/ui-new/dialogs/ConfirmDialog';
import { ChangeTargetDialog } from '@/components/ui-new/dialogs/ChangeTargetDialog';
import { RebaseDialog } from '@/components/ui-new/dialogs/RebaseDialog';
import { CreatePRDialog } from '@/components/dialogs/tasks/CreatePRDialog';
import { getIdeName } from '@/components/ide/IdeIcon';
import { EditorSelectionDialog } from '@/components/dialogs/tasks/EditorSelectionDialog';
// Special icon types for ContextBar
export type SpecialIconType = 'ide-icon' | 'copy-icon';
export type ActionIcon = Icon | SpecialIconType;
// Workspace type for sidebar (minimal subset needed for workspace selection)
interface SidebarWorkspace {
id: string;
}
// Dev server state type for visibility context
export type DevServerState = 'stopped' | 'starting' | 'running' | 'stopping';
// Context provided to action executors (from React hooks)
export interface ActionExecutorContext {
navigate: NavigateFunction;
queryClient: QueryClient;
// Optional workspace selection context (for archive action)
selectWorkspace?: (workspaceId: string) => void;
activeWorkspaces?: SidebarWorkspace[];
// Current workspace ID (for actions that optionally use workspace context)
currentWorkspaceId?: string;
// ContextBar-specific state (optional, only set in ContextBar context)
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
export interface ActionVisibilityContext {
// Layout state
isChangesMode: boolean;
isLogsMode: boolean;
isPreviewMode: boolean;
isSidebarVisible: boolean;
isMainPanelVisible: boolean;
isGitPanelVisible: boolean;
isCreateMode: boolean;
// Workspace state
hasWorkspace: boolean;
workspaceArchived: boolean;
// Diff state
hasDiffs: boolean;
diffViewMode: DiffViewMode;
isAllDiffsExpanded: boolean;
// ContextBar-specific state (optional)
editorType?: EditorType | null;
devServerState?: DevServerState;
runningDevServerId?: string;
// Git panel state
hasGitRepos: boolean;
hasMultipleRepos: boolean;
}
// Base properties shared by all actions
interface ActionBase {
id: string;
label: string | ((workspace?: Workspace) => string);
icon: ActionIcon;
shortcut?: string;
variant?: 'default' | 'destructive';
// Optional visibility condition - if omitted, action is always visible
isVisible?: (ctx: ActionVisibilityContext) => boolean;
// Optional active state - if omitted, action is not active
isActive?: (ctx: ActionVisibilityContext) => boolean;
// Optional enabled state - if omitted, action is enabled
isEnabled?: (ctx: ActionVisibilityContext) => boolean;
// Optional dynamic icon - if omitted, uses static icon property
getIcon?: (ctx: ActionVisibilityContext) => ActionIcon;
// Optional dynamic tooltip - if omitted, uses label
getTooltip?: (ctx: ActionVisibilityContext) => string;
// Optional dynamic label - if omitted, uses static label property
getLabel?: (ctx: ActionVisibilityContext) => string;
}
// Global action (no target needed)
export interface GlobalActionDefinition extends ActionBase {
requiresTarget: false;
execute: (ctx: ActionExecutorContext) => Promise<void> | void;
}
// Workspace action (target required - validated by ActionsContext)
export interface WorkspaceActionDefinition extends ActionBase {
requiresTarget: true;
execute: (
ctx: ActionExecutorContext,
workspaceId: string
) => Promise<void> | void;
}
// Discriminated union
export type ActionDefinition =
| GlobalActionDefinition
| WorkspaceActionDefinition;
// Helper to get workspace from query cache
function getWorkspaceFromCache(
queryClient: QueryClient,
workspaceId: string
): Workspace {
const workspace = queryClient.getQueryData<Workspace>(
attemptKeys.byId(workspaceId)
);
if (!workspace) {
throw new Error('Workspace not found');
}
return workspace;
}
// Helper to invalidate workspace-related queries
function invalidateWorkspaceQueries(
queryClient: QueryClient,
workspaceId: string
) {
queryClient.invalidateQueries({ queryKey: attemptKeys.byId(workspaceId) });
queryClient.invalidateQueries({ queryKey: workspaceSummaryKeys.all });
}
// All application actions
export const Actions = {
// === Workspace Actions ===
DuplicateWorkspace: {
id: 'duplicate-workspace',
label: 'Duplicate',
icon: CopyIcon,
requiresTarget: true,
execute: async (ctx, workspaceId) => {
try {
const firstMessage = await attemptsApi.getFirstUserMessage(workspaceId);
ctx.navigate('/workspaces/create', {
state: { duplicatePrompt: firstMessage },
});
} catch {
// Fallback to creating without the prompt
ctx.navigate('/workspaces/create');
}
},
},
PinWorkspace: {
id: 'pin-workspace',
label: (workspace?: Workspace) => (workspace?.pinned ? 'Unpin' : 'Pin'),
icon: PushPinIcon,
requiresTarget: true,
execute: async (ctx, workspaceId) => {
const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId);
await attemptsApi.update(workspaceId, {
pinned: !workspace.pinned,
});
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
},
},
ArchiveWorkspace: {
id: 'archive-workspace',
label: (workspace?: Workspace) =>
workspace?.archived ? 'Unarchive' : 'Archive',
icon: ArchiveIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace,
isActive: (ctx) => ctx.workspaceArchived,
execute: async (ctx, workspaceId) => {
const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId);
const wasArchived = workspace.archived;
// Calculate next workspace before archiving (if we have the context)
let nextWorkspaceId: string | null = null;
if (!wasArchived && ctx.selectWorkspace && ctx.activeWorkspaces) {
const currentIndex = ctx.activeWorkspaces.findIndex(
(ws) => ws.id === workspaceId
);
if (currentIndex >= 0 && ctx.activeWorkspaces.length > 1) {
const nextWorkspace =
ctx.activeWorkspaces[currentIndex + 1] ||
ctx.activeWorkspaces[currentIndex - 1];
nextWorkspaceId = nextWorkspace?.id ?? null;
}
}
// Perform the archive/unarchive
await attemptsApi.update(workspaceId, { archived: !wasArchived });
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
// Select next workspace after successful archive
if (!wasArchived && nextWorkspaceId && ctx.selectWorkspace) {
ctx.selectWorkspace(nextWorkspaceId);
}
},
},
DeleteWorkspace: {
id: 'delete-workspace',
label: 'Delete',
icon: TrashIcon,
variant: 'destructive',
requiresTarget: true,
execute: async (ctx, workspaceId) => {
const workspace = getWorkspaceFromCache(ctx.queryClient, workspaceId);
const result = await ConfirmDialog.show({
title: 'Delete Workspace',
message:
'Are you sure you want to delete this workspace? This action cannot be undone.',
confirmText: 'Delete',
cancelText: 'Cancel',
variant: 'destructive',
});
if (result === 'confirmed') {
await tasksApi.delete(workspace.task_id);
ctx.queryClient.invalidateQueries({ queryKey: taskKeys.all });
ctx.queryClient.invalidateQueries({
queryKey: workspaceSummaryKeys.all,
});
}
},
},
// === Global/Navigation Actions ===
NewWorkspace: {
id: 'new-workspace',
label: 'New Workspace',
icon: PlusIcon,
shortcut: 'N',
requiresTarget: false,
execute: (ctx) => {
ctx.navigate('/workspaces/create');
},
},
Settings: {
id: 'settings',
label: 'Settings',
icon: GearIcon,
shortcut: ',',
requiresTarget: false,
execute: (ctx) => {
ctx.navigate('/settings');
},
},
// === Diff View Actions ===
ToggleDiffViewMode: {
id: 'toggle-diff-view-mode',
label: () =>
useDiffViewStore.getState().mode === 'unified'
? 'Switch to Side-by-Side View'
: 'Switch to Inline View',
icon: ColumnsIcon,
requiresTarget: false,
isVisible: (ctx) => ctx.isChangesMode,
isActive: (ctx) => ctx.diffViewMode === 'split',
getIcon: (ctx) => (ctx.diffViewMode === 'split' ? ColumnsIcon : RowsIcon),
getTooltip: (ctx) =>
ctx.diffViewMode === 'split' ? 'Inline view' : 'Side-by-side view',
execute: () => {
useDiffViewStore.getState().toggle();
},
},
ToggleIgnoreWhitespace: {
id: 'toggle-ignore-whitespace',
label: () =>
useDiffViewStore.getState().ignoreWhitespace
? 'Show Whitespace Changes'
: 'Ignore Whitespace Changes',
icon: EyeSlashIcon,
requiresTarget: false,
isVisible: (ctx) => ctx.isChangesMode,
execute: () => {
const store = useDiffViewStore.getState();
store.setIgnoreWhitespace(!store.ignoreWhitespace);
},
},
ToggleWrapLines: {
id: 'toggle-wrap-lines',
label: () =>
useDiffViewStore.getState().wrapText
? 'Disable Line Wrapping'
: 'Enable Line Wrapping',
icon: TextAlignLeftIcon,
requiresTarget: false,
isVisible: (ctx) => ctx.isChangesMode,
execute: () => {
const store = useDiffViewStore.getState();
store.setWrapText(!store.wrapText);
},
},
// === Layout Panel Actions ===
ToggleSidebar: {
id: 'toggle-sidebar',
label: () =>
useLayoutStore.getState().isSidebarVisible
? 'Hide Sidebar'
: 'Show Sidebar',
icon: SidebarSimpleIcon,
shortcut: '[',
requiresTarget: false,
isActive: (ctx) => ctx.isSidebarVisible,
execute: () => {
useLayoutStore.getState().toggleSidebar();
},
},
ToggleMainPanel: {
id: 'toggle-main-panel',
label: () =>
useLayoutStore.getState().isMainPanelVisible
? 'Hide Chat Panel'
: 'Show Chat Panel',
icon: ChatsTeardropIcon,
requiresTarget: false,
isActive: (ctx) => ctx.isMainPanelVisible,
isEnabled: (ctx) => !(ctx.isMainPanelVisible && !ctx.isChangesMode),
execute: () => {
useLayoutStore.getState().toggleMainPanel();
},
},
ToggleGitPanel: {
id: 'toggle-git-panel',
label: () =>
useLayoutStore.getState().isGitPanelVisible
? 'Hide Git Panel'
: 'Show Git Panel',
icon: SidebarSimpleIcon,
shortcut: ']',
requiresTarget: false,
isActive: (ctx) => ctx.isGitPanelVisible,
execute: () => {
useLayoutStore.getState().toggleGitPanel();
},
},
ToggleChangesMode: {
id: 'toggle-changes-mode',
label: () =>
useLayoutStore.getState().isChangesMode
? 'Hide Changes Panel'
: 'Show Changes Panel',
icon: GitDiffIcon,
shortcut: 'C',
requiresTarget: false,
isVisible: (ctx) => !ctx.isCreateMode,
isActive: (ctx) => ctx.isChangesMode,
isEnabled: (ctx) => !ctx.isCreateMode,
execute: () => {
useLayoutStore.getState().toggleChangesMode();
},
},
ToggleLogsMode: {
id: 'toggle-logs-mode',
label: () =>
useLayoutStore.getState().isLogsMode
? 'Hide Logs Panel'
: 'Show Logs Panel',
icon: TerminalIcon,
shortcut: 'L',
requiresTarget: false,
isVisible: (ctx) => !ctx.isCreateMode,
isActive: (ctx) => ctx.isLogsMode,
isEnabled: (ctx) => !ctx.isCreateMode,
execute: () => {
useLayoutStore.getState().toggleLogsMode();
},
},
TogglePreviewMode: {
id: 'toggle-preview-mode',
label: () =>
useLayoutStore.getState().isPreviewMode
? 'Hide Preview Panel'
: 'Show Preview Panel',
icon: DesktopIcon,
shortcut: 'P',
requiresTarget: false,
isVisible: (ctx) => !ctx.isCreateMode,
isActive: (ctx) => ctx.isPreviewMode,
isEnabled: (ctx) => !ctx.isCreateMode,
execute: () => {
useLayoutStore.getState().togglePreviewMode();
},
},
// === Navigation Actions ===
OpenInOldUI: {
id: 'open-in-old-ui',
label: 'Open in Old UI',
icon: SignOutIcon,
requiresTarget: false,
execute: async (ctx) => {
// If no workspace is selected, navigate to root
if (!ctx.currentWorkspaceId) {
ctx.navigate('/');
return;
}
const workspace = getWorkspaceFromCache(
ctx.queryClient,
ctx.currentWorkspaceId
);
if (!workspace?.task_id) {
ctx.navigate('/');
return;
}
// Fetch task lazily to get project_id
const task = await tasksApi.getById(workspace.task_id);
if (task?.project_id) {
ctx.navigate(
`/projects/${task.project_id}/tasks/${workspace.task_id}/attempts/${workspace.id}`
);
} else {
ctx.navigate('/');
}
},
},
// === Diff Actions for Navbar ===
ToggleAllDiffs: {
id: 'toggle-all-diffs',
label: () => {
const { diffPaths } = useDiffViewStore.getState();
const { expanded } = useUiPreferencesStore.getState();
const keys = diffPaths.map((p) => `diff:${p}`);
const isAllExpanded =
keys.length > 0 && keys.every((k) => expanded[k] !== false);
return isAllExpanded ? 'Collapse All Diffs' : 'Expand All Diffs';
},
icon: CaretDoubleUpIcon,
requiresTarget: false,
isVisible: (ctx) => ctx.isChangesMode,
getIcon: (ctx) =>
ctx.isAllDiffsExpanded ? CaretDoubleUpIcon : CaretDoubleDownIcon,
getTooltip: (ctx) =>
ctx.isAllDiffsExpanded ? 'Collapse all diffs' : 'Expand all diffs',
execute: () => {
const { diffPaths } = useDiffViewStore.getState();
const { expanded, setExpandedAll } = useUiPreferencesStore.getState();
const keys = diffPaths.map((p) => `diff:${p}`);
const isAllExpanded =
keys.length > 0 && keys.every((k) => expanded[k] !== false);
setExpandedAll(keys, !isAllExpanded);
},
},
// === ContextBar Actions ===
OpenInIDE: {
id: 'open-in-ide',
label: 'Open in IDE',
icon: 'ide-icon' as const,
requiresTarget: false,
isVisible: (ctx) => ctx.hasWorkspace,
getTooltip: (ctx) => `Open in ${getIdeName(ctx.editorType)}`,
execute: async (ctx) => {
if (!ctx.currentWorkspaceId) return;
try {
const response = await attemptsApi.openEditor(ctx.currentWorkspaceId, {
editor_type: null,
file_path: null,
});
if (response.url) {
window.open(response.url, '_blank');
}
} catch {
// Show editor selection dialog on failure
EditorSelectionDialog.show({
selectedAttemptId: ctx.currentWorkspaceId,
});
}
},
},
CopyPath: {
id: 'copy-path',
label: 'Copy path',
icon: 'copy-icon' as const,
requiresTarget: false,
isVisible: (ctx) => ctx.hasWorkspace,
execute: async (ctx) => {
if (!ctx.containerRef) return;
await navigator.clipboard.writeText(ctx.containerRef);
},
},
ToggleDevServer: {
id: 'toggle-dev-server',
label: 'Dev Server',
icon: PlayIcon,
requiresTarget: false,
isVisible: (ctx) => ctx.hasWorkspace,
isEnabled: (ctx) =>
ctx.devServerState !== 'starting' && ctx.devServerState !== 'stopping',
getIcon: (ctx) => {
if (
ctx.devServerState === 'starting' ||
ctx.devServerState === 'stopping'
) {
return SpinnerIcon;
}
if (ctx.devServerState === 'running') {
return PauseIcon;
}
return PlayIcon;
},
getTooltip: (ctx) => {
switch (ctx.devServerState) {
case 'starting':
return 'Starting dev server...';
case 'stopping':
return 'Stopping dev server...';
case 'running':
return 'Stop dev server';
default:
return 'Start dev server';
}
},
getLabel: (ctx) =>
ctx.devServerState === 'running' ? 'Stop Dev Server' : 'Start Dev Server',
execute: (ctx) => {
if (ctx.runningDevServerId && ctx.stopDevServer) {
ctx.stopDevServer();
} else if (ctx.startDevServer) {
ctx.startDevServer();
}
},
},
// === Git Actions ===
GitCreatePR: {
id: 'git-create-pr',
label: 'Create Pull Request',
icon: GitPullRequestIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
execute: async (ctx, workspaceId) => {
// 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 task = await tasksApi.getById(workspace.task_id);
const result = await CreatePRDialog.show({
attempt: workspace,
task: {
...task,
has_in_progress_attempt: false,
last_attempt_failed: false,
executor: '',
},
repoId,
});
if (!result.success && result.error) {
throw new Error(result.error);
}
},
},
GitMerge: {
id: 'git-merge',
label: 'Merge',
icon: GitMergeIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
execute: async (ctx, workspaceId) => {
// 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({
title: 'Merge Branch',
message:
'Are you sure you want to merge this branch into the target branch?',
confirmText: 'Merge',
cancelText: 'Cancel',
});
if (confirmResult === 'confirmed') {
await attemptsApi.merge(workspaceId, { repo_id: repoId });
invalidateWorkspaceQueries(ctx.queryClient, workspaceId);
}
},
},
GitRebase: {
id: 'git-rebase',
label: 'Rebase',
icon: ArrowsClockwiseIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
execute: async (ctx, workspaceId) => {
// Fetch repos to get target_branch info
const repos = await attemptsApi.getRepos(workspaceId);
if (repos.length === 0) throw new Error('No repositories found');
// Get repo - either from context (RepoCard click) or use first repo
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({
attemptId: workspaceId,
repoId: repo.id,
branches,
initialTargetBranch: repo.target_branch,
});
},
},
GitChangeTarget: {
id: 'git-change-target',
label: 'Change Target Branch',
icon: CrosshairIcon,
requiresTarget: true,
isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos,
execute: async (ctx, workspaceId) => {
// 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);
await ChangeTargetDialog.show({
attemptId: workspaceId,
repoId,
branches,
});
},
},
} as const satisfies Record<string, ActionDefinition>;
// Helper to resolve dynamic label
export function resolveLabel(
action: ActionDefinition,
workspace?: Workspace
): string {
return typeof action.label === 'function'
? action.label(workspace)
: action.label;
}
// Divider marker for navbar action groups
export const NavbarDivider = { type: 'divider' } as const;
export type NavbarItem = ActionDefinition | typeof NavbarDivider;
// Navbar action groups define which actions appear in each section
export const NavbarActionGroups = {
left: [Actions.ArchiveWorkspace, Actions.OpenInOldUI] as ActionDefinition[],
right: [
Actions.ToggleDiffViewMode,
Actions.ToggleAllDiffs,
NavbarDivider,
Actions.ToggleSidebar,
Actions.ToggleMainPanel,
Actions.ToggleChangesMode,
Actions.ToggleLogsMode,
Actions.TogglePreviewMode,
Actions.ToggleGitPanel,
] as NavbarItem[],
};
// Divider marker for context bar action groups
export const ContextBarDivider = { type: 'divider' } as const;
export type ContextBarItem = ActionDefinition | typeof ContextBarDivider;
// ContextBar action groups define which actions appear in each section
export const ContextBarActionGroups = {
primary: [Actions.OpenInIDE, Actions.CopyPath] as ActionDefinition[],
secondary: [
Actions.ToggleDevServer,
Actions.TogglePreviewMode,
Actions.ToggleChangesMode,
] as ActionDefinition[],
};
// Helper to check if an icon is a special type
export function isSpecialIcon(icon: ActionIcon): icon is SpecialIconType {
return icon === 'ide-icon' || icon === 'copy-icon';
}