diff --git a/Taskfile.yml b/Taskfile.yml
index a5f73417d7..6d329f3bf8 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -54,6 +54,17 @@ tasks:
WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central"
WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/"
+ electron:winquickdev:
+ desc: Run the Electron application via the Vite dev server (quick dev - Windows amd64 only, no generate, no wsh).
+ cmd: npm run dev
+ deps:
+ - npm:install
+ - build:backend:quickdev:windows
+ env:
+ WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env"
+ WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central"
+ WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/"
+
docs:npm:install:
desc: Runs `npm install` in docs directory
internal: true
@@ -186,6 +197,25 @@ tasks:
generates:
- dist/bin/wavesrv.*
+ build:backend:quickdev:windows:
+ desc: Build only the wavesrv component for quickdev (Windows amd64 only, no generate, no wsh).
+ platforms: [windows]
+ cmds:
+ - task: build:server:internal
+ vars:
+ ARCHS: amd64
+ GO_ENV_VARS: CC="zig cc -target x86_64-windows-gnu"
+ deps:
+ - go:mod:tidy
+ sources:
+ - "cmd/server/*.go"
+ - "pkg/**/*.go"
+ - "pkg/**/*.json"
+ - "pkg/**/*.sh"
+ - "tsunami/**/*.go"
+ generates:
+ - dist/bin/wavesrv.x64.exe
+
build:server:windows:
desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture).
platforms: [windows]
diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx
index c66b798ea7..9da0d6340b 100644
--- a/frontend/app/block/blockframe.tsx
+++ b/frontend/app/block/blockframe.tsx
@@ -254,7 +254,8 @@ const BlockFrame_Header = ({
icon: "link-slash",
title: "wsh is not installed for this connection",
};
- const showNoWshButton = manageConnection && wshProblem && !util.isBlank(connName) && !connName.startsWith("aws:");
+ const showNoWshButton =
+ manageConnection && wshProblem && !util.isLocalConnName(connName) && !connName.startsWith("aws:");
return (
{
return;
}
const connName = blockData?.meta?.connection;
- if (!util.isBlank(connName)) {
+ if (!util.isLocalConnName(connName)) {
console.log("ensure conn", nodeModel.blockId, connName);
RpcApi.ConnEnsureCommand(
TabRpcClient,
diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx
index 3cfd2343c2..60c7e2a901 100644
--- a/frontend/app/block/blockutil.tsx
+++ b/frontend/app/block/blockutil.tsx
@@ -166,7 +166,7 @@ export const ConnectionButton = React.memo(
React.forwardRef
(
({ connection, changeConnModalAtom }: ConnectionButtonProps, ref) => {
const [connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom);
- const isLocal = util.isBlank(connection);
+ const isLocal = util.isLocalConnName(connection);
const connStatusAtom = getConnStatusAtom(connection);
const connStatus = jotai.useAtomValue(connStatusAtom);
let showDisconnectedSlash = false;
@@ -178,9 +178,15 @@ export const ConnectionButton = React.memo(
};
let titleText = null;
let shouldSpin = false;
+ let connDisplayName: string = null;
if (isLocal) {
color = "var(--grey-text-color)";
- titleText = "Connected to Local Machine";
+ if (connection === "local:gitbash") {
+ titleText = "Connected to Git Bash";
+ connDisplayName = "Git Bash";
+ } else {
+ titleText = "Connected to Local Machine";
+ }
connIconElem = (
- {isLocal ? null : {connection}
}
+ {connDisplayName ? (
+ {connDisplayName}
+ ) : isLocal ? null : (
+ {connection}
+ )}
);
}
diff --git a/frontend/app/modals/conntypeahead.tsx b/frontend/app/modals/conntypeahead.tsx
index b5e21c2257..f326ba633f 100644
--- a/frontend/app/modals/conntypeahead.tsx
+++ b/frontend/app/modals/conntypeahead.tsx
@@ -3,16 +3,8 @@
import { computeConnColorNum } from "@/app/block/blockutil";
import { TypeAheadModal } from "@/app/modals/typeaheadmodal";
-import {
- atoms,
- createBlock,
- getApi,
- getConnStatusAtom,
- getHostName,
- getUserName,
- globalStore,
- WOS,
-} from "@/app/store/global";
+import { ConnectionsModel } from "@/app/store/connections-model";
+import { atoms, createBlock, getConnStatusAtom, getHostName, getUserName, globalStore, WOS } from "@/app/store/global";
import { globalRefocusWithTimeout } from "@/app/store/keymodel";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
@@ -107,7 +99,7 @@ function createFilteredLocalSuggestionItem(
iconColor: "var(--grey-text-color)",
value: "",
label: localName,
- current: connection == null,
+ current: util.isBlank(connection),
};
return [localSuggestion];
}
@@ -172,12 +164,26 @@ function getLocalSuggestions(
connSelected: string,
connStatusMap: Map,
fullConfig: FullConfigType,
- filterOutNowsh: boolean
+ filterOutNowsh: boolean,
+ hasGitBash: boolean
): SuggestionConnectionScope | null {
const wslFiltered = filterConnections(connList, connSelected, fullConfig, filterOutNowsh);
const wslSuggestionItems = createWslSuggestionItems(wslFiltered, connection, connStatusMap);
const localSuggestionItem = createFilteredLocalSuggestionItem(localName, connection, connSelected);
- const combinedSuggestionItems = [...localSuggestionItem, ...wslSuggestionItems];
+
+ const gitBashItems: Array = [];
+ if (hasGitBash && "Git Bash".toLowerCase().includes(connSelected.toLowerCase())) {
+ gitBashItems.push({
+ status: "connected",
+ icon: "laptop",
+ iconColor: "var(--grey-text-color)",
+ value: "local:gitbash",
+ label: "Git Bash",
+ current: connection === "local:gitbash",
+ });
+ }
+
+ const combinedSuggestionItems = [...localSuggestionItem, ...gitBashItems, ...wslSuggestionItems];
const sortedSuggestionItems = sortConnSuggestionItems(combinedSuggestionItems, fullConfig);
if (sortedSuggestionItems.length == 0) {
return null;
@@ -235,7 +241,7 @@ function getDisconnectItem(
connection: string,
connStatusMap: Map
): SuggestionConnectionItem | null {
- if (!connection) {
+ if (util.isLocalConnName(connection)) {
return null;
}
const connStatus = connStatusMap.get(connection);
@@ -346,6 +352,7 @@ const ChangeConnectionBlockModal = React.memo(
const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom);
let filterOutNowsh = util.useAtomValueSafe(viewModel.filterOutNowsh) ?? true;
const showS3 = util.useAtomValueSafe(viewModel.showS3) ?? false;
+ const hasGitBash = jotai.useAtomValue(ConnectionsModel.getInstance().hasGitBashAtom);
let maxActiveConnNum = 1;
for (const conn of allConnStatus) {
@@ -402,7 +409,7 @@ const ChangeConnectionBlockModal = React.memo(
oref: WOS.makeORef("block", blockId),
meta: { connection: connName, file: newFile, "cmd:cwd": null },
});
-
+
try {
await RpcApi.ConnEnsureCommand(
TabRpcClient,
@@ -425,7 +432,8 @@ const ChangeConnectionBlockModal = React.memo(
connSelected,
connStatusMap,
fullConfig,
- filterOutNowsh
+ filterOutNowsh,
+ hasGitBash
);
const remoteSuggestions = getRemoteSuggestions(
connList,
diff --git a/frontend/app/store/connections-model.ts b/frontend/app/store/connections-model.ts
new file mode 100644
index 0000000000..55479545f6
--- /dev/null
+++ b/frontend/app/store/connections-model.ts
@@ -0,0 +1,51 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { RpcApi } from "@/app/store/wshclientapi";
+import { TabRpcClient } from "@/app/store/wshrpcutil";
+import { isWindows } from "@/util/platformutil";
+import { atom, type Atom, type PrimitiveAtom } from "jotai";
+import { globalStore } from "./jotaiStore";
+
+class ConnectionsModel {
+ private static instance: ConnectionsModel;
+ gitBashPathAtom: PrimitiveAtom = atom("") as PrimitiveAtom;
+ hasGitBashAtom: Atom;
+
+ private constructor() {
+ this.hasGitBashAtom = atom((get) => {
+ if (!isWindows()) {
+ return false;
+ }
+ const path = get(this.gitBashPathAtom);
+ return path !== "";
+ });
+ this.loadGitBashPath();
+ }
+
+ static getInstance(): ConnectionsModel {
+ if (!ConnectionsModel.instance) {
+ ConnectionsModel.instance = new ConnectionsModel();
+ }
+ return ConnectionsModel.instance;
+ }
+
+ async loadGitBashPath(rescan: boolean = false): Promise {
+ if (!isWindows()) {
+ return;
+ }
+ try {
+ const path = await RpcApi.FindGitBashCommand(TabRpcClient, rescan, { timeout: 2000 });
+ globalStore.set(this.gitBashPathAtom, path);
+ } catch (error) {
+ console.error("Failed to find git bash path:", error);
+ globalStore.set(this.gitBashPathAtom, "");
+ }
+ }
+
+ getGitBashPath(): string {
+ return globalStore.get(this.gitBashPathAtom);
+ }
+}
+
+export { ConnectionsModel };
diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts
index 47a5441627..35eb9f1585 100644
--- a/frontend/app/store/global.ts
+++ b/frontend/app/store/global.ts
@@ -17,7 +17,14 @@ import {
import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil";
import { setPlatform } from "@/util/platformutil";
-import { base64ToString, deepCompareReturnPrev, fireAndForget, getPrefixedSettings, isBlank } from "@/util/util";
+import {
+ base64ToString,
+ deepCompareReturnPrev,
+ fireAndForget,
+ getPrefixedSettings,
+ isBlank,
+ isLocalConnName,
+} from "@/util/util";
import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai";
import { globalStore } from "./jotaiStore";
import { modalsModel } from "./modalmodel";
@@ -730,8 +737,7 @@ function getConnStatusAtom(conn: string): PrimitiveAtom {
const connStatusMap = globalStore.get(ConnStatusMapAtom);
let rtn = connStatusMap.get(conn);
if (rtn == null) {
- if (isBlank(conn)) {
- // create a fake "local" status atom that's always connected
+ if (isLocalConnName(conn)) {
const connStatus: ConnStatus = {
connection: conn,
connected: true,
diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts
index dfb6ab8aec..59945312d9 100644
--- a/frontend/app/store/wshclientapi.ts
+++ b/frontend/app/store/wshclientapi.ts
@@ -282,6 +282,11 @@ class RpcApiType {
return client.wshRpcCall("filewrite", data, opts);
}
+ // command "findgitbash" [call]
+ FindGitBashCommand(client: WshClient, data: boolean, opts?: RpcOpts): Promise {
+ return client.wshRpcCall("findgitbash", data, opts);
+ }
+
// command "focuswindow" [call]
FocusWindowCommand(client: WshClient, data: string, opts?: RpcOpts): Promise {
return client.wshRpcCall("focuswindow", data, opts);
diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts
index dc150711e0..8ecaef08cd 100644
--- a/frontend/app/view/term/termwrap.ts
+++ b/frontend/app/view/term/termwrap.ts
@@ -526,14 +526,14 @@ export class TermWrap {
oref: WOS.makeORef("block", this.blockId),
});
- if (rtInfo["shell:integration"]) {
+ if (rtInfo && rtInfo["shell:integration"]) {
const shellState = rtInfo["shell:state"] as ShellIntegrationStatus;
globalStore.set(this.shellIntegrationStatusAtom, shellState || null);
} else {
globalStore.set(this.shellIntegrationStatusAtom, null);
}
- const lastCmd = rtInfo["shell:lastcmd"];
+ const lastCmd = rtInfo ? rtInfo["shell:lastcmd"] : null;
globalStore.set(this.lastCommandAtom, lastCmd || null);
} catch (e) {
console.log("Error loading runtime info:", e);
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index e481590bf7..23a9008399 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -1082,6 +1082,7 @@ declare global {
"term:disablewebgl"?: boolean;
"term:localshellpath"?: string;
"term:localshellopts"?: string[];
+ "term:gitbashpath"?: string;
"term:scrollback"?: number;
"term:copyonselect"?: boolean;
"term:transparency"?: number;
diff --git a/frontend/util/util.ts b/frontend/util/util.ts
index 6245d78147..2fdc793bee 100644
--- a/frontend/util/util.ts
+++ b/frontend/util/util.ts
@@ -12,6 +12,13 @@ function isBlank(str: string): boolean {
return str == null || str == "";
}
+function isLocalConnName(connName: string): boolean {
+ if (isBlank(connName)) {
+ return true;
+ }
+ return connName === "local" || connName.startsWith("local:");
+}
+
function base64ToString(b64: string): string {
if (b64 == null) {
return null;
@@ -509,6 +516,7 @@ export {
getPromiseState,
getPromiseValue,
isBlank,
+ isLocalConnName,
jotaiLoadableValue,
jsonDeepEqual,
lazy,
diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go
index 77b7178a94..a255c680a4 100644
--- a/pkg/blockcontroller/blockcontroller.go
+++ b/pkg/blockcontroller/blockcontroller.go
@@ -83,7 +83,7 @@ func getController(blockId string) Controller {
func registerController(blockId string, controller Controller) {
var existingController Controller
-
+
registryLock.Lock()
existing, exists := controllerRegistry[blockId]
if exists {
@@ -91,7 +91,7 @@ func registerController(blockId string, controller Controller) {
}
controllerRegistry[blockId] = controller
registryLock.Unlock()
-
+
if existingController != nil {
existingController.Stop(false, Status_Done)
wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId))
@@ -169,8 +169,9 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
// For shell/cmd, check if connection changed
if !needsReplace && (controllerName == BlockController_Shell || controllerName == BlockController_Cmd) {
connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "")
+ // Check if connection changed, including between different local connections
if existingStatus.ShellProcStatus == Status_Running && existingStatus.ShellProcConnName != connName {
- log.Printf("stopping blockcontroller %s due to conn change\n", blockId)
+ log.Printf("stopping blockcontroller %s due to conn change (from %q to %q)\n", blockId, existingStatus.ShellProcConnName, connName)
StopBlockControllerAndSetStatus(blockId, Status_Init)
time.Sleep(100 * time.Millisecond)
// Don't delete, will reuse same controller type
@@ -209,10 +210,10 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
// Check if we need to start/restart
status := controller.GetRuntimeStatus()
if status.ShellProcStatus == Status_Init || status.ShellProcStatus == Status_Done {
- // For shell/cmd, check connection status first
+ // For shell/cmd, check connection status first (for non-local connections)
if controllerName == BlockController_Shell || controllerName == BlockController_Cmd {
connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "")
- if connName != "" {
+ if !conncontroller.IsLocalConnName(connName) {
err = CheckConnStatus(blockId)
if err != nil {
return fmt.Errorf("cannot start shellproc: %w", err)
@@ -362,7 +363,7 @@ func CheckConnStatus(blockId string) error {
return fmt.Errorf("error getting block: %w", err)
}
connName := bdata.Meta.GetString(waveobj.MetaKey_Connection, "")
- if connName == "" {
+ if conncontroller.IsLocalConnName(connName) {
return nil
}
if strings.HasPrefix(connName, "wsl://") {
diff --git a/pkg/blockcontroller/shellcontroller.go b/pkg/blockcontroller/shellcontroller.go
index 2f54c2c365..5411c40404 100644
--- a/pkg/blockcontroller/shellcontroller.go
+++ b/pkg/blockcontroller/shellcontroller.go
@@ -11,6 +11,7 @@ import (
"io/fs"
"log"
"os"
+ "runtime"
"strings"
"sync"
"sync/atomic"
@@ -44,6 +45,10 @@ const (
ConnType_Ssh = "ssh"
)
+const (
+ LocalConnVariant_GitBash = "gitbash"
+)
+
type ShellController struct {
Lock *sync.Mutex
@@ -329,7 +334,10 @@ func (bc *ShellController) getConnUnion(logCtx context.Context, remoteName strin
rtn.ConnType = ConnType_Wsl
rtn.WslConn = wslConn
rtn.WshEnabled = wshEnabled && wslConn.WshEnabled.Load()
- } else if remoteName != "" {
+ } else if conncontroller.IsLocalConnName(remoteName) {
+ rtn.ConnType = ConnType_Local
+ rtn.WshEnabled = wshEnabled
+ } else {
opts, err := remote.ParseOpts(remoteName)
if err != nil {
return ConnUnion{}, fmt.Errorf("invalid ssh remote name (%s): %w", remoteName, err)
@@ -345,9 +353,6 @@ func (bc *ShellController) getConnUnion(logCtx context.Context, remoteName strin
rtn.ConnType = ConnType_Ssh
rtn.SshConn = conn
rtn.WshEnabled = wshEnabled && conn.WshEnabled.Load()
- } else {
- rtn.ConnType = ConnType_Local
- rtn.WshEnabled = wshEnabled
}
err := rtn.getRemoteInfoAndShellType(blockMeta)
if err != nil {
@@ -478,7 +483,7 @@ func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc
}
cmdOpts.ShellPath = connUnion.ShellPath
cmdOpts.ShellOpts = getLocalShellOpts(blockMeta)
- shellProc, err = shellexec.StartLocalShellProc(logCtx, rc.TermSize, cmdStr, cmdOpts)
+ shellProc, err = shellexec.StartLocalShellProc(logCtx, rc.TermSize, cmdStr, cmdOpts, remoteName)
if err != nil {
return nil, err
}
@@ -611,7 +616,11 @@ func (union *ConnUnion) getRemoteInfoAndShellType(blockMeta waveobj.MetaMapType)
// TODO allow overriding remote shell path
union.ShellPath = remoteInfo.Shell
} else {
- union.ShellPath = getLocalShellPath(blockMeta)
+ shellPath, err := getLocalShellPath(blockMeta)
+ if err != nil {
+ return err
+ }
+ union.ShellPath = shellPath
}
union.ShellType = shellutil.GetShellTypeFromShellPath(union.ShellPath)
return nil
@@ -642,16 +651,34 @@ func checkCloseOnExit(blockId string, exitCode int) {
}
}
-func getLocalShellPath(blockMeta waveobj.MetaMapType) string {
+func getLocalShellPath(blockMeta waveobj.MetaMapType) (string, error) {
shellPath := blockMeta.GetString(waveobj.MetaKey_TermLocalShellPath, "")
if shellPath != "" {
- return shellPath
+ return shellPath, nil
+ }
+
+ connName := blockMeta.GetString(waveobj.MetaKey_Connection, "")
+ if strings.HasPrefix(connName, "local:") {
+ variant := strings.TrimPrefix(connName, "local:")
+ if variant == LocalConnVariant_GitBash {
+ if runtime.GOOS != "windows" {
+ return "", fmt.Errorf("connection \"local:gitbash\" is only supported on Windows")
+ }
+ fullConfig := wconfig.GetWatcher().GetFullConfig()
+ gitBashPath := shellutil.FindGitBash(&fullConfig, false)
+ if gitBashPath == "" {
+ return "", fmt.Errorf("connection \"local:gitbash\": git bash not found on this system, please install Git for Windows or set term:localshellpath to specify the git bash location")
+ }
+ return gitBashPath, nil
+ }
+ return "", fmt.Errorf("unsupported local connection type: %q", connName)
}
+
settings := wconfig.GetWatcher().GetFullConfig().Settings
if settings.TermLocalShellPath != "" {
- return settings.TermLocalShellPath
+ return settings.TermLocalShellPath, nil
}
- return shellutil.DetectLocalShellPath()
+ return shellutil.DetectLocalShellPath(), nil
}
func getLocalShellOpts(blockMeta waveobj.MetaMapType) []string {
diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go
index 776db57c27..b5d8779a58 100644
--- a/pkg/remote/conncontroller/conncontroller.go
+++ b/pkg/remote/conncontroller/conncontroller.go
@@ -77,6 +77,10 @@ var ConnServerCmdTemplate = strings.TrimSpace(
"exec %s connserver",
}, "\n"))
+func IsLocalConnName(connName string) bool {
+ return strings.HasPrefix(connName, "local:") || connName == "local" || connName == ""
+}
+
func GetAllConnStatus() []wshrpc.ConnStatus {
globalLock.Lock()
defer globalLock.Unlock()
@@ -833,7 +837,7 @@ func GetConn(opts *remote.SSHOpts) *SSHConn {
// Convenience function for ensuring a connection is established
func EnsureConnection(ctx context.Context, connName string) error {
- if connName == "" {
+ if IsLocalConnName(connName) {
return nil
}
connOpts, err := remote.ParseOpts(connName)
diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go
index 30a9871f0d..597aaab56a 100644
--- a/pkg/shellexec/shellexec.go
+++ b/pkg/shellexec/shellexec.go
@@ -459,7 +459,7 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize
return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
}
-func StartLocalShellProc(logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType) (*ShellProc, error) {
+func StartLocalShellProc(logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, connName string) (*ShellProc, error) {
shellutil.InitCustomShellStartupFiles()
var ecmd *exec.Cmd
var shellOpts []string
@@ -562,7 +562,7 @@ func StartLocalShellProc(logCtx context.Context, termSize waveobj.TermSize, cmdS
return nil, err
}
cmdWrap := MakeCmdWrap(ecmd, cmdPty)
- return &ShellProc{Cmd: cmdWrap, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
+ return &ShellProc{Cmd: cmdWrap, ConnName: connName, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
}
func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize waveobj.TermSize) ([]byte, error) {
diff --git a/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go
index a6268bf5dd..265fdd5fff 100644
--- a/pkg/util/shellutil/shellutil.go
+++ b/pkg/util/shellutil/shellutil.go
@@ -20,8 +20,10 @@ import (
"github.com/wavetermdev/waveterm/pkg/util/envutil"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
+ "github.com/wavetermdev/waveterm/pkg/utilds"
"github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wconfig"
)
var (
@@ -60,6 +62,8 @@ var cachedMacUserShell string
var macUserShellOnce = &sync.Once{}
var userShellRegexp = regexp.MustCompile(`^UserShell: (.*)$`)
+var gitBashCache = utilds.MakeSyncCache(findInstalledGitBash)
+
const DefaultShellPath = "/bin/bash"
const (
@@ -132,6 +136,81 @@ func internalMacUserShell() string {
return m[1]
}
+func hasDirPart(dir string, part string) bool {
+ dir = filepath.Clean(dir)
+ part = strings.ToLower(part)
+ for {
+ base := strings.ToLower(filepath.Base(dir))
+ if base == part {
+ return true
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ return false
+}
+
+func FindGitBash(config *wconfig.FullConfigType, rescan bool) string {
+ if runtime.GOOS != "windows" {
+ return ""
+ }
+
+ if config != nil && config.Settings.TermGitBashPath != "" {
+ return config.Settings.TermGitBashPath
+ }
+
+ path, _ := gitBashCache.Get(rescan)
+ return path
+}
+
+func findInstalledGitBash() (string, error) {
+ // Try PATH first (skip system32, and only accept if in a Git directory)
+ pathEnv := os.Getenv("PATH")
+ pathDirs := filepath.SplitList(pathEnv)
+ for _, dir := range pathDirs {
+ dir = strings.Trim(dir, `"`)
+ if hasDirPart(dir, "system32") {
+ continue
+ }
+ if !hasDirPart(dir, "git") {
+ continue
+ }
+ bashPath := filepath.Join(dir, "bash.exe")
+ if _, err := os.Stat(bashPath); err == nil {
+ return bashPath, nil
+ }
+ }
+
+ // Try scoop location
+ userProfile := os.Getenv("USERPROFILE")
+ if userProfile != "" {
+ scoopPath := filepath.Join(userProfile, "scoop", "apps", "git", "current", "bin", "bash.exe")
+ if _, err := os.Stat(scoopPath); err == nil {
+ return scoopPath, nil
+ }
+ }
+
+ // Try LocalAppData\programs\git\bin
+ localAppData := os.Getenv("LOCALAPPDATA")
+ if localAppData != "" {
+ localPath := filepath.Join(localAppData, "programs", "git", "bin", "bash.exe")
+ if _, err := os.Stat(localPath); err == nil {
+ return localPath, nil
+ }
+ }
+
+ // Try C:\Program Files\Git\bin
+ programFilesPath := filepath.Join("C:\\", "Program Files", "Git", "bin", "bash.exe")
+ if _, err := os.Stat(programFilesPath); err == nil {
+ return programFilesPath, nil
+ }
+
+ return "", nil
+}
+
func DefaultTermSize() waveobj.TermSize {
return waveobj.TermSize{Rows: DefaultTermRows, Cols: DefaultTermCols}
}
diff --git a/pkg/utilds/synccache.go b/pkg/utilds/synccache.go
new file mode 100644
index 0000000000..b5ddb52206
--- /dev/null
+++ b/pkg/utilds/synccache.go
@@ -0,0 +1,33 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package utilds
+
+import "sync"
+
+type SyncCache[T any] struct {
+ lock sync.Mutex
+ computeFn func() (T, error)
+ value T
+ err error
+ cached bool
+}
+
+func MakeSyncCache[T any](computeFn func() (T, error)) *SyncCache[T] {
+ return &SyncCache[T]{
+ computeFn: computeFn,
+ }
+}
+
+func (sc *SyncCache[T]) Get(force bool) (T, error) {
+ sc.lock.Lock()
+ defer sc.lock.Unlock()
+
+ if sc.cached && !force {
+ return sc.value, sc.err
+ }
+
+ sc.value, sc.err = sc.computeFn()
+ sc.cached = true
+ return sc.value, sc.err
+}
\ No newline at end of file
diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go
index 55ebf979f0..926e7044c5 100644
--- a/pkg/wconfig/metaconsts.go
+++ b/pkg/wconfig/metaconsts.go
@@ -40,6 +40,7 @@ const (
ConfigKey_TermDisableWebGl = "term:disablewebgl"
ConfigKey_TermLocalShellPath = "term:localshellpath"
ConfigKey_TermLocalShellOpts = "term:localshellopts"
+ ConfigKey_TermGitBashPath = "term:gitbashpath"
ConfigKey_TermScrollback = "term:scrollback"
ConfigKey_TermCopyOnSelect = "term:copyonselect"
ConfigKey_TermTransparency = "term:transparency"
diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go
index aa0441911b..50a1da2474 100644
--- a/pkg/wconfig/settingsconfig.go
+++ b/pkg/wconfig/settingsconfig.go
@@ -87,6 +87,7 @@ type SettingsType struct {
TermDisableWebGl bool `json:"term:disablewebgl,omitempty"`
TermLocalShellPath string `json:"term:localshellpath,omitempty"`
TermLocalShellOpts []string `json:"term:localshellopts,omitempty"`
+ TermGitBashPath string `json:"term:gitbashpath,omitempty"`
TermScrollback *int64 `json:"term:scrollback,omitempty"`
TermCopyOnSelect *bool `json:"term:copyonselect,omitempty"`
TermTransparency *float64 `json:"term:transparency,omitempty"`
diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go
index eeecc0bb31..0490b403fb 100644
--- a/pkg/wshrpc/wshclient/wshclient.go
+++ b/pkg/wshrpc/wshclient/wshclient.go
@@ -344,6 +344,12 @@ func FileWriteCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcO
return err
}
+// command "findgitbash", wshserver.FindGitBashCommand
+func FindGitBashCommand(w *wshutil.WshRpc, data bool, opts *wshrpc.RpcOpts) (string, error) {
+ resp, err := sendRpcRequestCallHelper[string](w, "findgitbash", data, opts)
+ return resp, err
+}
+
// command "focuswindow", wshserver.FocusWindowCommand
func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "focuswindow", data, opts)
diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go
index df07a00a18..0191a4e4e0 100644
--- a/pkg/wshrpc/wshrpctypes.go
+++ b/pkg/wshrpc/wshrpctypes.go
@@ -129,6 +129,7 @@ const (
Command_WslDefaultDistro = "wsldefaultdistro"
Command_DismissWshFail = "dismisswshfail"
Command_ConnUpdateWsh = "updatewsh"
+ Command_FindGitBash = "findgitbash"
Command_WorkspaceList = "workspacelist"
@@ -274,6 +275,7 @@ type WshRpcInterface interface {
WslDefaultDistroCommand(ctx context.Context) (string, error)
DismissWshFailCommand(ctx context.Context, connName string) error
ConnUpdateWshCommand(ctx context.Context, remoteInfo RemoteInfo) (bool, error)
+ FindGitBashCommand(ctx context.Context, rescan bool) (string, error)
// eventrecv is special, it's handled internally by WshRpc with EventListener
EventRecvCommand(ctx context.Context, data wps.WaveEvent) error
diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go
index 41dd5bd4f5..f211d71e7b 100644
--- a/pkg/wshrpc/wshserver/wshserver.go
+++ b/pkg/wshrpc/wshserver/wshserver.go
@@ -647,6 +647,9 @@ func (ws *WshServer) ConnDisconnectCommand(ctx context.Context, connName string)
if strings.HasPrefix(connName, "aws:") {
return nil
}
+ if conncontroller.IsLocalConnName(connName) {
+ return nil
+ }
if strings.HasPrefix(connName, "wsl://") {
distroName := strings.TrimPrefix(connName, "wsl://")
conn := wslconn.GetWslConn(distroName)
@@ -671,6 +674,9 @@ func (ws *WshServer) ConnConnectCommand(ctx context.Context, connRequest wshrpc.
if strings.HasPrefix(connRequest.Host, "aws:") {
return nil
}
+ if conncontroller.IsLocalConnName(connRequest.Host) {
+ return nil
+ }
ctx = genconn.ContextWithConnData(ctx, connRequest.LogBlockId)
ctx = termCtxWithLogBlockId(ctx, connRequest.LogBlockId)
connName := connRequest.Host
@@ -698,6 +704,9 @@ func (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, data wshrpc.Co
if strings.HasPrefix(data.ConnName, "aws:") {
return nil
}
+ if conncontroller.IsLocalConnName(data.ConnName) {
+ return nil
+ }
ctx = genconn.ContextWithConnData(ctx, data.LogBlockId)
ctx = termCtxWithLogBlockId(ctx, data.LogBlockId)
connName := data.ConnName
@@ -826,6 +835,11 @@ func (ws *WshServer) DismissWshFailCommand(ctx context.Context, connName string)
return nil
}
+func (ws *WshServer) FindGitBashCommand(ctx context.Context, rescan bool) (string, error) {
+ fullConfig := wconfig.GetWatcher().GetFullConfig()
+ return shellutil.FindGitBash(&fullConfig, rescan), nil
+}
+
func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) {
blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
if err != nil {
diff --git a/schema/settings.json b/schema/settings.json
index 1b8da17d6d..9492e7bb8d 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -98,6 +98,9 @@
},
"type": "array"
},
+ "term:gitbashpath": {
+ "type": "string"
+ },
"term:scrollback": {
"type": "integer"
},