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
14 changes: 10 additions & 4 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,17 @@ fn get_available_shells() -> Vec<ShellInfo> {

#[cfg(target_os = "windows")]
{
// CRITICAL: Check explicit paths FIRST, then PATH entries
// This ensures the correct shell is found when multiple versions exist
let mut candidates = vec![
// PowerShell 7 explicit paths (checked first)
("pwsh", r"C:\Program Files\PowerShell\7\pwsh.exe", None),
("pwsh", r"C:\Program Files\PowerShell\6\pwsh.exe", None),
// Windows PowerShell 5 (explicit path)
("powershell", r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", None),
// PATH-based fallbacks (checked last)
("pwsh", "pwsh.exe", None),
("powershell", "powershell.exe", None),
("pwsh", "pwsh.exe", None), // PowerShell 7 via PATH
("pwsh", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", None), // PowerShell 7 explicit path
("cmd", "cmd.exe", None),
("wsl", "wsl.exe", None),
];
Expand Down Expand Up @@ -269,14 +276,13 @@ fn get_available_shells() -> Vec<ShellInfo> {
#[cfg(target_os = "windows")]
fn is_builtin_windows_shell(shell_path: &str) -> bool {
let normalized = shell_path.to_ascii_lowercase();
// NOTE: pwsh is NOT a built-in - it must be resolved from PATH
matches!(
normalized.as_str(),
"cmd"
| "cmd.exe"
| "powershell"
| "powershell.exe"
| "pwsh"
| "pwsh.exe"
| "wsl"
| "wsl.exe"
)
Expand Down
50 changes: 31 additions & 19 deletions src-tauri/src/pty/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,13 +471,13 @@ impl PtyManager {
if cfg!(windows)
&& (shell_path.contains("powershell") || shell_path.contains("pwsh"))
{
"-NoLogo -NoProfile"
"-NoLogo" // Load user profile for custom prompts
} else {
""
}
)
} else if shell_path.contains("powershell") || shell_path.contains("pwsh") {
format!("{} -NoLogo -NoProfile", shell_path)
format!("{} -NoLogo", shell_path) // Load user profile for custom prompts
} else {
shell_path.clone()
};
Expand Down Expand Up @@ -966,13 +966,40 @@ impl PtyManager {
}

// Standard shell resolution for other shells
// Try shell.exe variant
// CRITICAL: Check PowerShell variants BEFORE generic *.exe lookup
// so name-only tokens hit explicit paths first
if shell == "pwsh" {
// PowerShell 7/6 resolution path
let paths = vec![
r"C:\Program Files\PowerShell\7\pwsh.exe",
r"C:\Program Files\PowerShell\6\pwsh.exe",
"pwsh.exe",
];
for path in paths {
if let Some(abs_path) = self.get_absolute_shell_path(path) {
return Ok(abs_path);
}
}
} else if shell == "powershell" {
// Windows PowerShell 5 resolution path
let paths = vec![
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"powershell.exe",
];
for path in paths {
if let Some(abs_path) = self.get_absolute_shell_path(path) {
return Ok(abs_path);
}
}
}

// Try shell.exe variant for non-PowerShell shells
let exe_shell = format!("{}.exe", shell);
if let Some(abs_path) = self.get_absolute_shell_path(&exe_shell) {
return Ok(abs_path);
}

// Try the shell name directly
// Try the shell name directly for non-PowerShell shells
if let Some(abs_path) = self.get_absolute_shell_path(shell) {
return Ok(abs_path);
}
Expand All @@ -992,21 +1019,6 @@ impl PtyManager {
}
}
}

// Try PowerShell variants
if shell == "powershell" || shell == "pwsh" {
let paths = vec![
"pwsh.exe",
"powershell.exe",
r"C:\Program Files\PowerShell\7\pwsh.exe",
r"C:\Program Files\PowerShell\6\pwsh.exe",
];
for path in paths {
if let Some(abs_path) = self.get_absolute_shell_path(path) {
return Ok(abs_path);
}
}
}
}

#[cfg(not(target_os = "windows"))]
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/ProjectSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,6 @@ describe('ProjectSidebar Default Shell Submenu', () => {
})
fireEvent.click(screen.getByText('Zsh'))

expect(onUpdateProject).toHaveBeenCalledWith('1', { defaultShell: 'zsh' })
expect(onUpdateProject).toHaveBeenCalledWith('1', { defaultShell: '/usr/bin/zsh' })
})
})
18 changes: 14 additions & 4 deletions src/renderer/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,18 @@ export function ProjectSidebar({
const project = projects.find((p) => p.id === projectId)
const shellSubmenu: ContextMenuSubItem[] = availableShells?.available.map((shell) => ({
label: shell.displayName,
value: shell.name,
isSelected: project?.defaultShell === shell.name
value: shell.path,
isSelected: (() => {
const projectShell = project?.defaultShell
if (!projectShell) return false
// Match by full path
if (projectShell === shell.path) return true
// Match by name
if (projectShell === shell.name) return true
// Match by basename of path
const pathBasename = shell.path.split(/[\\/]/).pop()
return projectShell === pathBasename
})()
})) || []

const items: ContextMenuItem[] = [
Expand All @@ -237,8 +247,8 @@ export function ProjectSidebar({
label: 'Set Default Shell',
icon: <Terminal size={14} />,
submenu: shellSubmenu,
onSubmenuSelect: (shellName: string) => {
onUpdateProject(projectId, { defaultShell: shellName })
onSubmenuSelect: (shellPath: string) => {
onUpdateProject(projectId, { defaultShell: shellPath })
}
})
}
Expand Down
51 changes: 46 additions & 5 deletions src/renderer/hooks/use-terminal-restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTerminalStore } from '../stores/terminal-store'
import { useAppSettingsStore } from '../stores/app-settings-store'
import { useWorkspaceStore } from '../stores/workspace-store'
import { terminalApi } from '@/lib/api'
import { shellApi } from '@/lib/shell-api'
import {
loadPersistedTerminals,
saveTerminalLayout,
Expand Down Expand Up @@ -142,6 +143,41 @@ export function normalizeShellForStartup(shell?: string): string {
return shell
}

/**
* Resolve shell identifier to an absolute path.
* If the shell is already a path (contains \ or /), return it as-is.
* If it's just a name (e.g., "pwsh", "bash"), look up the path from available shells.
* Also matches by executable basename (e.g., "pwsh.exe" matches shell with path ending in "pwsh.exe").
*/
async function resolveShellToPath(shell: string): Promise<string> {
// If shell is already a path, return it as-is
if (shell.includes('\\') || shell.includes('/')) {
return shell
}

// Otherwise, look up the path from available shells
try {
const result = await shellApi.getAvailableShells()
if (result.success && result.data.available) {
// Match by name or by executable basename
const match = result.data.available.find((s) => {
if (s.name === shell) return true
// Also match by basename of path (e.g., "pwsh.exe" matches "C:\...\pwsh.exe")
const pathBasename = s.path.split(/[\\/]/).pop()
return pathBasename === shell
})
if (match) {
return match.path
}
}
} catch (error) {
console.error('Failed to resolve shell path:', error)
}

// Fallback: return original value
return shell
}

/**
* Hook to restore terminals when switching projects
* Loads persisted terminal layout and creates terminal instances
Expand Down Expand Up @@ -291,6 +327,8 @@ export function useTerminalRestore(): void {
restoreTerminals()

// CRITICAL: Cleanup function to handle project switching mid-restore
// Capture projectId at effect run time to avoid stale closure in cleanup
const projectIdForCleanup = activeProjectId
return () => {
// Signal cancellation to the async function
cancelRestore()
Expand All @@ -300,7 +338,8 @@ export function useTerminalRestore(): void {
if (idx > -1) RESTORE_CALL_STACK.splice(idx, 1)

// FIX #5: Also clean up the restoring flag for this project on cleanup
isRestoringRef.current.delete(activeProjectId)
// eslint-disable-next-line react-hooks/exhaustive-deps -- Ref access in cleanup is intentional
isRestoringRef.current.delete(projectIdForCleanup)
}
}, [activeProjectId])
}
Expand Down Expand Up @@ -415,7 +454,8 @@ async function restoreFromLayout(projectId: string, layout: PersistedTerminalLay
TERMINALS_PENDING_PTY_ASSIGNMENT.add(newId)

try {
const normalizedShell = normalizeShellForStartup(persistedTerminal.shell)
const resolvedShell = await resolveShellToPath(persistedTerminal.shell)
const normalizedShell = normalizeShellForStartup(resolvedShell)
const spawnResult = await terminalApi.spawn({
shell: normalizedShell,
cwd: persistedTerminal.cwd
Expand Down Expand Up @@ -546,10 +586,11 @@ async function createDefaultTerminal(projectId: string): Promise<void> {
}

// Get shell from fallback chain: project -> app settings -> system default
// Then resolve shell name to path for backward compatibility
const project = projectStore.projects.find((p) => p.id === projectId)
const shell = normalizeShellForStartup(
project?.defaultShell || appSettings.settings.defaultShell || ''
)
const shellSetting = project?.defaultShell || appSettings.settings.defaultShell || ''
const resolvedShell = await resolveShellToPath(shellSetting)
const shell = normalizeShellForStartup(resolvedShell)

debugLog('createDefaultTerminal', `Spawning default terminal [${defaultId}]`, {
shell,
Expand Down
72 changes: 37 additions & 35 deletions src/renderer/layouts/WorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,40 @@ export default function WorkspaceLayout(): React.JSX.Element {
[isWorkspaceRoute]
)

// Terminal creation callbacks - defined before keyboard shortcut useEffect
const handleCreateTerminalInPane = useCallback(
async (paneId: string, shellName?: string) => {
if (terminals.length >= maxTerminals) {
toast.error(`Maximum ${maxTerminals} terminals per project`)
return
}

const shell = shellName || activeProject?.defaultShell || appDefaultShell || undefined
const cwd = activeProject?.path

const spawnResult = await terminalApi.spawn({ shell, cwd })
if (!spawnResult.success) {
toast.error(spawnResult.error || 'Failed to create terminal')
return
}

const terminal = addTerminal(`Terminal ${terminals.length + 1}`, activeProjectId, shell, cwd)
useTerminalStore.getState().setTerminalPtyId(terminal.id, spawnResult.data.id)

useWorkspaceStore.getState().addTabToPane(paneId, {
type: 'terminal',
id: `term-${terminal.id}`,
terminalId: terminal.id
})
},
[activeProject?.defaultShell, activeProject?.path, activeProjectId, addTerminal, appDefaultShell, maxTerminals, terminals.length]
)

const handleNewTerminal = useCallback(() => {
const paneId = useWorkspaceStore.getState().activePaneId
handleCreateTerminalInPane(paneId)
}, [handleCreateTerminalInPane])

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if typing in an input/textarea/editable element
Expand Down Expand Up @@ -487,7 +521,8 @@ export default function WorkspaceLayout(): React.JSX.Element {
maxTerminals,
isWorkspaceRoute,
cycleTab,
activeTab
activeTab,
handleCreateTerminalInPane
])

// Listen for optional backend shortcut callbacks. In current Tauri fallback mode this is effectively a future-compat shim.
Expand Down Expand Up @@ -523,39 +558,6 @@ export default function WorkspaceLayout(): React.JSX.Element {
})
}, [cycleTab, fontSize, updateAppSetting])

const handleCreateTerminalInPane = useCallback(
async (paneId: string, shellName?: string) => {
if (terminals.length >= maxTerminals) {
toast.error(`Maximum ${maxTerminals} terminals per project`)
return
}

const shell = shellName || activeProject?.defaultShell || appDefaultShell || undefined
const cwd = activeProject?.path

const spawnResult = await terminalApi.spawn({ shell, cwd })
if (!spawnResult.success) {
toast.error(spawnResult.error || 'Failed to create terminal')
return
}

const terminal = addTerminal(`Terminal ${terminals.length + 1}`, activeProjectId, shell, cwd)
useTerminalStore.getState().setTerminalPtyId(terminal.id, spawnResult.data.id)

useWorkspaceStore.getState().addTabToPane(paneId, {
type: 'terminal',
id: `term-${terminal.id}`,
terminalId: terminal.id
})
},
[activeProject?.defaultShell, activeProject?.path, activeProjectId, addTerminal, appDefaultShell, maxTerminals, terminals.length]
)

const handleNewTerminal = useCallback(() => {
const paneId = useWorkspaceStore.getState().activePaneId
handleCreateTerminalInPane(paneId)
}, [handleCreateTerminalInPane])

const handleCloseTerminal = useCallback((id: string, tabId: string) => {
setCloseConfirmTerminal({ terminalId: id, tabId })
}, [])
Expand Down Expand Up @@ -761,7 +763,7 @@ export default function WorkspaceLayout(): React.JSX.Element {
handleCreateTerminalInPane(paneId)
}}
onNewTerminalWithShell={(paneId, shell) => {
handleCreateTerminalInPane(paneId, shell.name)
handleCreateTerminalInPane(paneId, shell.path)
}}
onCloseTerminal={handleCloseTerminal}
onRenameTerminal={renameTerminal}
Expand Down
20 changes: 17 additions & 3 deletions src/renderer/pages/AppPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,14 +287,28 @@ export default function AppPreferences(): React.JSX.Element {
Shell
</label>
<select
value={defaultShell}
value={(() => {
// Normalize the stored defaultShell for display
// If it's a path, use it directly; if it's a name, find matching shell's path
if (!defaultShell) return ''
if (defaultShell.includes('\\') || defaultShell.includes('/')) {
return defaultShell
}
// Find shell by name or by basename of path
const match = availableShells?.available.find((s) => {
if (s.name === defaultShell) return true
const pathBasename = s.path.split(/[\\/]/).pop()
return pathBasename === defaultShell
})
return match?.path ?? defaultShell
})()}
onChange={(e) => handleDefaultShellChange(e.target.value)}
className="w-full bg-secondary/50 border border-border rounded-md px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-shadow"
>
<option value="">System Default</option>
{availableShells?.available?.map((shell) => (
<option key={shell.path} value={shell.name}>
{shell.name} ({shell.path})
<option key={shell.path} value={shell.path}>
{shell.displayName}
</option>
))}
</select>
Expand Down
Loading