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" },