Save settings to ~/.gemini/settings.json and optionally /your/workspace/.gemini/settings.json (#237)

This commit is contained in:
Jacob Richman 2025-05-01 10:34:07 -07:00 committed by GitHub
parent a18eea8c23
commit 7e8f379dfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 284 additions and 36 deletions

View File

@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'os';
import { Config } from '@gemini-code/server';
const SETTINGS_DIRECTORY_NAME = '.gemini';
const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
export enum SettingScope {
User = 'User',
Workspace = 'Workspace',
}
export interface Settings {
theme?: string;
// Add other settings here.
}
export interface SettingsFile {
settings: Settings;
path: string;
}
export class LoadedSettings {
constructor(user: SettingsFile, workspace: SettingsFile) {
this.user = user;
this.workspace = workspace;
this.merged = this.computeMergedSettings();
}
readonly user: SettingsFile;
readonly workspace: SettingsFile;
private merged: Settings;
getMerged(): Settings {
return this.merged;
}
private computeMergedSettings(): Settings {
return {
...this.user.settings,
...this.workspace.settings,
};
}
forScope(scope: SettingScope): SettingsFile {
switch (scope) {
case SettingScope.User:
return this.user;
case SettingScope.Workspace:
return this.workspace;
default:
throw new Error(`Invalid scope: ${scope}`);
}
}
setValue(
scope: SettingScope,
key: keyof Settings,
value: string | undefined,
): void {
const settingsFile = this.forScope(scope);
settingsFile.settings[key] = value;
this.merged = this.computeMergedSettings();
saveSettings(settingsFile);
}
}
/**
* Loads settings from user and project configuration files.
* Project settings override user settings.
*/
export function loadSettings(config: Config): LoadedSettings {
let userSettings: Settings = {};
let workspaceSettings = {};
// Load user settings
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
userSettings = JSON.parse(userContent);
}
} catch (error) {
console.error('Error reading user settings file:', error);
}
const workspaceSettingsPath = path.join(
config.getTargetDir(),
SETTINGS_DIRECTORY_NAME,
'settings.json',
);
// Load workspace settings
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
workspaceSettings = JSON.parse(projectContent);
}
} catch (error) {
console.error('Error reading workspace settings file:', error);
}
return new LoadedSettings(
{ path: USER_SETTINGS_PATH, settings: userSettings },
{ path: workspaceSettingsPath, settings: workspaceSettings },
);
}
export function saveSettings(settingsFile: SettingsFile): void {
try {
// Ensure the directory exists
const dirPath = path.dirname(settingsFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
settingsFile.path,
JSON.stringify(settingsFile.settings, null, 2),
'utf-8',
);
} catch (error) {
console.error('Error saving user settings file:', error);
}
}

View File

@ -14,12 +14,20 @@ import { readPackageUp } from 'read-package-up';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { sandbox_command, start_sandbox } from './utils/sandbox.js'; import { sandbox_command, start_sandbox } from './utils/sandbox.js';
import { loadSettings } from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
async function main() { async function main() {
const config = await loadCliConfig(); const config = await loadCliConfig();
const settings = loadSettings(config);
const theme = settings.getMerged().theme;
if (theme) {
themeManager.setActiveTheme(theme);
}
let input = config.getQuestion(); let input = config.getQuestion();
// hop into sandbox if we are outside and sandboxing is enabled // hop into sandbox if we are outside and sandboxing is enabled
@ -41,6 +49,7 @@ async function main() {
render( render(
React.createElement(App, { React.createElement(App, {
config, config,
settings,
cliVersion, cliVersion,
}), }),
); );

View File

@ -20,6 +20,7 @@ import { useStartupWarnings } from './hooks/useAppEffects.js';
import { shortenPath, type Config } from '@gemini-code/server'; import { shortenPath, type Config } from '@gemini-code/server';
import { Colors } from './colors.js'; import { Colors } from './colors.js';
import { Intro } from './components/Intro.js'; import { Intro } from './components/Intro.js';
import { LoadedSettings } from '../config/settings.js';
import { Tips } from './components/Tips.js'; import { Tips } from './components/Tips.js';
import { ConsoleOutput } from './components/ConsolePatcher.js'; import { ConsoleOutput } from './components/ConsolePatcher.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
@ -29,10 +30,11 @@ import { isAtCommand } from './utils/commandUtils.js';
interface AppProps { interface AppProps {
config: Config; config: Config;
settings: LoadedSettings;
cliVersion: string; cliVersion: string;
} }
export const App = ({ config, cliVersion }: AppProps) => { export const App = ({ config, settings, cliVersion }: AppProps) => {
const [history, setHistory] = useState<HistoryItem[]>([]); const [history, setHistory] = useState<HistoryItem[]>([]);
const [startupWarnings, setStartupWarnings] = useState<string[]>([]); const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
const { const {
@ -40,7 +42,7 @@ export const App = ({ config, cliVersion }: AppProps) => {
openThemeDialog, openThemeDialog,
handleThemeSelect, handleThemeSelect,
handleThemeHighlight, handleThemeHighlight,
} = useThemeCommand(); } = useThemeCommand(settings);
const { const {
streamingState, streamingState,
@ -176,6 +178,7 @@ export const App = ({ config, cliVersion }: AppProps) => {
<ThemeDialog <ThemeDialog
onSelect={handleThemeSelect} onSelect={handleThemeSelect}
onHighlight={handleThemeHighlight} onHighlight={handleThemeHighlight}
settings={settings}
/> />
) : ( ) : (
<> <>

View File

@ -4,33 +4,87 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React from 'react'; import React, { useState } from 'react';
import { Box, Text } from 'ink'; import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { themeManager } from '../themes/theme-manager.js'; import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js'; import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js'; import { colorizeCode } from '../utils/CodeColorizer.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
interface ThemeDialogProps { interface ThemeDialogProps {
/** Callback function when a theme is selected */ /** Callback function when a theme is selected */
onSelect: (themeName: string) => void; onSelect: (themeName: string | undefined, scope: SettingScope) => void;
/** Callback function when a theme is highlighted */ /** Callback function when a theme is highlighted */
onHighlight: (themeName: string) => void; onHighlight: (themeName: string | undefined) => void;
/** The settings object */
settings: LoadedSettings;
} }
export function ThemeDialog({ export function ThemeDialog({
onSelect, onSelect,
onHighlight, onHighlight,
settings,
}: ThemeDialogProps): React.JSX.Element { }: ThemeDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
const themeItems = themeManager.getAvailableThemes().map((theme) => ({ const themeItems = themeManager.getAvailableThemes().map((theme) => ({
label: theme.active ? `${theme.name} (Active)` : theme.name, label: theme.active ? `${theme.name} (Active)` : theme.name,
value: theme.name, value: theme.name,
})); }));
const initialIndex = themeItems.findIndex( const [selectInputKey, setSelectInputKey] = useState(Date.now());
(item) => item.value === themeManager.getActiveTheme().name,
const initialThemeIndex = themeItems.findIndex(
(item) =>
item.value ===
(settings.forScope(selectedScope).settings.theme || DEFAULT_THEME.name),
); );
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
];
const handleThemeSelect = (themeName: string) => {
onSelect(themeName, selectedScope);
};
const handleScopeHighlight = (scope: SettingScope) => {
setSelectedScope(scope);
setSelectInputKey(Date.now());
};
const handleScopeSelect = (scope: SettingScope) => {
handleScopeHighlight(scope);
setFocusedSection('theme'); // Reset focus to theme section
};
const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>(
'theme',
);
useInput((input, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
});
let otherScopeModifiedMessage = '';
const otherScope =
selectedScope === SettingScope.User
? SettingScope.Workspace
: SettingScope.User;
if (settings.forScope(otherScope).settings.theme !== undefined) {
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.theme !== undefined
? `(Also modified in ${otherScope})`
: `(Modified in ${otherScope})`;
}
return ( return (
<Box <Box
borderStyle="round" borderStyle="round"
@ -39,18 +93,36 @@ export function ThemeDialog({
padding={1} padding={1}
width="50%" width="50%"
> >
<Box marginBottom={1}> <Text bold={focusedSection === 'theme'}>
<Text bold>Select Theme</Text> {focusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
</Box> <Text color={Colors.SubtleComment}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect <RadioButtonSelect
key={selectInputKey}
items={themeItems} items={themeItems}
initialIndex={initialIndex} initialIndex={initialThemeIndex}
onSelect={onSelect} onSelect={handleThemeSelect} // Use the wrapper handler
onHighlight={onHighlight} onHighlight={onHighlight}
isFocused={focusedSection === 'theme'}
/> />
{/* Scope Selection */}
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0} // Default to User Settings
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusedSection === 'scope'}
/>
</Box>
<Box marginTop={1}> <Box marginTop={1}>
<Text color={Colors.SubtleComment}> <Text color={Colors.SubtleComment}>
(Use / arrows and Enter to select) (Use / arrows and Enter to select, Tab to change focus)
</Text> </Text>
</Box> </Box>

View File

@ -37,6 +37,9 @@ export interface RadioButtonSelectProps<T> {
/** Function called when an item is highlighted. Receives the `value` of the selected item. */ /** Function called when an item is highlighted. Receives the `value` of the selected item. */
onHighlight?: (value: T) => void; onHighlight?: (value: T) => void;
/** Whether this select input is currently focused and should respond to input. */
isFocused?: boolean;
} }
/** /**
@ -77,6 +80,7 @@ export function RadioButtonSelect<T>({
initialIndex, initialIndex,
onSelect, onSelect,
onHighlight, onHighlight,
isFocused,
}: RadioButtonSelectProps<T>): React.JSX.Element { }: RadioButtonSelectProps<T>): React.JSX.Element {
const handleSelect = (item: RadioSelectItem<T>) => { const handleSelect = (item: RadioSelectItem<T>) => {
onSelect(item.value); onSelect(item.value);
@ -95,6 +99,7 @@ export function RadioButtonSelect<T>({
initialIndex={initialIndex} initialIndex={initialIndex}
onSelect={handleSelect} onSelect={handleSelect}
onHighlight={handleHighlight} onHighlight={handleHighlight}
isFocused={isFocused}
/> />
); );
} }

View File

@ -6,23 +6,36 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { themeManager } from '../themes/theme-manager.js'; import { themeManager } from '../themes/theme-manager.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting
interface UseThemeCommandReturn { interface UseThemeCommandReturn {
isThemeDialogOpen: boolean; isThemeDialogOpen: boolean;
openThemeDialog: () => void; openThemeDialog: () => void;
handleThemeSelect: (themeName: string) => void; handleThemeSelect: (
handleThemeHighlight: (themeName: string) => void; themeName: string | undefined,
scope: SettingScope,
) => void; // Added scope
handleThemeHighlight: (themeName: string | undefined) => void;
} }
export const useThemeCommand = (): UseThemeCommandReturn => { export const useThemeCommand = (
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false); loadedSettings: LoadedSettings, // Changed parameter
): UseThemeCommandReturn => {
// Determine the effective theme
const effectiveTheme = loadedSettings.getMerged().theme;
// Initial state: Open dialog if no theme is set in either user or workspace settings
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(
effectiveTheme === undefined,
);
// TODO: refactor how theme's are accessed to avoid requiring a forced render.
const [, setForceRender] = useState(0); const [, setForceRender] = useState(0);
const openThemeDialog = useCallback(() => { const openThemeDialog = useCallback(() => {
setIsThemeDialogOpen(true); setIsThemeDialogOpen(true);
}, []); }, []);
function applyTheme(themeName: string) { function applyTheme(themeName: string | undefined) {
try { try {
themeManager.setActiveTheme(themeName); themeManager.setActiveTheme(themeName);
setForceRender((v) => v + 1); // Trigger potential re-render setForceRender((v) => v + 1); // Trigger potential re-render
@ -31,17 +44,25 @@ export const useThemeCommand = (): UseThemeCommandReturn => {
} }
} }
const handleThemeHighlight = useCallback((themeName: string) => { const handleThemeHighlight = useCallback(
applyTheme(themeName); (themeName: string | undefined) => {
}, []);
const handleThemeSelect = useCallback((themeName: string) => {
try {
applyTheme(themeName); applyTheme(themeName);
} finally { },
setIsThemeDialogOpen(false); // Close the dialog [applyTheme],
} ); // Added applyTheme to dependencies
}, []);
const handleThemeSelect = useCallback(
(themeName: string | undefined, scope: SettingScope) => {
// Added scope parameter
try {
loadedSettings.setValue(scope, 'theme', themeName); // Update the merged settings
applyTheme(loadedSettings.getMerged().theme); // Apply the current theme
} finally {
setIsThemeDialogOpen(false); // Close the dialog
}
},
[applyTheme], // Added applyTheme to dependencies
);
return { return {
isThemeDialogOpen, isThemeDialogOpen,

View File

@ -19,8 +19,9 @@ export interface ThemeDisplay {
active: boolean; active: boolean;
} }
export const DEFAULT_THEME: Theme = VS2015;
class ThemeManager { class ThemeManager {
private static readonly DEFAULT_THEME: Theme = VS2015;
private readonly availableThemes: Theme[]; private readonly availableThemes: Theme[];
private activeTheme: Theme; private activeTheme: Theme;
@ -35,7 +36,7 @@ class ThemeManager {
XCode, XCode,
ANSI, ANSI,
]; ];
this.activeTheme = ThemeManager.DEFAULT_THEME; this.activeTheme = DEFAULT_THEME;
} }
/** /**
@ -52,10 +53,8 @@ class ThemeManager {
* Sets the active theme. * Sets the active theme.
* @param themeName The name of the theme to activate. * @param themeName The name of the theme to activate.
*/ */
setActiveTheme(themeName: string): void { setActiveTheme(themeName: string | undefined): void {
const foundTheme = this.availableThemes.find( const foundTheme = this.findThemeByName(themeName);
(theme) => theme.name === themeName,
);
if (foundTheme) { if (foundTheme) {
this.activeTheme = foundTheme; this.activeTheme = foundTheme;
@ -64,6 +63,13 @@ class ThemeManager {
} }
} }
findThemeByName(themeName: string | undefined): Theme | undefined {
if (!themeName) {
return DEFAULT_THEME;
}
return this.availableThemes.find((theme) => theme.name === themeName);
}
/** /**
* Returns the currently active theme object. * Returns the currently active theme object.
*/ */