Save settings to ~/.gemini/settings.json and optionally /your/workspace/.gemini/settings.json (#237)
This commit is contained in:
parent
a18eea8c23
commit
7e8f379dfb
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
Loading…
Reference in New Issue