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 { dirname } from 'node:path';
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 __dirname = dirname(__filename);
async function main() {
const config = await loadCliConfig();
const settings = loadSettings(config);
const theme = settings.getMerged().theme;
if (theme) {
themeManager.setActiveTheme(theme);
}
let input = config.getQuestion();
// hop into sandbox if we are outside and sandboxing is enabled
@ -41,6 +49,7 @@ async function main() {
render(
React.createElement(App, {
config,
settings,
cliVersion,
}),
);

View File

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

View File

@ -4,33 +4,87 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
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 { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
interface ThemeDialogProps {
/** 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 */
onHighlight: (themeName: string) => void;
onHighlight: (themeName: string | undefined) => void;
/** The settings object */
settings: LoadedSettings;
}
export function ThemeDialog({
onSelect,
onHighlight,
settings,
}: ThemeDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
const themeItems = themeManager.getAvailableThemes().map((theme) => ({
label: theme.active ? `${theme.name} (Active)` : theme.name,
value: theme.name,
}));
const initialIndex = themeItems.findIndex(
(item) => item.value === themeManager.getActiveTheme().name,
const [selectInputKey, setSelectInputKey] = useState(Date.now());
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 (
<Box
borderStyle="round"
@ -39,18 +93,36 @@ export function ThemeDialog({
padding={1}
width="50%"
>
<Box marginBottom={1}>
<Text bold>Select Theme</Text>
</Box>
<Text bold={focusedSection === 'theme'}>
{focusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={Colors.SubtleComment}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
key={selectInputKey}
items={themeItems}
initialIndex={initialIndex}
onSelect={onSelect}
initialIndex={initialThemeIndex}
onSelect={handleThemeSelect} // Use the wrapper handler
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}>
<Text color={Colors.SubtleComment}>
(Use / arrows and Enter to select)
(Use / arrows and Enter to select, Tab to change focus)
</Text>
</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. */
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,
onSelect,
onHighlight,
isFocused,
}: RadioButtonSelectProps<T>): React.JSX.Element {
const handleSelect = (item: RadioSelectItem<T>) => {
onSelect(item.value);
@ -95,6 +99,7 @@ export function RadioButtonSelect<T>({
initialIndex={initialIndex}
onSelect={handleSelect}
onHighlight={handleHighlight}
isFocused={isFocused}
/>
);
}

View File

@ -6,23 +6,36 @@
import { useState, useCallback } from 'react';
import { themeManager } from '../themes/theme-manager.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting
interface UseThemeCommandReturn {
isThemeDialogOpen: boolean;
openThemeDialog: () => void;
handleThemeSelect: (themeName: string) => void;
handleThemeHighlight: (themeName: string) => void;
handleThemeSelect: (
themeName: string | undefined,
scope: SettingScope,
) => void; // Added scope
handleThemeHighlight: (themeName: string | undefined) => void;
}
export const useThemeCommand = (): UseThemeCommandReturn => {
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false);
export const useThemeCommand = (
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 openThemeDialog = useCallback(() => {
setIsThemeDialogOpen(true);
}, []);
function applyTheme(themeName: string) {
function applyTheme(themeName: string | undefined) {
try {
themeManager.setActiveTheme(themeName);
setForceRender((v) => v + 1); // Trigger potential re-render
@ -31,17 +44,25 @@ export const useThemeCommand = (): UseThemeCommandReturn => {
}
}
const handleThemeHighlight = useCallback((themeName: string) => {
const handleThemeHighlight = useCallback(
(themeName: string | undefined) => {
applyTheme(themeName);
}, []);
},
[applyTheme],
); // Added applyTheme to dependencies
const handleThemeSelect = useCallback((themeName: string) => {
const handleThemeSelect = useCallback(
(themeName: string | undefined, scope: SettingScope) => {
// Added scope parameter
try {
applyTheme(themeName);
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 {
isThemeDialogOpen,

View File

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