Skip to content

feat: add comprehensive shell preference system with WSL and multi-distro support #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
16 changes: 14 additions & 2 deletions apps/studio/electron/main/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import path from 'path';
import { promisify } from 'util';
import { __dirname } from '../index';
import { PersistentStorage } from '../storage';
import { getShellCommand, isWSL, detectUserShell } from '../utils/platform';
import { replaceCommand } from './parse';

const execAsync = promisify(exec);
Expand All @@ -32,9 +33,20 @@ export async function runBunCommand(
): Promise<RunBunCommandResult> {
try {
const commandToExecute = getBunCommand(command);
const shell = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';

console.log('Executing command: ', commandToExecute, options.cwd);
// Get user's shell preference from settings
const userSettings = PersistentStorage.USER_SETTINGS.read();
const userShellPreference = userSettings?.editor?.shellType;

// Use the shared shell detection function with user preference
const shell = getShellCommand(userShellPreference);
const detectedShell = detectUserShell();

console.log(`Executing command: ${commandToExecute}`);
console.log(
` Shell: ${shell}, WSL: ${isWSL()}, User Preference: ${userShellPreference || 'auto-detect'}, Detected: ${detectedShell}`,
options.cwd,
);
const { stdout, stderr } = await execAsync(commandToExecute, {
cwd: options.cwd,
maxBuffer: 1024 * 1024 * 10,
Expand Down
20 changes: 18 additions & 2 deletions apps/studio/electron/main/run/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import * as pty from 'node-pty';
import os from 'os';
import { mainWindow } from '..';
import { getBunCommand } from '../bun';
import { getShellCommand, isWSL, getLineEnding, detectUserShell } from '../utils/platform';
import { PersistentStorage } from '../storage';

class TerminalManager {
private static instance: TerminalManager;
Expand All @@ -24,7 +26,20 @@ class TerminalManager {

create(id: string, options?: { cwd?: string }): boolean {
try {
const shell = os.platform() === 'win32' ? 'powershell.exe' : '/bin/sh';
// Get user's shell preference from settings
const userSettings = PersistentStorage.USER_SETTINGS.read();
const userShellPreference = userSettings?.editor?.shellType;

// Use the shell detection function with user preference
const shell = getShellCommand(userShellPreference);
const detectedShell = detectUserShell();
console.log(`Creating terminal with shell: ${shell}`);
console.log(` Platform: ${os.platform()}`);
console.log(` WSL: ${isWSL()}`);
console.log(` User Preference: ${userShellPreference || 'auto-detect'}`);
console.log(` Detected User Shell: ${detectedShell}`);
console.log(` Final Shell: ${shell}`);

const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cwd: options?.cwd,
Expand Down Expand Up @@ -176,7 +191,8 @@ class TerminalManager {
executeCommand(id: string, command: string): boolean {
try {
const commandToExecute = getBunCommand(command);
const newline = os.platform() === 'win32' ? '\r\n' : '\n';
// Use platform-aware line ending that considers WSL
const newline = getLineEnding();
return this.write(id, commandToExecute + newline);
} catch (error) {
console.error('Failed to execute command.', error);
Expand Down
148 changes: 148 additions & 0 deletions apps/studio/electron/main/utils/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import fs from 'fs';
import os from 'os';
import { execSync } from 'child_process';
import { ShellType } from '@onlook/models/constants';

/**
* Detects if the current environment is Windows Subsystem for Linux (WSL)
* @returns true if running in WSL, false otherwise
*/
export function isWSL(): boolean {
try {
// Check for WSL-specific environment variables
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
return true;
}

// Check for WSL in /proc/version (available in WSL)
if (os.platform() === 'linux') {
try {
const procVersion = fs.readFileSync('/proc/version', 'utf8');
return (
procVersion.toLowerCase().includes('microsoft') ||
procVersion.toLowerCase().includes('wsl')
);
} catch {
// If we can't read /proc/version, fall back to other checks
}
}

// Check for WSL in /proc/sys/kernel/osrelease (WSL 2)
if (os.platform() === 'linux') {
try {
const osRelease = fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8');
return (
osRelease.toLowerCase().includes('microsoft') ||
osRelease.toLowerCase().includes('wsl')
);
} catch {
// If we can't read the file, it's likely not WSL
}
}

return false;
} catch (error) {
console.warn('Error detecting WSL environment:', error);
return false;
}
}

/**
* Detects the user's default shell from environment variables and system configuration
* @returns the path to the user's default shell
*/
export function detectUserShell(): string {
try {
// First, try the SHELL environment variable (most reliable)
if (process.env.SHELL) {
return process.env.SHELL;
}

// On Unix-like systems, try to get the shell from /etc/passwd
if (os.platform() !== 'win32') {
try {
const username = os.userInfo().username;
const passwdEntry = execSync(`getent passwd ${username}`, {
encoding: 'utf8',
}).trim();
const fields = passwdEntry.split(':');
if (fields.length >= 7 && fields[6]) {
return fields[6];
}
} catch (error) {
console.warn(
'Could not read user shell from passwd:',
error instanceof Error ? error.message : String(error),
);
}
}

// Fallback to common shells based on platform
if (os.platform() === 'win32') {
return 'powershell.exe';
} else {
return '/bin/bash'; // Most common fallback
}
} catch (error) {
console.warn('Error detecting user shell:', error);
// Ultimate fallback
return os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash';
}
}

/**
* Determines the appropriate shell to use based on the platform and environment
* @param userPreference Optional user preference for shell type
* @returns the shell command to use
*/
export function getShellCommand(userPreference?: ShellType): string {
// If user has specified a preference, use it (except for auto-detect)
if (userPreference && userPreference !== ShellType.AUTO_DETECT) {
return getShellCommandForType(userPreference);
}

// Auto-detect logic: use the user's actual default shell
return detectUserShell();
}

/**
* Gets the shell command for a specific shell type
* @param shellType The shell type to get the command for
* @returns the shell command
*/
export function getShellCommandForType(shellType: ShellType): string {
switch (shellType) {
case ShellType.POWERSHELL:
return 'powershell.exe';
case ShellType.BASH:
return '/bin/bash';
case ShellType.FISH:
return '/usr/bin/fish';
case ShellType.ZSH:
return '/bin/zsh';
case ShellType.SYSTEM_SHELL:
return '/bin/sh';
case ShellType.AUTO_DETECT:
default:
// Fall back to auto-detection
return getShellCommand();
}
}

/**
* Determines if we're running on a Windows-like environment (including WSL)
* This is useful for path handling and other Windows-specific logic
* @returns true if running on Windows or WSL, false otherwise
*/
export function isWindowsLike(): boolean {
return os.platform() === 'win32' || isWSL();
}

/**
* Gets platform-specific line ending
* @returns '\r\n' for Windows, '\n' for Unix-like systems
*/
export function getLineEnding(): string {
// Use Windows line endings only for native Windows, not WSL
return os.platform() === 'win32' && !isWSL() ? '\r\n' : '\n';
}
41 changes: 40 additions & 1 deletion apps/studio/src/components/Modals/Settings/Preferences/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { useUserManager } from '@/components/Context';
import { useTheme } from '@/components/ThemeProvider';
import { invokeMainChannel } from '@/lib/utils';
import { Language, LANGUAGE_DISPLAY_NAMES, MainChannels, Theme } from '@onlook/models/constants';
import {
Language,
LANGUAGE_DISPLAY_NAMES,
MainChannels,
Theme,
ShellType,
SHELL_TYPE_DISPLAY_NAMES,
} from '@onlook/models/constants';
import { DEFAULT_IDE } from '@onlook/models/ide';
import { Button } from '@onlook/ui/button';
import {
Expand All @@ -23,6 +30,7 @@ const PreferencesTab = observer(() => {
const ide = IDE.fromType(userManager.settings.settings?.editor?.ideType || DEFAULT_IDE);
const isAnalyticsEnabled = userManager.settings.settings?.enableAnalytics || false;
const shouldWarnDelete = userManager.settings.settings?.editor?.shouldWarnDelete ?? true;
const shellType = userManager.settings.settings?.editor?.shellType || ShellType.AUTO_DETECT;
const IDEIcon = Icons[ide.icon];

function updateIde(ide: IDE) {
Expand All @@ -38,6 +46,10 @@ const PreferencesTab = observer(() => {
userManager.settings.updateEditor({ shouldWarnDelete: enabled });
}

function updateShellType(newShellType: ShellType) {
userManager.settings.updateEditor({ shellType: newShellType });
}

return (
<div className="flex flex-col gap-8 p-6">
<div className="flex justify-between items-center">
Expand Down Expand Up @@ -142,6 +154,33 @@ const PreferencesTab = observer(() => {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex justify-between items-center">
<div className="flex flex-col gap-2">
<p className="text-largePlus">Terminal Shell</p>
<p className="text-foreground-onlook text-small">
Choose the shell to use for terminal operations
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="text-smallPlus min-w-[150px]">
{SHELL_TYPE_DISPLAY_NAMES[shellType]}
<Icons.ChevronDown className="ml-auto" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[150px]">
{Object.entries(SHELL_TYPE_DISPLAY_NAMES).map(([type, displayName]) => (
<DropdownMenuItem
key={type}
onClick={() => updateShellType(type as ShellType)}
>
<span>{displayName}</span>
{shellType === type && <Icons.CheckCircled className="ml-auto" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className=" flex justify-between items-center gap-4">
<div className=" flex flex-col gap-2">
<p className="text-largePlus">{'Warn before delete'}</p>
Expand Down
2 changes: 2 additions & 0 deletions packages/models/src/constants/editor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DEFAULT_IDE } from '../ide/index.ts';
import { ShellType } from './terminal';

export const APP_NAME = 'Onlook';
export const APP_SCHEMA = 'onlook';
Expand Down Expand Up @@ -86,6 +87,7 @@ export const DefaultSettings = {
ideType: DEFAULT_IDE,
enableBunReplace: true,
buildFlags: '--no-lint',
shellType: ShellType.AUTO_DETECT,
},
};

Expand Down
24 changes: 24 additions & 0 deletions packages/models/src/constants/terminal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
export const TerminalCommands = {
CTRL_C: String.fromCharCode(3),
};

/**
* Available shell types for terminal usage
*/
export enum ShellType {
AUTO_DETECT = 'auto-detect',
POWERSHELL = 'powershell',
BASH = 'bash',
FISH = 'fish',
ZSH = 'zsh',
SYSTEM_SHELL = 'system-shell',
}

/**
* Display names for shell types in the UI
*/
export const SHELL_TYPE_DISPLAY_NAMES: Record<ShellType, string> = {
[ShellType.AUTO_DETECT]: 'Auto-detect',
[ShellType.POWERSHELL]: 'PowerShell',
[ShellType.BASH]: 'Bash',
[ShellType.FISH]: 'Fish',
[ShellType.ZSH]: 'Zsh',
[ShellType.SYSTEM_SHELL]: 'System Shell (/bin/sh)',
};
2 changes: 2 additions & 0 deletions packages/models/src/settings/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IdeType } from '../ide';
import { type Project } from '../projects';
import { ShellType } from '../constants/terminal';

export interface UserSettings {
id?: string;
Expand All @@ -15,6 +16,7 @@ export interface EditorSettings {
enableBunReplace?: boolean;
buildFlags?: string;
newProjectPath?: string;
shellType?: ShellType;
}

export interface ChatSettings {
Expand Down