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 /// Delete a slash command
#[tauri::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); info!("Deleting slash command: {}", command_id);
// Get the command to find its file path // First, we need to determine if this is a project command by parsing the ID
let command = slash_command_get(command_id.clone()).await?; 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 // Delete the file
fs::remove_file(&command.file_path) fs::remove_file(&command.file_path)

View File

@@ -10,7 +10,8 @@ import {
Settings, Settings,
ChevronUp, ChevronUp,
X, X,
Hash Hash,
Command
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -25,6 +26,7 @@ import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingProm
import { ErrorBoundary } from "./ErrorBoundary"; import { ErrorBoundary } from "./ErrorBoundary";
import { TimelineNavigator } from "./TimelineNavigator"; import { TimelineNavigator } from "./TimelineNavigator";
import { CheckpointSettings } from "./CheckpointSettings"; import { CheckpointSettings } from "./CheckpointSettings";
import { SlashCommandsManager } from "./SlashCommandsManager";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { SplitPane } from "@/components/ui/split-pane"; import { SplitPane } from "@/components/ui/split-pane";
@@ -87,6 +89,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const [timelineVersion, setTimelineVersion] = useState(0); const [timelineVersion, setTimelineVersion] = useState(0);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showForkDialog, setShowForkDialog] = useState(false); const [showForkDialog, setShowForkDialog] = useState(false);
const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false);
const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null); const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null);
const [forkSessionName, setForkSessionName] = useState(""); const [forkSessionName, setForkSessionName] = useState("");
@@ -995,6 +998,17 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
Hooks Hooks
</Button> </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"> <div className="flex items-center gap-2">
{showSettings && ( {showSettings && (
<CheckpointSettings <CheckpointSettings
@@ -1386,6 +1400,23 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
}; };

View File

@@ -4,6 +4,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { HooksEditor } from '@/components/HooksEditor'; import { HooksEditor } from '@/components/HooksEditor';
import { SlashCommandsManager } from '@/components/SlashCommandsManager';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { import {
AlertTriangle, AlertTriangle,
@@ -11,7 +12,8 @@ import {
Settings, Settings,
FolderOpen, FolderOpen,
GitBranch, GitBranch,
Shield Shield,
Command
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
@@ -31,7 +33,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
onBack, onBack,
className className
}) => { }) => {
const [activeTab, setActiveTab] = useState('project'); const [activeTab, setActiveTab] = useState('commands');
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// Other hooks settings // Other hooks settings
@@ -88,7 +90,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" /> <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> </div>
</div> </div>
@@ -106,6 +108,10 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
<div className="p-6"> <div className="p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-6"> <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"> <TabsTrigger value="project" className="gap-2">
<GitBranch className="h-4 w-4" /> <GitBranch className="h-4 w-4" />
Project Hooks Project Hooks
@@ -116,6 +122,26 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
</TabsTrigger> </TabsTrigger>
</TabsList> </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"> <TabsContent value="project" className="space-y-6">
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -33,6 +33,7 @@ import { COMMON_TOOL_MATCHERS } from "@/types/hooks";
interface SlashCommandsManagerProps { interface SlashCommandsManagerProps {
projectPath?: string; projectPath?: string;
className?: string; className?: string;
scopeFilter?: 'project' | 'user' | 'all';
} }
interface CommandForm { interface CommandForm {
@@ -88,13 +89,14 @@ const getCommandIcon = (command: SlashCommand) => {
export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
projectPath, projectPath,
className, className,
scopeFilter = 'all',
}) => { }) => {
const [commands, setCommands] = useState<SlashCommand[]>([]); const [commands, setCommands] = useState<SlashCommand[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); 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()); const [expandedCommands, setExpandedCommands] = useState<Set<string>>(new Set());
// Edit dialog state // Edit dialog state
@@ -109,6 +111,11 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
scope: 'user' 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 // Load commands on mount
useEffect(() => { useEffect(() => {
loadCommands(); loadCommands();
@@ -136,7 +143,7 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
content: "", content: "",
description: "", description: "",
allowedTools: [], allowedTools: [],
scope: projectPath ? 'project' : 'user' scope: scopeFilter !== 'all' ? scopeFilter : (projectPath ? 'project' : 'user')
}); });
setEditDialogOpen(true); setEditDialogOpen(true);
}; };
@@ -179,20 +186,35 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
} }
}; };
const handleDelete = async (command: SlashCommand) => { const handleDeleteClick = (command: SlashCommand) => {
if (!confirm(`Delete command "${command.full_command}"?`)) { setCommandToDelete(command);
return; setDeleteDialogOpen(true);
} };
const confirmDelete = async () => {
if (!commandToDelete) return;
try { try {
await api.slashCommandDelete(command.id); setDeleting(true);
setError(null);
await api.slashCommandDelete(commandToDelete.id, projectPath);
setDeleteDialogOpen(false);
setCommandToDelete(null);
await loadCommands(); await loadCommands();
} catch (err) { } catch (err) {
console.error("Failed to delete command:", 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) => { const toggleExpanded = (commandId: string) => {
setExpandedCommands(prev => { setExpandedCommands(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -226,6 +248,16 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
// Filter commands // Filter commands
const filteredCommands = commands.filter(cmd => { 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 // Scope filter
if (selectedScope !== 'all' && cmd.scope !== selectedScope) { if (selectedScope !== 'all' && cmd.scope !== selectedScope) {
return false; return false;
@@ -262,9 +294,13 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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"> <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> </p>
</div> </div>
<Button onClick={handleCreateNew} size="sm" className="gap-2"> <Button onClick={handleCreateNew} size="sm" className="gap-2">
@@ -286,6 +322,7 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
/> />
</div> </div>
</div> </div>
{scopeFilter === 'all' && (
<Select value={selectedScope} onValueChange={(value: any) => setSelectedScope(value)}> <Select value={selectedScope} onValueChange={(value: any) => setSelectedScope(value)}>
<SelectTrigger className="w-[150px]"> <SelectTrigger className="w-[150px]">
<SelectValue /> <SelectValue />
@@ -296,6 +333,7 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
<SelectItem value="user">User</SelectItem> <SelectItem value="user">User</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)}
</div> </div>
{/* Error Message */} {/* Error Message */}
@@ -316,11 +354,17 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
<div className="text-center"> <div className="text-center">
<Command className="h-12 w-12 mx-auto text-muted-foreground mb-4" /> <Command className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground"> <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> </p>
{!searchQuery && ( {!searchQuery && (
<Button onClick={handleCreateNew} variant="outline" size="sm" className="mt-4"> <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> </Button>
)} )}
</div> </div>
@@ -414,7 +458,7 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDelete(command)} onClick={() => handleDeleteClick(command)}
className="h-8 w-8 text-destructive hover:text-destructive" className="h-8 w-8 text-destructive hover:text-destructive"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -465,24 +509,28 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
<Select <Select
value={commandForm.scope} value={commandForm.scope}
onValueChange={(value: 'project' | 'user') => setCommandForm(prev => ({ ...prev, scope: value }))} onValueChange={(value: 'project' | 'user') => setCommandForm(prev => ({ ...prev, scope: value }))}
disabled={!projectPath && commandForm.scope === 'project'} disabled={scopeFilter !== 'all' || (!projectPath && commandForm.scope === 'project')}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(scopeFilter === 'all' || scopeFilter === 'user') && (
<SelectItem value="user"> <SelectItem value="user">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
User (Global) User (Global)
</div> </div>
</SelectItem> </SelectItem>
)}
{(scopeFilter === 'all' || scopeFilter === 'project') && (
<SelectItem value="project" disabled={!projectPath}> <SelectItem value="project" disabled={!projectPath}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
Project Project
</div> </div>
</SelectItem> </SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -619,6 +667,53 @@ export const SlashCommandsManager: React.FC<SlashCommandsManagerProps> = ({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
}; };

View File

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