Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,17 @@ leetcode config --lang sql
leetcode config --editor code
leetcode config --workdir ~/leetcode
leetcode config --repo https://github.com/username/leetcode-solutions.git

# Pick a color theme for the TUI (dark, light, or auto)
leetcode config --theme light
leetcode config --theme dark
leetcode config --theme auto
```

The `--theme` setting is workspace-scoped. `auto` (the default) probes the
terminal for its background color via OSC 11 and falls back to `COLORFGBG`,
then to `dark`, if the terminal doesn't reply.

## Folder Structure

Solution files are automatically organized by difficulty and category:
Expand Down
90 changes: 90 additions & 0 deletions src/__tests__/tui/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
applyTheme,
colors,
darkPalette,
lightPalette,
normalizeThemeInput,
resolveTheme,
SUPPORTED_THEMES,
} from '../../tui/theme.js';

const ORIGINAL_COLORFGBG = process.env.COLORFGBG;

describe('TUI theme', () => {
beforeEach(() => {
delete process.env.COLORFGBG;
applyTheme('dark');
});

afterEach(() => {
if (ORIGINAL_COLORFGBG === undefined) {
delete process.env.COLORFGBG;
} else {
process.env.COLORFGBG = ORIGINAL_COLORFGBG;
}
applyTheme('dark');
});

describe('normalizeThemeInput', () => {
it('accepts every supported theme name', () => {
for (const name of SUPPORTED_THEMES) {
expect(normalizeThemeInput(name)).toBe(name);
}
});

it('is case-insensitive and trims whitespace', () => {
expect(normalizeThemeInput(' LIGHT ')).toBe('light');
expect(normalizeThemeInput('Dark')).toBe('dark');
expect(normalizeThemeInput('AUTO')).toBe('auto');
});

it('returns null for unsupported input', () => {
expect(normalizeThemeInput('solarized')).toBeNull();
expect(normalizeThemeInput('')).toBeNull();
});
});

describe('resolveTheme', () => {
it('returns the explicit palette for dark/light', () => {
expect(resolveTheme('dark')).toBe('dark');
expect(resolveTheme('light')).toBe('light');
});

it('falls back to dark when auto has no signal', () => {
expect(resolveTheme('auto')).toBe('dark');
expect(resolveTheme(undefined)).toBe('dark');
});

it('detects light terminals via COLORFGBG background code >= 7', () => {
process.env.COLORFGBG = '0;15';
expect(resolveTheme('auto')).toBe('light');
});

it('detects dark terminals via COLORFGBG background code < 7', () => {
process.env.COLORFGBG = '15;0';
expect(resolveTheme('auto')).toBe('dark');
});
});

describe('applyTheme', () => {
it('swaps the mutable colors palette to light', () => {
applyTheme('light');
expect(colors.bg).toBe(lightPalette.bg);
expect(colors.text).toBe(lightPalette.text);
});

it('restores the dark palette', () => {
applyTheme('light');
applyTheme('dark');
expect(colors.bg).toBe(darkPalette.bg);
expect(colors.text).toBe(darkPalette.text);
});

it('keeps the same object identity so importers keep working', () => {
const before = colors;
applyTheme('light');
expect(colors).toBe(before);
});
});
});
32 changes: 31 additions & 1 deletion src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,28 @@ import {
normalizeLeetCodeSiteInput,
SUPPORTED_LEETCODE_SITES,
} from '../utils/site.js';
import { normalizeThemeInput, SUPPORTED_THEMES } from '../tui/theme.js';

interface ConfigOptions {
lang?: string;
editor?: string;
workdir?: string;
repo?: string | boolean;
site?: string;
theme?: string;
}

export async function configCommand(options: ConfigOptions): Promise<void> {
const hasRepoOption = options.repo !== undefined;

if (!options.lang && !options.editor && !options.workdir && !hasRepoOption && !options.site) {
if (
!options.lang &&
!options.editor &&
!options.workdir &&
!hasRepoOption &&
!options.site &&
!options.theme
) {
await showCurrentConfig();
return;
}
Expand Down Expand Up @@ -96,12 +105,24 @@ export async function configCommand(options: ConfigOptions): Promise<void> {
config.setSite(normalizedSite);
console.log(chalk.green(`✓ Site set to ${normalizedSite}`));
}

if (options.theme) {
const normalizedTheme = normalizeThemeInput(options.theme);
if (!normalizedTheme) {
console.log(chalk.red(`Unsupported theme: ${options.theme}`));
console.log(chalk.gray(`Supported: ${SUPPORTED_THEMES.join(', ')}`));
return;
}
config.setTheme(normalizedTheme);
console.log(chalk.green(`✓ Theme set to ${normalizedTheme}`));
}
}

export async function configInteractiveCommand(): Promise<void> {
const currentConfig = config.getConfig();
const workspace = config.getActiveWorkspace();
const currentSite = normalizeLeetCodeSiteInput(currentConfig.site ?? '') ?? DEFAULT_LEETCODE_SITE;
const currentTheme = currentConfig.theme ?? 'auto';

console.log();
console.log(chalk.bold.cyan(`📁 Configuring workspace: ${workspace}`));
Expand All @@ -125,6 +146,13 @@ export async function configInteractiveCommand(): Promise<void> {
})),
default: currentSite,
},
{
type: 'list',
name: 'theme',
message: 'Color theme:',
choices: SUPPORTED_THEMES.map((t) => ({ name: t, value: t })),
default: currentTheme,
},
{
type: 'input',
name: 'editor',
Expand All @@ -148,6 +176,7 @@ export async function configInteractiveCommand(): Promise<void> {
config.setLanguage(answers.language);
config.setEditor(answers.editor);
config.setWorkDir(answers.workDir);
config.setTheme(answers.theme);
if (answers.repo) {
config.setRepo(answers.repo);
} else {
Expand Down Expand Up @@ -198,6 +227,7 @@ async function showCurrentConfig(): Promise<void> {
console.log();
console.log(chalk.gray('Language: '), chalk.white(currentConfig.language));
console.log(chalk.gray('Site: '), chalk.white(site));
console.log(chalk.gray('Theme: '), chalk.white(currentConfig.theme ?? 'auto'));
console.log(chalk.gray('Editor: '), chalk.white(currentConfig.editor ?? '(not set)'));
console.log(chalk.gray('Work Dir: '), chalk.white(currentConfig.workDir));
console.log(chalk.gray('Repo URL: '), chalk.white(currentConfig.repo ?? '(not set)'));
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ program
.option('-e, --editor <editor>', 'Set editor command')
.option('-w, --workdir <path>', 'Set working directory for solutions')
.option('-r, --repo [url]', 'Set Git repository URL (omit value to clear)')
.option('--theme <theme>', 'Set color theme (dark, light, auto)')
.option('-i, --interactive', 'Interactive configuration')
.addHelpText(
'after',
Expand All @@ -463,6 +464,7 @@ ${chalk.yellow('Examples:')}
${chalk.cyan('$ leetcode config -w ~/leetcode')} Set solutions folder
${chalk.cyan('$ leetcode config -r https://...')} Set git repository
${chalk.cyan('$ leetcode config --repo')} Clear git repository
${chalk.cyan('$ leetcode config --theme light')} Use light color theme
${chalk.cyan('$ leetcode config -i')} Interactive setup

${chalk.gray(`Supported languages: ${getSupportedLanguagesLabel()}`)}
Expand Down
13 changes: 12 additions & 1 deletion src/storage/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Configuration management - delegates to workspace storage
import { join } from 'path';
import type { LeetCodeSite, SupportedLanguage, UserConfig } from '../types.js';
import type { LeetCodeSite, SupportedLanguage, ThemeName, UserConfig } from '../types.js';
import { workspaceStorage } from './workspaces.js';
import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../utils/site.js';

const DEFAULT_THEME: ThemeName = 'auto';

export const config = {
getConfig(): UserConfig {
const wsConfig = workspaceStorage.getConfig();
Expand All @@ -13,6 +15,7 @@ export const config = {
workDir: wsConfig.workDir,
repo: wsConfig.syncRepo,
site: normalizeLeetCodeSiteInput(wsConfig.site ?? '') ?? DEFAULT_LEETCODE_SITE,
theme: wsConfig.theme ?? DEFAULT_THEME,
};
},

Expand All @@ -36,6 +39,10 @@ export const config = {
workspaceStorage.setConfig({ site });
},

setTheme(theme: ThemeName): void {
workspaceStorage.setConfig({ theme });
},

deleteRepo(): void {
const wsConfig = workspaceStorage.getConfig();
delete wsConfig.syncRepo;
Expand Down Expand Up @@ -64,6 +71,10 @@ export const config = {
);
},

getTheme(): ThemeName {
return workspaceStorage.getConfig().theme ?? DEFAULT_THEME;
},

getPath(): string {
return join(workspaceStorage.getWorkspaceDir(), 'config.json');
},
Expand Down
3 changes: 2 additions & 1 deletion src/storage/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { isValidWorkspaceName } from '../utils/validation.js';
import type { LeetCodeSite } from '../types.js';
import type { LeetCodeSite, ThemeName } from '../types.js';

export interface WorkspaceConfig {
workDir: string;
lang: string;
editor?: string;
syncRepo?: string;
site?: LeetCodeSite;
theme?: ThemeName;
}

export interface WorkspaceRegistry {
Expand Down
9 changes: 9 additions & 0 deletions src/tui/commands/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import got from 'got';
import { configureLeetCodeClientSite } from '../../utils/auth.js';
import { normalizeLeetCodeSiteInput } from '../../utils/site.js';
import { applyTheme, normalizeThemeInput } from '../theme.js';

const RELEASES_URL =
'https://raw.githubusercontent.com/night-slayer18/leetcode-cli/main/docs/releases.md';
Expand Down Expand Up @@ -244,7 +245,7 @@
function saveConfig(key: string, value: string): void {
switch (key) {
case 'language':
config.setLanguage(value as any);

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 26)

Unexpected any. Specify a different type

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 22)

Unexpected any. Specify a different type

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Unexpected any. Specify a different type

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 26)

Unexpected any. Specify a different type

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24)

Unexpected any. Specify a different type

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24)

Unexpected any. Specify a different type

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 22)

Unexpected any. Specify a different type

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24)

Unexpected any. Specify a different type

Check warning on line 248 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 26)

Unexpected any. Specify a different type
break;
case 'site': {
const site = normalizeLeetCodeSiteInput(value);
Expand All @@ -266,6 +267,14 @@
case 'repo':
config.setRepo(value);
break;
case 'theme': {
const theme = normalizeThemeInput(value);
if (theme) {
config.setTheme(theme);
applyTheme(theme);
}
break;
}
}
}

Expand All @@ -291,7 +300,7 @@
try {
const code = await fs.readFile(filePath, 'utf-8');
return { code, lang: leetcodeLang, questionId: problem.questionId };
} catch (e) {

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 26)

'e' is defined but never used

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 22)

'e' is defined but never used

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

'e' is defined but never used

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 26)

'e' is defined but never used

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24)

'e' is defined but never used

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24)

'e' is defined but never used

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 22)

'e' is defined but never used

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24)

'e' is defined but never used

Check warning on line 303 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 26)

'e' is defined but never used
const rootPath = path.join(workDir, fileName);
try {
const code = await fs.readFile(rootPath, 'utf-8');
Expand Down Expand Up @@ -344,7 +353,7 @@

try {
await fs.mkdir(targetDir, { recursive: true });
} catch (e) {}

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 26)

'e' is defined but never used

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 22)

'e' is defined but never used

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

'e' is defined but never used

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 26)

'e' is defined but never used

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24)

'e' is defined but never used

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24)

'e' is defined but never used

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 22)

'e' is defined but never used

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24)

'e' is defined but never used

Check warning on line 356 in src/tui/commands/effects.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 26)

'e' is defined but never used

const fileName = getSolutionFileName(problem.questionFrontendId, problem.titleSlug, language);
const filePath = path.join(targetDir, fileName);
Expand Down
7 changes: 7 additions & 0 deletions src/tui/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { runApp } from './runtime.js';
import { createInitialModel } from './types.js';
import { applyTheme, primeAutoTheme } from './theme.js';
import { config } from '../storage/config.js';

interface LaunchOptions {
username?: string;
}

export async function launchTUI(options: LaunchOptions = {}): Promise<void> {
const { username } = options;
const theme = config.getTheme();
if (theme === 'auto') {
await primeAutoTheme();
}
applyTheme(theme);
const initialModel = createInitialModel(username);
await runApp(initialModel);
}
10 changes: 10 additions & 0 deletions src/tui/screens/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ConfigScreenModel, ConfigMsg, Command } from '../../types.js';
import { Cmd } from '../../types.js';
import { config } from '../../../storage/config.js';
import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../../../utils/site.js';
import { normalizeThemeInput, SUPPORTED_THEMES } from '../../theme.js';

type ConfigOption = ConfigScreenModel['options'][number];

Expand All @@ -19,6 +20,12 @@ function buildOptions(currentConfig: ReturnType<typeof config.getConfig>): Confi
description: 'Target site for API operations (leetcode.com or leetcode.cn)',
value: currentConfig.site || DEFAULT_LEETCODE_SITE,
},
{
id: 'theme',
label: 'Color Theme',
description: `Color palette for the TUI (${SUPPORTED_THEMES.join(', ')})`,
value: currentConfig.theme ?? 'auto',
},
{
id: 'editor',
label: 'Editor Command',
Expand Down Expand Up @@ -55,6 +62,9 @@ function validate(option: ConfigOption, value: string): string | null {
if (option.id === 'site' && !normalizeLeetCodeSiteInput(trimmed)) {
return 'Site must be leetcode.com or leetcode.cn';
}
if (option.id === 'theme' && !normalizeThemeInput(trimmed)) {
return `Theme must be one of: ${SUPPORTED_THEMES.join(', ')}`;
}
return null;
}

Expand Down
8 changes: 7 additions & 1 deletion src/tui/screens/config/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { colors, borders, icons } from '../../theme.js';
const EXAMPLES: Record<string, string> = {
language: 'Example: typescript, python3, cpp, sql',
site: 'Example: leetcode.com or leetcode.cn',
theme: 'Example: dark, light, auto',
editor: 'Example: code, zed, vim, nvim',
workdir: 'Example: /Users/name/leetcode',
repo: 'Example: https://github.com/user/leetcode.git',
Expand Down Expand Up @@ -105,7 +106,12 @@ function renderOptionDetails(model: ConfigScreenModel, width: number): string[]
lines.push(chalk.hex(colors.textMuted)(truncate(EXAMPLES[option.id] || '', paneWidth - 2)));
lines.push('');

if (option.id === 'language' || option.id === 'workdir' || option.id === 'site') {
if (
option.id === 'language' ||
option.id === 'workdir' ||
option.id === 'site' ||
option.id === 'theme'
) {
lines.push(chalk.hex(colors.textMuted)('Validation: required'));
} else {
lines.push(chalk.hex(colors.textMuted)('Validation: optional'));
Expand Down
Loading
Loading