@@ -28,6 +28,7 @@ import { DisposableTempDir } from "@/services/tempDir";
2828import { InitStateManager } from "@/services/initStateManager" ;
2929import { createRuntime } from "@/runtime/runtimeFactory" ;
3030import type { RuntimeConfig } from "@/types/runtime" ;
31+ import { isSSHRuntime } from "@/types/runtime" ;
3132import { validateProjectPath } from "@/utils/pathUtils" ;
3233import { ExtensionMetadataService } from "@/services/ExtensionMetadataService" ;
3334/**
@@ -957,76 +958,29 @@ export class IpcMain {
957958 }
958959 ) ;
959960
960- ipcMain . handle ( IPC_CHANNELS . WORKSPACE_OPEN_TERMINAL , async ( _event , workspacePath : string ) => {
961+ ipcMain . handle ( IPC_CHANNELS . WORKSPACE_OPEN_TERMINAL , async ( _event , workspaceId : string ) => {
961962 try {
962- if ( process . platform === "darwin" ) {
963- // macOS - try Ghostty first, fallback to Terminal.app
964- const terminal = await this . findAvailableCommand ( [ "ghostty" , "terminal" ] ) ;
965- if ( terminal === "ghostty" ) {
966- // Match main: pass workspacePath to 'open -a Ghostty' to avoid regressions
967- const cmd = "open" ;
968- const args = [ "-a" , "Ghostty" , workspacePath ] ;
969- log . info ( `Opening terminal: ${ cmd } ${ args . join ( " " ) } ` ) ;
970- const child = spawn ( cmd , args , {
971- detached : true ,
972- stdio : "ignore" ,
973- } ) ;
974- child . unref ( ) ;
975- } else {
976- // Terminal.app opens in the directory when passed as argument
977- const cmd = "open" ;
978- const args = [ "-a" , "Terminal" , workspacePath ] ;
979- log . info ( `Opening terminal: ${ cmd } ${ args . join ( " " ) } ` ) ;
980- const child = spawn ( cmd , args , {
981- detached : true ,
982- stdio : "ignore" ,
983- } ) ;
984- child . unref ( ) ;
985- }
986- } else if ( process . platform === "win32" ) {
987- // Windows
988- const cmd = "cmd" ;
989- const args = [ "/c" , "start" , "cmd" , "/K" , "cd" , "/D" , workspacePath ] ;
990- log . info ( `Opening terminal: ${ cmd } ${ args . join ( " " ) } ` ) ;
991- const child = spawn ( cmd , args , {
992- detached : true ,
993- shell : true ,
994- stdio : "ignore" ,
963+ // Look up workspace metadata to get runtime config
964+ const allMetadata = await this . config . getAllWorkspaceMetadata ( ) ;
965+ const workspace = allMetadata . find ( ( w ) => w . id === workspaceId ) ;
966+
967+ if ( ! workspace ) {
968+ log . error ( `Workspace not found: ${ workspaceId } ` ) ;
969+ return ;
970+ }
971+
972+ const runtimeConfig = workspace . runtimeConfig ;
973+
974+ if ( isSSHRuntime ( runtimeConfig ) ) {
975+ // SSH workspace - spawn local terminal that SSHs into remote host
976+ await this . openTerminal ( {
977+ type : "ssh" ,
978+ sshConfig : runtimeConfig ,
979+ remotePath : workspace . namedWorkspacePath ,
995980 } ) ;
996- child . unref ( ) ;
997981 } else {
998- // Linux - try terminal emulators in order of preference
999- // x-terminal-emulator is checked first as it respects user's system-wide preference
1000- const terminals = [
1001- { cmd : "x-terminal-emulator" , args : [ ] , cwd : workspacePath } ,
1002- { cmd : "ghostty" , args : [ "--working-directory=" + workspacePath ] } ,
1003- { cmd : "alacritty" , args : [ "--working-directory" , workspacePath ] } ,
1004- { cmd : "kitty" , args : [ "--directory" , workspacePath ] } ,
1005- { cmd : "wezterm" , args : [ "start" , "--cwd" , workspacePath ] } ,
1006- { cmd : "gnome-terminal" , args : [ "--working-directory" , workspacePath ] } ,
1007- { cmd : "konsole" , args : [ "--workdir" , workspacePath ] } ,
1008- { cmd : "xfce4-terminal" , args : [ "--working-directory" , workspacePath ] } ,
1009- { cmd : "xterm" , args : [ ] , cwd : workspacePath } ,
1010- ] ;
1011-
1012- const availableTerminal = await this . findAvailableTerminal ( terminals ) ;
1013-
1014- if ( availableTerminal ) {
1015- const cwdInfo = availableTerminal . cwd ? ` (cwd: ${ availableTerminal . cwd } )` : "" ;
1016- log . info (
1017- `Opening terminal: ${ availableTerminal . cmd } ${ availableTerminal . args . join ( " " ) } ${ cwdInfo } `
1018- ) ;
1019- const child = spawn ( availableTerminal . cmd , availableTerminal . args , {
1020- cwd : availableTerminal . cwd ?? workspacePath ,
1021- detached : true ,
1022- stdio : "ignore" ,
1023- } ) ;
1024- child . unref ( ) ;
1025- } else {
1026- log . error (
1027- "No terminal emulator found. Tried: " + terminals . map ( ( t ) => t . cmd ) . join ( ", " )
1028- ) ;
1029- }
982+ // Local workspace - spawn terminal with cwd set
983+ await this . openTerminal ( { type : "local" , workspacePath : workspace . namedWorkspacePath } ) ;
1030984 }
1031985 } catch ( error ) {
1032986 const message = error instanceof Error ? error . message : String ( error ) ;
@@ -1383,6 +1337,168 @@ export class IpcMain {
13831337 }
13841338 }
13851339
1340+ /**
1341+ * Open a terminal (local or SSH) with platform-specific handling
1342+ */
1343+ private async openTerminal (
1344+ config :
1345+ | { type : "local" ; workspacePath : string }
1346+ | {
1347+ type : "ssh" ;
1348+ sshConfig : Extract < RuntimeConfig , { type : "ssh" } > ;
1349+ remotePath : string ;
1350+ }
1351+ ) : Promise < void > {
1352+ const isSSH = config . type === "ssh" ;
1353+
1354+ // Build SSH args if needed
1355+ let sshArgs : string [ ] | null = null ;
1356+ if ( isSSH ) {
1357+ sshArgs = [ ] ;
1358+ // Add port if specified
1359+ if ( config . sshConfig . port ) {
1360+ sshArgs . push ( "-p" , String ( config . sshConfig . port ) ) ;
1361+ }
1362+ // Add identity file if specified
1363+ if ( config . sshConfig . identityFile ) {
1364+ sshArgs . push ( "-i" , config . sshConfig . identityFile ) ;
1365+ }
1366+ // Force pseudo-terminal allocation
1367+ sshArgs . push ( "-t" ) ;
1368+ // Add host
1369+ sshArgs . push ( config . sshConfig . host ) ;
1370+ // Add remote command to cd into directory and start shell
1371+ // Use single quotes to prevent local shell expansion
1372+ // exec $SHELL replaces the SSH process with the shell, avoiding nested processes
1373+ sshArgs . push ( `cd '${ config . remotePath . replace ( / ' / g, "'\\''" ) } ' && exec $SHELL` ) ;
1374+ }
1375+
1376+ const logPrefix = isSSH ? "SSH terminal" : "terminal" ;
1377+
1378+ if ( process . platform === "darwin" ) {
1379+ // macOS - try Ghostty first, fallback to Terminal.app
1380+ const terminal = await this . findAvailableCommand ( [ "ghostty" , "terminal" ] ) ;
1381+ if ( terminal === "ghostty" ) {
1382+ const cmd = "open" ;
1383+ let args : string [ ] ;
1384+ if ( isSSH && sshArgs ) {
1385+ // Ghostty: Use --command flag to run SSH
1386+ // Build the full SSH command as a single string
1387+ const sshCommand = [ "ssh" , ...sshArgs ] . join ( " " ) ;
1388+ args = [ "-n" , "-a" , "Ghostty" , "--args" , `--command=${ sshCommand } ` ] ;
1389+ } else {
1390+ // Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions
1391+ if ( config . type !== "local" ) throw new Error ( "Expected local config" ) ;
1392+ args = [ "-a" , "Ghostty" , config . workspacePath ] ;
1393+ }
1394+ log . info ( `Opening ${ logPrefix } : ${ cmd } ${ args . join ( " " ) } ` ) ;
1395+ const child = spawn ( cmd , args , {
1396+ detached : true ,
1397+ stdio : "ignore" ,
1398+ } ) ;
1399+ child . unref ( ) ;
1400+ } else {
1401+ // Terminal.app
1402+ const cmd = isSSH ? "osascript" : "open" ;
1403+ let args : string [ ] ;
1404+ if ( isSSH && sshArgs ) {
1405+ // Terminal.app: Use osascript with proper AppleScript structure
1406+ // Properly escape single quotes in args before wrapping in quotes
1407+ const sshCommand = `ssh ${ sshArgs
1408+ . map ( ( arg ) => {
1409+ if ( arg . includes ( " " ) || arg . includes ( "'" ) ) {
1410+ // Escape single quotes by ending quote, adding escaped quote, starting quote again
1411+ return `'${ arg . replace ( / ' / g, "'\\''" ) } '` ;
1412+ }
1413+ return arg ;
1414+ } )
1415+ . join ( " " ) } `;
1416+ // Escape double quotes for AppleScript string
1417+ const escapedCommand = sshCommand . replace ( / \\ / g, "\\\\" ) . replace ( / " / g, '\\"' ) ;
1418+ const script = `tell application "Terminal"\nactivate\ndo script "${ escapedCommand } "\nend tell` ;
1419+ args = [ "-e" , script ] ;
1420+ } else {
1421+ // Terminal.app opens in the directory when passed as argument
1422+ if ( config . type !== "local" ) throw new Error ( "Expected local config" ) ;
1423+ args = [ "-a" , "Terminal" , config . workspacePath ] ;
1424+ }
1425+ log . info ( `Opening ${ logPrefix } : ${ cmd } ${ args . join ( " " ) } ` ) ;
1426+ const child = spawn ( cmd , args , {
1427+ detached : true ,
1428+ stdio : "ignore" ,
1429+ } ) ;
1430+ child . unref ( ) ;
1431+ }
1432+ } else if ( process . platform === "win32" ) {
1433+ // Windows
1434+ const cmd = "cmd" ;
1435+ let args : string [ ] ;
1436+ if ( isSSH && sshArgs ) {
1437+ // Windows - use cmd to start ssh
1438+ args = [ "/c" , "start" , "cmd" , "/K" , "ssh" , ...sshArgs ] ;
1439+ } else {
1440+ if ( config . type !== "local" ) throw new Error ( "Expected local config" ) ;
1441+ args = [ "/c" , "start" , "cmd" , "/K" , "cd" , "/D" , config . workspacePath ] ;
1442+ }
1443+ log . info ( `Opening ${ logPrefix } : ${ cmd } ${ args . join ( " " ) } ` ) ;
1444+ const child = spawn ( cmd , args , {
1445+ detached : true ,
1446+ shell : true ,
1447+ stdio : "ignore" ,
1448+ } ) ;
1449+ child . unref ( ) ;
1450+ } else {
1451+ // Linux - try terminal emulators in order of preference
1452+ let terminals : Array < { cmd : string ; args : string [ ] ; cwd ?: string } > ;
1453+
1454+ if ( isSSH && sshArgs ) {
1455+ // x-terminal-emulator is checked first as it respects user's system-wide preference
1456+ terminals = [
1457+ { cmd : "x-terminal-emulator" , args : [ "-e" , "ssh" , ...sshArgs ] } ,
1458+ { cmd : "ghostty" , args : [ "ssh" , ...sshArgs ] } ,
1459+ { cmd : "alacritty" , args : [ "-e" , "ssh" , ...sshArgs ] } ,
1460+ { cmd : "kitty" , args : [ "ssh" , ...sshArgs ] } ,
1461+ { cmd : "wezterm" , args : [ "start" , "--" , "ssh" , ...sshArgs ] } ,
1462+ { cmd : "gnome-terminal" , args : [ "--" , "ssh" , ...sshArgs ] } ,
1463+ { cmd : "konsole" , args : [ "-e" , "ssh" , ...sshArgs ] } ,
1464+ { cmd : "xfce4-terminal" , args : [ "-e" , `ssh ${ sshArgs . join ( " " ) } ` ] } ,
1465+ { cmd : "xterm" , args : [ "-e" , "ssh" , ...sshArgs ] } ,
1466+ ] ;
1467+ } else {
1468+ if ( config . type !== "local" ) throw new Error ( "Expected local config" ) ;
1469+ const workspacePath = config . workspacePath ;
1470+ terminals = [
1471+ { cmd : "x-terminal-emulator" , args : [ ] , cwd : workspacePath } ,
1472+ { cmd : "ghostty" , args : [ "--working-directory=" + workspacePath ] } ,
1473+ { cmd : "alacritty" , args : [ "--working-directory" , workspacePath ] } ,
1474+ { cmd : "kitty" , args : [ "--directory" , workspacePath ] } ,
1475+ { cmd : "wezterm" , args : [ "start" , "--cwd" , workspacePath ] } ,
1476+ { cmd : "gnome-terminal" , args : [ "--working-directory" , workspacePath ] } ,
1477+ { cmd : "konsole" , args : [ "--workdir" , workspacePath ] } ,
1478+ { cmd : "xfce4-terminal" , args : [ "--working-directory" , workspacePath ] } ,
1479+ { cmd : "xterm" , args : [ ] , cwd : workspacePath } ,
1480+ ] ;
1481+ }
1482+
1483+ const availableTerminal = await this . findAvailableTerminal ( terminals ) ;
1484+
1485+ if ( availableTerminal ) {
1486+ const cwdInfo = availableTerminal . cwd ? ` (cwd: ${ availableTerminal . cwd } )` : "" ;
1487+ log . info (
1488+ `Opening ${ logPrefix } : ${ availableTerminal . cmd } ${ availableTerminal . args . join ( " " ) } ${ cwdInfo } `
1489+ ) ;
1490+ const child = spawn ( availableTerminal . cmd , availableTerminal . args , {
1491+ cwd : availableTerminal . cwd ,
1492+ detached : true ,
1493+ stdio : "ignore" ,
1494+ } ) ;
1495+ child . unref ( ) ;
1496+ } else {
1497+ log . error ( "No terminal emulator found. Tried: " + terminals . map ( ( t ) => t . cmd ) . join ( ", " ) ) ;
1498+ }
1499+ }
1500+ }
1501+
13861502 /**
13871503 * Find the first available command from a list of commands
13881504 */
0 commit comments