diff --git a/server/index.js b/server/index.js index b6103a10..aa341425 100755 --- a/server/index.js +++ b/server/index.js @@ -36,7 +36,7 @@ import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; -import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; +import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, loadGlobalSettings, saveGlobalSettings, getMergedSettings, saveProjectSettings } from './projects.js'; import { spawnClaude, abortClaudeSession } from './claude-cli.js'; import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; @@ -254,6 +254,80 @@ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => } }); +// Settings API endpoints + +/** + * GET /api/settings/global - Get global settings from ~/.claude/settings.json + * @route GET /api/settings/global + * @returns {Object} Global settings object + */ +app.get('/api/settings/global', authenticateToken, async (req, res) => { + try { + const globalSettings = await loadGlobalSettings(); + res.json(globalSettings); + } catch (error) { + console.error('Error loading global settings:', error); + res.status(500).json({ error: 'Failed to load global settings' }); + } +}); + +/** + * POST /api/settings/global - Save global settings to ~/.claude/settings.json + * @route POST /api/settings/global + * @body {Object} settings - Settings object to save + * @returns {Object} Success response + */ +app.post('/api/settings/global', authenticateToken, async (req, res) => { + try { + const settings = req.body; + await saveGlobalSettings(settings); + res.json({ success: true }); + } catch (error) { + console.error('Error saving global settings:', error); + res.status(500).json({ error: 'Failed to save global settings' }); + } +}); + +/** + * GET /api/settings/merged - Get merged settings with hierarchy applied + * @route GET /api/settings/merged + * @query {string} [projectPath] - Optional project path for project-specific settings + * @returns {Object} Merged settings object + */ +app.get('/api/settings/merged', authenticateToken, async (req, res) => { + try { + const { projectPath } = req.query; + const mergedSettings = await getMergedSettings(projectPath || null); + res.json(mergedSettings); + } catch (error) { + console.error('Error getting merged settings:', error); + res.status(500).json({ error: 'Failed to get merged settings' }); + } +}); + +/** + * POST /api/settings/project - Save project-specific settings + * @route POST /api/settings/project + * @body {string} projectPath - Project directory path + * @body {Object} settings - Settings object to save + * @returns {Object} Success response + */ +app.post('/api/settings/project', authenticateToken, async (req, res) => { + try { + const { projectPath, settings } = req.body; + + if (!projectPath) { + return res.status(400).json({ error: 'Project path is required' }); + } + + await saveProjectSettings(projectPath, settings); + res.json({ success: true }); + } catch (error) { + console.error('Error saving project settings:', error); + res.status(500).json({ error: 'Failed to save project settings' }); + } +}); + // Create project endpoint app.post('/api/projects/create', authenticateToken, async (req, res) => { try { diff --git a/server/projects.js b/server/projects.js index 23ee4629..6a88a404 100755 --- a/server/projects.js +++ b/server/projects.js @@ -13,6 +13,226 @@ function clearProjectDirectoryCache() { cacheTimestamp = Date.now(); } +/** + * Load global settings file from ~/.claude/settings.json + * @returns {Promise} Global settings object + */ +async function loadGlobalSettings() { + const settingsPath = path.join(process.env.HOME, '.claude', 'settings.json'); + try { + const settingsData = await fs.readFile(settingsPath, 'utf8'); + const rawSettings = JSON.parse(settingsData); + + // Convert from Claude CLI format to claudecodeui format + return convertSettingsFormat(rawSettings); + } catch (error) { + // Return empty settings if file doesn't exist + return {}; + } +} + +/** + * Load project-specific settings file from {projectPath}/.claude/settings.json + * @param {string|null} projectPath - Path to the project directory + * @returns {Promise} Project settings object + */ +async function loadProjectSettings(projectPath) { + if (!projectPath) return {}; + + const settingsPath = path.join(projectPath, '.claude', 'settings.json'); + try { + const settingsData = await fs.readFile(settingsPath, 'utf8'); + const rawSettings = JSON.parse(settingsData); + + // Convert from Claude CLI format to claudecodeui format + return convertSettingsFormat(rawSettings); + } catch (error) { + // Return empty settings if file doesn't exist + return {}; + } +} + +/** + * Convert Claude CLI format settings to claudecodeui format + * @param {Object} rawSettings - Raw settings in Claude CLI format + * @returns {Object} Settings in claudecodeui format + */ +function convertSettingsFormat(rawSettings) { + // Validate input + if (!rawSettings || typeof rawSettings !== 'object') { + return {}; + } + + // Return as-is if already in claudecodeui format + if (rawSettings.allowedTools !== undefined || rawSettings.disallowedTools !== undefined) { + return rawSettings; + } + + // Convert if in Claude CLI format + const converted = { ...rawSettings }; + + if (rawSettings.permissions) { + // permissions.allow -> allowedTools + if (Array.isArray(rawSettings.permissions.allow)) { + converted.allowedTools = rawSettings.permissions.allow; + } + + // permissions.deny -> disallowedTools + if (Array.isArray(rawSettings.permissions.deny)) { + converted.disallowedTools = rawSettings.permissions.deny; + } + + // permissions.defaultMode -> skipPermissions + // dangerous-type values are treated as permission skip + const dangerousModes = ['dangerous', 'dangerously-skip-permissions', 'skip', 'bypass', 'bypasspermissions']; + const defaultMode = rawSettings.permissions.defaultMode || 'default'; + converted.skipPermissions = dangerousModes.includes(defaultMode.toLowerCase()); + + // Remove permissions section + delete converted.permissions; + } + + return converted; +} + +/** + * Save global settings to ~/.claude/settings.json + * @param {Object} settings - Settings object to save + * @throws {Error} If save operation fails + */ +async function saveGlobalSettings(settings) { + const settingsPath = path.join(process.env.HOME, '.claude', 'settings.json'); + const claudeDir = path.dirname(settingsPath); + + try { + await fs.mkdir(claudeDir, { recursive: true }); + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); + } catch (error) { + console.error('Error saving global settings:', error); + throw error; + } +} + +/** + * Save project-specific settings to {projectPath}/.claude/settings.json + * @param {string} projectPath - Path to the project directory + * @param {Object} settings - Settings object to save + * @throws {Error} If project path is not provided or save fails + */ +async function saveProjectSettings(projectPath, settings) { + if (!projectPath) throw new Error('Project path is required'); + + const claudeDir = path.join(projectPath, '.claude'); + const settingsPath = path.join(claudeDir, 'settings.json'); + + try { + await fs.mkdir(claudeDir, { recursive: true }); + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); + } catch (error) { + console.error('Error saving project settings:', error); + throw error; + } +} + +/** + * Merge settings with priority: project > global > defaults + * @param {Object} globalSettings - Global settings from ~/.claude/settings.json + * @param {Object} projectSettings - Project-specific settings + * @param {Object} [defaults={}] - Default settings + * @returns {Object} Merged settings object + */ +function mergeSettings(globalSettings, projectSettings, defaults = {}) { + const mergeStrategies = { + // Objects: deep merge + ui: 'deepMerge', + editor: 'deepMerge', + whisper: 'deepMerge', + + // Arrays: complete replacement if project settings exist + allowedTools: 'replace', + disallowedTools: 'replace', + customTools: 'replace', + + // Primitives: override + skipPermissions: 'override', + projectSortOrder: 'override', + autoExpandTools: 'override', + showRawParameters: 'override', + autoScrollToBottom: 'override', + sendByCtrlEnter: 'override', + whisperMode: 'override', + theme: 'override' + }; + + function isObject(item) { + return item && typeof item === 'object' && !Array.isArray(item); + } + + function deepMergeWithStrategy(target, source) { + const result = { ...target }; + + for (const key in source) { + if (source[key] === null || source[key] === undefined) continue; + + const strategy = mergeStrategies[key] || 'deepMerge'; + + switch (strategy) { + case 'replace': + result[key] = source[key]; + break; + case 'override': + result[key] = source[key]; + break; + case 'deepMerge': + if (isObject(target[key]) && isObject(source[key])) { + result[key] = deepMergeWithStrategy(target[key], source[key]); + } else { + result[key] = source[key]; + } + break; + } + } + + return result; + } + + // Apply settings in order: defaults -> global -> project + let merged = { ...defaults }; + merged = deepMergeWithStrategy(merged, globalSettings); + merged = deepMergeWithStrategy(merged, projectSettings); + + return merged; +} + +/** + * Get merged settings for a specific project + * @param {string|null} [projectPath=null] - Project path for project-specific settings + * @returns {Promise} Merged settings with applied hierarchy + */ +async function getMergedSettings(projectPath = null) { + const defaults = { + ui: { + theme: 'system', + autoExpandTools: false, + showRawParameters: false, + autoScrollToBottom: true, + sendByCtrlEnter: false + }, + whisper: { + mode: 'default' + }, + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + projectSortOrder: 'name' + }; + + const globalSettings = await loadGlobalSettings(); + const projectSettings = projectPath ? await loadProjectSettings(projectPath) : {}; + + return mergeSettings(globalSettings, projectSettings, defaults); +} + // Load project configuration file async function loadProjectConfig() { const configPath = path.join(process.env.HOME, '.claude', 'project-config.json'); @@ -607,5 +827,11 @@ export { loadProjectConfig, saveProjectConfig, extractProjectDirectory, - clearProjectDirectoryCache + clearProjectDirectoryCache, + loadGlobalSettings, + loadProjectSettings, + saveGlobalSettings, + saveProjectSettings, + mergeSettings, + getMergedSettings }; \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index b9a9d8c5..62976b56 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,6 +32,7 @@ import { AuthProvider } from './contexts/AuthContext'; import ProtectedRoute from './components/ProtectedRoute'; import { useVersionCheck } from './hooks/useVersionCheck'; import { api } from './utils/api'; +import { useSettings } from './hooks/useSettings'; // Main App component with routing @@ -52,22 +53,29 @@ function AppContent() { const [isInputFocused, setIsInputFocused] = useState(false); const [showToolsSettings, setShowToolsSettings] = useState(false); const [showQuickSettings, setShowQuickSettings] = useState(false); - const [autoExpandTools, setAutoExpandTools] = useState(() => { - const saved = localStorage.getItem('autoExpandTools'); - return saved !== null ? JSON.parse(saved) : false; - }); - const [showRawParameters, setShowRawParameters] = useState(() => { - const saved = localStorage.getItem('showRawParameters'); - return saved !== null ? JSON.parse(saved) : false; - }); - const [autoScrollToBottom, setAutoScrollToBottom] = useState(() => { - const saved = localStorage.getItem('autoScrollToBottom'); - return saved !== null ? JSON.parse(saved) : true; - }); - const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => { - const saved = localStorage.getItem('sendByCtrlEnter'); - return saved !== null ? JSON.parse(saved) : false; - }); + // Use unified settings management system + const currentProjectPath = selectedProject?.path; + const { settings, loading: settingsLoading, updateSettings } = useSettings(currentProjectPath); + + // Get each value from settings + const autoExpandTools = settings.ui?.autoExpandTools ?? false; + const showRawParameters = settings.ui?.showRawParameters ?? false; + const autoScrollToBottom = settings.ui?.autoScrollToBottom ?? true; + const sendByCtrlEnter = settings.ui?.sendByCtrlEnter ?? false; + + // Create settings change functions + const setAutoExpandTools = (value) => { + updateSettings({ ui: { autoExpandTools: value } }); + }; + const setShowRawParameters = (value) => { + updateSettings({ ui: { showRawParameters: value } }); + }; + const setAutoScrollToBottom = (value) => { + updateSettings({ ui: { autoScrollToBottom: value } }); + }; + const setSendByCtrlEnter = (value) => { + updateSettings({ ui: { sendByCtrlEnter: value } }); + }; // Session Protection System: Track sessions with active conversations to prevent // automatic project updates from interrupting ongoing chats. When a user sends // a message, the session is marked as "active" and project updates are paused @@ -608,26 +616,16 @@ function AppContent() { isOpen={showQuickSettings} onToggle={setShowQuickSettings} autoExpandTools={autoExpandTools} - onAutoExpandChange={(value) => { - setAutoExpandTools(value); - localStorage.setItem('autoExpandTools', JSON.stringify(value)); - }} + onAutoExpandChange={setAutoExpandTools} showRawParameters={showRawParameters} - onShowRawParametersChange={(value) => { - setShowRawParameters(value); - localStorage.setItem('showRawParameters', JSON.stringify(value)); - }} + onShowRawParametersChange={setShowRawParameters} autoScrollToBottom={autoScrollToBottom} - onAutoScrollChange={(value) => { - setAutoScrollToBottom(value); - localStorage.setItem('autoScrollToBottom', JSON.stringify(value)); - }} + onAutoScrollChange={setAutoScrollToBottom} sendByCtrlEnter={sendByCtrlEnter} - onSendByCtrlEnterChange={(value) => { - setSendByCtrlEnter(value); - localStorage.setItem('sendByCtrlEnter', JSON.stringify(value)); - }} + onSendByCtrlEnterChange={setSendByCtrlEnter} isMobile={isMobile} + settings={settings} + onSettingsChange={updateSettings} /> )} @@ -635,6 +633,9 @@ function AppContent() { setShowToolsSettings(false)} + settings={settings} + onSettingsChange={updateSettings} + currentProjectPath={currentProjectPath} /> {/* Version Upgrade Modal */} @@ -661,4 +662,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/components/QuickSettingsPanel.jsx b/src/components/QuickSettingsPanel.jsx index 76e95141..b4d27a73 100644 --- a/src/components/QuickSettingsPanel.jsx +++ b/src/components/QuickSettingsPanel.jsx @@ -28,13 +28,25 @@ const QuickSettingsPanel = ({ onAutoScrollChange, sendByCtrlEnter, onSendByCtrlEnterChange, - isMobile + isMobile, + settings, + onSettingsChange }) => { const [localIsOpen, setLocalIsOpen] = useState(isOpen); - const [whisperMode, setWhisperMode] = useState(() => { - return localStorage.getItem('whisperMode') || 'default'; - }); const { isDarkMode } = useTheme(); + + // Get from settings with fallback + const whisperMode = settings?.whisper?.mode || 'default'; + + // WhisperMode change function + const handleWhisperModeChange = (mode) => { + if (onSettingsChange) { + onSettingsChange({ whisper: { mode } }); + } + // Also emit localStorage and event for legacy support + localStorage.setItem('whisperMode', mode); + window.dispatchEvent(new Event('whisperModeChanged')); + }; useEffect(() => { setLocalIsOpen(isOpen); @@ -177,11 +189,7 @@ const QuickSettingsPanel = ({ name="whisperMode" value="default" checked={whisperMode === 'default'} - onChange={() => { - setWhisperMode('default'); - localStorage.setItem('whisperMode', 'default'); - window.dispatchEvent(new Event('whisperModeChanged')); - }} + onChange={() => handleWhisperModeChange('default')} className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600" />
@@ -201,11 +209,7 @@ const QuickSettingsPanel = ({ name="whisperMode" value="prompt" checked={whisperMode === 'prompt'} - onChange={() => { - setWhisperMode('prompt'); - localStorage.setItem('whisperMode', 'prompt'); - window.dispatchEvent(new Event('whisperModeChanged')); - }} + onChange={() => handleWhisperModeChange('prompt')} className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600" />
@@ -225,11 +229,7 @@ const QuickSettingsPanel = ({ name="whisperMode" value="vibe" checked={whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect'} - onChange={() => { - setWhisperMode('vibe'); - localStorage.setItem('whisperMode', 'vibe'); - window.dispatchEvent(new Event('whisperModeChanged')); - }} + onChange={() => handleWhisperModeChange('vibe')} className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600" />
diff --git a/src/components/ToolsSettings.jsx b/src/components/ToolsSettings.jsx index ddfba0e6..51456e87 100644 --- a/src/components/ToolsSettings.jsx +++ b/src/components/ToolsSettings.jsx @@ -6,7 +6,7 @@ import { Badge } from './ui/badge'; import { X, Plus, Settings, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Play, Globe, Terminal, Zap } from 'lucide-react'; import { useTheme } from '../contexts/ThemeContext'; -function ToolsSettings({ isOpen, onClose }) { +function ToolsSettings({ isOpen, onClose, settings, onSettingsChange, currentProjectPath }) { const { isDarkMode, toggleDarkMode } = useTheme(); const [allowedTools, setAllowedTools] = useState([]); const [disallowedTools, setDisallowedTools] = useState([]); @@ -271,26 +271,34 @@ function ToolsSettings({ isOpen, onClose }) { if (isOpen) { loadSettings(); } - }, [isOpen]); + }, [isOpen, settings]); const loadSettings = async () => { try { - // Load from localStorage - const savedSettings = localStorage.getItem('claude-tools-settings'); - - if (savedSettings) { - const settings = JSON.parse(savedSettings); + // Load from unified settings system + if (settings) { setAllowedTools(settings.allowedTools || []); setDisallowedTools(settings.disallowedTools || []); setSkipPermissions(settings.skipPermissions || false); setProjectSortOrder(settings.projectSortOrder || 'name'); } else { - // Set defaults - setAllowedTools([]); - setDisallowedTools([]); - setSkipPermissions(false); - setProjectSortOrder('name'); + // Fallback: load from localStorage + const savedSettings = localStorage.getItem('claude-tools-settings'); + + if (savedSettings) { + const localSettings = JSON.parse(savedSettings); + setAllowedTools(localSettings.allowedTools || []); + setDisallowedTools(localSettings.disallowedTools || []); + setSkipPermissions(localSettings.skipPermissions || false); + setProjectSortOrder(localSettings.projectSortOrder || 'name'); + } else { + // Default values + setAllowedTools([]); + setDisallowedTools([]); + setSkipPermissions(false); + setProjectSortOrder('name'); + } } // Load MCP servers from API @@ -305,22 +313,29 @@ function ToolsSettings({ isOpen, onClose }) { } }; - const saveSettings = () => { + const saveSettings = async () => { setIsSaving(true); setSaveStatus(null); try { - const settings = { + const newSettings = { allowedTools, disallowedTools, skipPermissions, - projectSortOrder, - lastUpdated: new Date().toISOString() + projectSortOrder }; - - // Save to localStorage - localStorage.setItem('claude-tools-settings', JSON.stringify(settings)); + // Save to unified settings system + if (onSettingsChange) { + await onSettingsChange(newSettings); + } else { + // Fallback: save to localStorage + const localSettings = { + ...newSettings, + lastUpdated: new Date().toISOString() + }; + localStorage.setItem('claude-tools-settings', JSON.stringify(localSettings)); + } setSaveStatus('success'); @@ -625,6 +640,77 @@ function ToolsSettings({ isOpen, onClose }) { {activeTab === 'tools' && (
+ {/* Current Applied Rules */} +
+
+ +

+ Currently Applied Rules +

+
+
+
+
+ {currentProjectPath ? `Project: ${currentProjectPath}` : 'Global Settings'} +
+ + {/* Allowed Tools */} +
+
+ Allowed Tools ({allowedTools.length}) +
+ {allowedTools.length > 0 ? ( +
+ {allowedTools.map((tool, index) => ( + + {tool} + + ))} +
+ ) : ( +
+ No restrictions (all tools allowed) +
+ )} +
+ + {/* Disallowed Tools */} +
+
+ Disallowed Tools ({disallowedTools.length}) +
+ {disallowedTools.length > 0 ? ( +
+ {disallowedTools.map((tool, index) => ( + + {tool} + + ))} +
+ ) : ( +
+ None +
+ )} +
+ + {/* Permission Skip */} +
+
+ Skip Permission Prompts +
+
+ {skipPermissions ? 'Enabled (Caution: Dangerous setting)' : 'Disabled (Recommended)'} +
+
+
+
+
+ {/* Skip Permissions */}
diff --git a/src/hooks/useSettings.js b/src/hooks/useSettings.js new file mode 100644 index 00000000..ae3351bd --- /dev/null +++ b/src/hooks/useSettings.js @@ -0,0 +1,262 @@ +import { useState, useEffect } from 'react'; + +/** + * Custom hook for unified settings management. + * Manages hierarchical settings files and localStorage with bidirectional sync. + * + * @param {string|null} projectPath - Current project path for project-specific settings + * @returns {Object} Settings state and control functions + */ +export const useSettings = (projectPath = null) => { + const [settings, setSettings] = useState({ + ui: { + theme: 'system', + autoExpandTools: false, + showRawParameters: false, + autoScrollToBottom: true, + sendByCtrlEnter: false + }, + whisper: { + mode: 'default' + }, + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + projectSortOrder: 'name' + }); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Load settings from server and merge with localStorage + const loadSettings = async () => { + try { + setLoading(true); + setError(null); + + // Get merged settings from server + const response = await fetch(`/api/settings/merged${projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : ''}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('auth-token')}` + } + }); + + if (!response.ok) { + throw new Error(`Failed to load settings: ${response.statusText}`); + } + + const mergedSettings = await response.json(); + + // Supplement/override with localStorage settings + const localSettings = getLocalStorageSettings(); + const finalSettings = mergeLocalSettings(mergedSettings, localSettings); + + setSettings(finalSettings); + } catch (err) { + console.error('Error loading settings:', err); + setError(err.message); + + // Fallback to localStorage only on error + const localSettings = getLocalStorageSettings(); + setSettings(prev => ({ ...prev, ...localSettings })); + } finally { + setLoading(false); + } + }; + + // Get settings from localStorage + const getLocalStorageSettings = () => { + const localSettings = {}; + + // UI settings + const autoExpandTools = localStorage.getItem('autoExpandTools'); + if (autoExpandTools !== null) { + localSettings.ui = { ...localSettings.ui, autoExpandTools: JSON.parse(autoExpandTools) }; + } + + const showRawParameters = localStorage.getItem('showRawParameters'); + if (showRawParameters !== null) { + localSettings.ui = { ...localSettings.ui, showRawParameters: JSON.parse(showRawParameters) }; + } + + const autoScrollToBottom = localStorage.getItem('autoScrollToBottom'); + if (autoScrollToBottom !== null) { + localSettings.ui = { ...localSettings.ui, autoScrollToBottom: JSON.parse(autoScrollToBottom) }; + } + + const sendByCtrlEnter = localStorage.getItem('sendByCtrlEnter'); + if (sendByCtrlEnter !== null) { + localSettings.ui = { ...localSettings.ui, sendByCtrlEnter: JSON.parse(sendByCtrlEnter) }; + } + + const theme = localStorage.getItem('theme'); + if (theme !== null) { + localSettings.ui = { ...localSettings.ui, theme }; + } + + // Whisper settings + const whisperMode = localStorage.getItem('whisperMode'); + if (whisperMode !== null) { + localSettings.whisper = { ...localSettings.whisper, mode: whisperMode }; + } + + // Tool settings + const claudeToolsSettings = localStorage.getItem('claude-tools-settings'); + if (claudeToolsSettings) { + try { + const toolsSettings = JSON.parse(claudeToolsSettings); + if (toolsSettings.allowedTools) localSettings.allowedTools = toolsSettings.allowedTools; + if (toolsSettings.disallowedTools) localSettings.disallowedTools = toolsSettings.disallowedTools; + if (typeof toolsSettings.skipPermissions === 'boolean') localSettings.skipPermissions = toolsSettings.skipPermissions; + } catch (e) { + console.warn('Error parsing claude-tools-settings from localStorage:', e); + } + } + + return localSettings; + }; + + // Merge local settings with server settings + const mergeLocalSettings = (serverSettings, localSettings) => { + const merged = { ...serverSettings }; + + // Merge UI settings + if (localSettings.ui) { + merged.ui = { ...merged.ui, ...localSettings.ui }; + } + + // Merge whisper settings + if (localSettings.whisper) { + merged.whisper = { ...merged.whisper, ...localSettings.whisper }; + } + + // Override tool settings (local takes priority) + if (localSettings.allowedTools) merged.allowedTools = localSettings.allowedTools; + if (localSettings.disallowedTools) merged.disallowedTools = localSettings.disallowedTools; + if (typeof localSettings.skipPermissions === 'boolean') merged.skipPermissions = localSettings.skipPermissions; + + return merged; + }; + + // Update settings function + const updateSettings = async (updates, saveToFile = true) => { + try { + // Validate updates + if (!updates || typeof updates !== 'object') { + throw new Error('Invalid settings update'); + } + + const newSettings = { ...settings }; + + // Update settings + Object.keys(updates).forEach(key => { + if (typeof updates[key] === 'object' && !Array.isArray(updates[key])) { + newSettings[key] = { ...newSettings[key], ...updates[key] }; + } else { + newSettings[key] = updates[key]; + } + }); + + // Update state immediately + setSettings(newSettings); + + // Update localStorage + updateLocalStorage(updates); + + // Save to settings file + if (saveToFile) { + await saveSettingsToFile(newSettings); + } + + // Clear error on success + setError(null); + + } catch (err) { + console.error('Error updating settings:', err); + setError(err.message); + throw err; // Re-throw for caller to handle + } + }; + + // Update localStorage with new settings + const updateLocalStorage = (updates) => { + // Save UI settings to localStorage + if (updates.ui) { + Object.keys(updates.ui).forEach(key => { + localStorage.setItem(key, JSON.stringify(updates.ui[key])); + }); + } + + // Save whisper settings to localStorage + if (updates.whisper?.mode) { + localStorage.setItem('whisperMode', updates.whisper.mode); + } + + // Save tool settings to localStorage + if (updates.allowedTools || updates.disallowedTools || typeof updates.skipPermissions === 'boolean') { + const claudeToolsSettings = { + allowedTools: updates.allowedTools || settings.allowedTools, + disallowedTools: updates.disallowedTools || settings.disallowedTools, + skipPermissions: updates.skipPermissions !== undefined ? updates.skipPermissions : settings.skipPermissions + }; + localStorage.setItem('claude-tools-settings', JSON.stringify(claudeToolsSettings)); + } + }; + + // Save settings to file + const saveSettingsToFile = async (settingsToSave) => { + try { + // Determine whether to save as project settings or global settings + const endpoint = projectPath ? '/api/settings/project' : '/api/settings/global'; + const body = projectPath + ? { projectPath, settings: settingsToSave } + : settingsToSave; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('auth-token')}` + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`Failed to save settings: ${response.statusText}`); + } + + } catch (err) { + console.error('Error saving settings to file:', err); + throw err; + } + }; + + // Initial load + useEffect(() => { + loadSettings(); + }, [projectPath]); + + // Listen for whisperModeChanged events + useEffect(() => { + const handleWhisperModeChanged = () => { + const mode = localStorage.getItem('whisperMode') || 'default'; + setSettings(prev => ({ + ...prev, + whisper: { ...prev.whisper, mode } + })); + }; + + window.addEventListener('whisperModeChanged', handleWhisperModeChanged); + return () => window.removeEventListener('whisperModeChanged', handleWhisperModeChanged); + }, []); + + return { + settings, + loading, + error, + updateSettings, + reloadSettings: loadSettings + }; +}; + +export default useSettings; \ No newline at end of file