feat: enhance project-specific slash commands management

- Add scopeFilter prop to SlashCommandsManager for filtering by scope
- Replace browser confirm() with proper delete confirmation dialog
- Fix slash_command_delete to handle project commands with project_path param
- Add Slash Commands tab to ProjectSettings as the default tab
- Add Commands button to ClaudeCodeSession for quick access
- Improve error handling and user feedback for delete operations
- Better UI text when showing project-specific commands only
This commit is contained in:
Vivek R
2025-07-07 23:56:09 +05:30
parent e6662bf0c9
commit cee71343f5
5 changed files with 213 additions and 46 deletions

View File

@@ -416,11 +416,25 @@ pub async fn slash_command_save(
/// Delete a slash command
#[tauri::command]
pub async fn slash_command_delete(command_id: String) -> Result<String, String> {
pub async fn slash_command_delete(command_id: String, project_path: Option<String>) -> Result<String, String> {
info!("Deleting slash command: {}", command_id);
// Get the command to find its file path
let command = slash_command_get(command_id.clone()).await?;
// First, we need to determine if this is a project command by parsing the ID
let is_project_command = command_id.starts_with("project-");
// If it's a project command and we don't have a project path, error out
if is_project_command && project_path.is_none() {
return Err("Project path required to delete project commands".to_string());
}
// List all commands (including project commands if applicable)
let commands = slash_commands_list(project_path).await?;
// Find the command by ID
let command = commands
.into_iter()
.find(|cmd| cmd.id == command_id)
.ok_or_else(|| format!("Command not found: {}", command_id))?;
// Delete the file
fs::remove_file(&command.file_path)

View File

@@ -10,7 +10,8 @@ import {
Settings,
ChevronUp,
X,
Hash
Hash,
Command
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -25,6 +26,7 @@ import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingProm
import { ErrorBoundary } from "./ErrorBoundary";
import { TimelineNavigator } from "./TimelineNavigator";
import { CheckpointSettings } from "./CheckpointSettings";
import { SlashCommandsManager } from "./SlashCommandsManager";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { SplitPane } from "@/components/ui/split-pane";
@@ -87,6 +89,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const [timelineVersion, setTimelineVersion] = useState(0);
const [showSettings, setShowSettings] = useState(false);
const [showForkDialog, setShowForkDialog] = useState(false);
const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false);
const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null);
const [forkSessionName, setForkSessionName] = useState("");
@@ -995,6 +998,17 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
Hooks
</Button>
)}
{projectPath && (
<Button
variant="outline"
size="sm"
onClick={() => setShowSlashCommandsSettings(true)}
disabled={isLoading}
>
<Command className="h-4 w-4 mr-2" />
Commands
</Button>
)}
<div className="flex items-center gap-2">
{showSettings && (
<CheckpointSettings
@@ -1386,6 +1400,23 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</DialogContent>
</Dialog>
)}
{/* Slash Commands Settings Dialog */}
{showSlashCommandsSettings && (
<Dialog open={showSlashCommandsSettings} onOpenChange={setShowSlashCommandsSettings}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
<DialogHeader>
<DialogTitle>Slash Commands</DialogTitle>
<DialogDescription>
Manage project-specific slash commands for {projectPath}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<SlashCommandsManager projectPath={projectPath} />
</div>
</DialogContent>
</Dialog>
)}
</div>
);
};

View File

@@ -4,6 +4,7 @@
import React, { useState, useEffect } from 'react';
import { HooksEditor } from '@/components/HooksEditor';
import { SlashCommandsManager } from '@/components/SlashCommandsManager';
import { api } from '@/lib/api';
import {
AlertTriangle,
@@ -11,7 +12,8 @@ import {
Settings,
FolderOpen,
GitBranch,
Shield
Shield,
Command
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
@@ -31,7 +33,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
onBack,
className
}) => {
const [activeTab, setActiveTab] = useState('project');
const [activeTab, setActiveTab] = useState('commands');
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// Other hooks settings
@@ -88,7 +90,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
</Button>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h2 className="text-xl font-semibold">Hooks</h2>
<h2 className="text-xl font-semibold">Project Settings</h2>
</div>
</div>
</div>
@@ -106,6 +108,10 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
<div className="p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-6">
<TabsTrigger value="commands" className="gap-2">
<Command className="h-4 w-4" />
Slash Commands
</TabsTrigger>
<TabsTrigger value="project" className="gap-2">
<GitBranch className="h-4 w-4" />
Project Hooks
@@ -116,6 +122,26 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
</TabsTrigger>
</TabsList>
<TabsContent value="commands" className="space-y-6">
<Card className="p-6">
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">Project Slash Commands</h3>
<p className="text-sm text-muted-foreground mb-4">
Custom commands that are specific to this project. These commands are stored in
<code className="mx-1 px-2 py-1 bg-muted rounded text-xs">.claude/slash-commands/</code>
and can be committed to version control.
</p>
</div>
<SlashCommandsManager
projectPath={project.path}
scopeFilter="project"
/>
</div>
</Card>
</TabsContent>
<TabsContent value="project" className="space-y-6">
<Card className="p-6">
<div className="space-y-4">

View File

@@ -33,6 +33,7 @@ import { COMMON_TOOL_MATCHERS } from "@/types/hooks";
interface SlashCommandsManagerProps {
projectPath?: string;
className?: string;
scopeFilter?: 'project' | 'user' | 'all';
}
interface CommandForm {
@@ -88,13 +89,14 @@ const getCommandIcon = (command: SlashCommand) => {
export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
projectPath,
className,
scopeFilter = 'all',
}) => {
const [commands, setCommands] = useState<SlashCommand[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [selectedScope, setSelectedScope] = useState<'all' | 'project' | 'user'>('all');
const [selectedScope, setSelectedScope] = useState<'all' | 'project' | 'user'>(scopeFilter === 'all' ? 'all' : scopeFilter as 'project' | 'user');
const [expandedCommands, setExpandedCommands] = useState<Set<string>>(new Set());
// Edit dialog state
@@ -109,6 +111,11 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
scope: 'user'
});
// Delete confirmation dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [commandToDelete, setCommandToDelete] = useState<SlashCommand | null>(null);
const [deleting, setDeleting] = useState(false);
// Load commands on mount
useEffect(() => {
loadCommands();
@@ -136,7 +143,7 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
content: "",
description: "",
allowedTools: [],
scope: projectPath ? 'project' : 'user'
scope: scopeFilter !== 'all' ? scopeFilter : (projectPath ? 'project' : 'user')
});
setEditDialogOpen(true);
};
@@ -179,20 +186,35 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
}
};
const handleDelete = async (command: SlashCommand) => {
if (!confirm(`Delete command "${command.full_command}"?`)) {
return;
}
const handleDeleteClick = (command: SlashCommand) => {
setCommandToDelete(command);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!commandToDelete) return;
try {
await api.slashCommandDelete(command.id);
setDeleting(true);
setError(null);
await api.slashCommandDelete(commandToDelete.id, projectPath);
setDeleteDialogOpen(false);
setCommandToDelete(null);
await loadCommands();
} catch (err) {
console.error("Failed to delete command:", err);
setError("Failed to delete command");
const errorMessage = err instanceof Error ? err.message : "Failed to delete command";
setError(errorMessage);
} finally {
setDeleting(false);
}
};
const cancelDelete = () => {
setDeleteDialogOpen(false);
setCommandToDelete(null);
};
const toggleExpanded = (commandId: string) => {
setExpandedCommands(prev => {
const next = new Set(prev);
@@ -226,6 +248,16 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
// Filter commands
const filteredCommands = commands.filter(cmd => {
// Hide default commands
if (cmd.scope === 'default') {
return false;
}
// Apply scopeFilter if set to specific scope
if (scopeFilter !== 'all' && cmd.scope !== scopeFilter) {
return false;
}
// Scope filter
if (selectedScope !== 'all' && cmd.scope !== selectedScope) {
return false;
@@ -262,9 +294,13 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Slash Commands</h3>
<h3 className="text-lg font-semibold">
{scopeFilter === 'project' ? 'Project Slash Commands' : 'Slash Commands'}
</h3>
<p className="text-sm text-muted-foreground mt-1">
Create custom commands to streamline your workflow
{scopeFilter === 'project'
? 'Create custom commands for this project'
: 'Create custom commands to streamline your workflow'}
</p>
</div>
<Button onClick={handleCreateNew} size="sm" className="gap-2">
@@ -286,6 +322,7 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
/>
</div>
</div>
{scopeFilter === 'all' && (
<Select value={selectedScope} onValueChange={(value: any) => setSelectedScope(value)}>
<SelectTrigger className="w-[150px]">
<SelectValue />
@@ -296,6 +333,7 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Error Message */}
@@ -316,11 +354,17 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
<div className="text-center">
<Command className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground">
{searchQuery ? "No commands found" : "No commands created yet"}
{searchQuery
? "No commands found"
: scopeFilter === 'project'
? "No project commands created yet"
: "No commands created yet"}
</p>
{!searchQuery && (
<Button onClick={handleCreateNew} variant="outline" size="sm" className="mt-4">
Create your first command
{scopeFilter === 'project'
? "Create your first project command"
: "Create your first command"}
</Button>
)}
</div>
@@ -414,7 +458,7 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(command)}
onClick={() => handleDeleteClick(command)}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
@@ -465,24 +509,28 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
<Select
value={commandForm.scope}
onValueChange={(value: 'project' | 'user') => setCommandForm(prev => ({ ...prev, scope: value }))}
disabled={!projectPath && commandForm.scope === 'project'}
disabled={scopeFilter !== 'all' || (!projectPath && commandForm.scope === 'project')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(scopeFilter === 'all' || scopeFilter === 'user') && (
<SelectItem value="user">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4" />
User (Global)
</div>
</SelectItem>
)}
{(scopeFilter === 'all' || scopeFilter === 'project') && (
<SelectItem value="project" disabled={!projectPath}>
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" />
Project
</div>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
@@ -619,6 +667,53 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Command</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<p>Are you sure you want to delete this command?</p>
{commandToDelete && (
<div className="p-3 bg-muted rounded-md">
<code className="text-sm font-mono">{commandToDelete.full_command}</code>
{commandToDelete.description && (
<p className="text-sm text-muted-foreground mt-1">{commandToDelete.description}</p>
)}
</div>
)}
<p className="text-sm text-muted-foreground">
This action cannot be undone. The command file will be permanently deleted.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={cancelDelete} disabled={deleting}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleting}
>
{deleting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -1825,11 +1825,12 @@ export const api = {
/**
* Deletes a slash command
* @param commandId - Unique identifier of the command to delete
* @param projectPath - Optional project path for deleting project commands
* @returns Promise resolving to deletion message
*/
async slashCommandDelete(commandId: string): Promise<string> {
async slashCommandDelete(commandId: string, projectPath?: string): Promise<string> {
try {
return await invoke<string>("slash_command_delete", { commandId });
return await invoke<string>("slash_command_delete", { commandId, projectPath });
} catch (error) {
console.error("Failed to delete slash command:", error);
throw error;